Golden Fleece 🐑

Easy schemas for JSON columns in your Ruby data models. Currently supports ActiveRecord/ActiveModel >= 3.0 (i.e., Rails 3, 4 and 5).

Golden Fleece lets you define a schema for your Ruby data models, which can be used to do fun things:

  • Validate JSON data types and formats
  • Normalize JSON data
  • Provide default values within nested JSON

It's like JSON Schema but more opinionated and, in our opinion, more straightforward to use.

🍊 Battle-tested at Instacart.

Quick start

Add this line to your application's Gemfile:

gem 'golden_fleece'

Then include GoldenFleece::Model and define schemas in your data models:

class Person < ActiveRecord::Base
  include GoldenFleece::Model

  fleece do
    define_schemas :profile, {
      first_name: { type: :string },
      last_name:  { types: [:string, :null] },
      zip_code:   { type: :string, default: '90210' }
    }
  end
end

person.profile['first_name'] = 'Jane'
person.profile['last_name'] = nil
person.valid?        # true
person.export_fleece # { profile: { 'first_name' => 'Jane', 'last_name' => nil, 'zip_code' => '90210' } }

person.profile.delete 'first_name'
person.valid? # false

Usage

Schemas

Golden Fleece's core concept is the schema. A schema is a structure that defines what your JSON columns should look like and are defined within the fleece block on a model using define_schemas:

class Person < ActiveRecord::Base
  include GoldenFleece::Model

  fleece do
    define_schemas :profile, {
      first_name: { type: :string },
      last_name:  { types: [:string, :null] },
      zip_code:   { type: :string, default: '90210' }
    }
  end
end

The above example defines a schema on the Person model's profile column and introduces certain restraints on the first_name, last_name and zip_code fields within the profile column's JSON object. Note that Golden Fleece assumes that all columns with a schema are valid JSON objects.

Note that any keys added to a JSON object that aren't listed in the schema are invalid:

define_schemas :profile, {
  first_name: ...,
  last_name: ...,
  zip_code: ...,
}

person.profile['address'] = '123 Nottingham Way'
person.valid? # false

Types

Type checks are introduced with the type or types option (both are interchangeable):

define_schemas :profile, {
  zip_code: { type: :string }
}

person.profile['zip_code'] = 90210
person.valid? # false

Note that passing :null to type/types allows the field to be nullable.

Defaults

Defaults defined on schemas will fill in nil values in your JSON columns when validating and exporting. Defaults are safe and will never backfill your model's columns:

define_schemas :profile, {
  zip_code: { type: :string, default: '90210' }
}

person.profile['zip_code'] = nil
person.valid?              # true
person.export_fleece       # { profile: { 'zip_code' => '90210' } }
person.profile['zip_code'] # nil

In addition to static values, you can use Proc's to dynamically generate defaults at runtime:

define_schemas :profile, {
  zip_code: { type: :string, default: -> record { record.closest_location.zip_code } }
}

person.export_fleece # { profile: { 'zip_code' => '94131' } }

Getters

Top-level keys in your JSON columns can automatically be mapped as getters on your data model's instances using define_getters. Getters are safe and will never override any preexisting instance methods:

define_schemas :profile, {
  zip_code: ...,
  class: ...
}
define_getters :profile

person.zip_code            # '90210'
person.profile['zip_code'] # nil

person.profile['class'] = 'Freshman'
person.class # Person

Note that getters will return the exported value of your JSON key rather than the raw value.

Normalizers

Normalizers are Procs that normalize your data before validating, exporting or saving:

define_normalizers({
  cast_string: -> record, value { value.to_s }
})

define_schemas :profile, {
  zip_code: { type: :string, normalizer: :cast_string }
}

person.profile['zip_code'] = 90210
person.profile['zip_code'] # 90210
person.zip_code            # '90210'
person.valid?              # true

person.save
person.profile['zip_code'] # '90210'
person.zip_code            # '90210'

Note that multiple normalizers can be chained with normalizers:

define_normalizers({
  cast_string: ...,
  sha1: -> record, value { sha1(value) }
})

define_schemas :profile, {
  zip_code: { type: :string, normalizers: [:cast_string, :sha1] }
}

person.profile['zip_code'] = 90210
person.zip_code            # '2b02dbc1030b278245b2b9cb11667eebf7275a52'

Be careful! Normalizers can change your data when saving your record. Make sure your normalizer doesn't make invalid assumptions about types, etc.:

define_normalizers({
  csv_to_array: -> record, value { value.to_s.split(/\s*,\s*/) }
})

define_schemas :settings, {
  important_ids: { type: :array, normalizer: :csv_to_array }
}

define_getters :settings

person.profile['important_ids'] = '1001, 1002,1003'
person.important_ids # [1001, 1002, 1003] (as expected)
person.save          # normalizer persists the array in place of the CSV string
person.important_ids # [0, 1002, 1003] (not expected! normalizer is trying to convert your array to a string, then splitting it on commas)

Formats

Formats are Procs that can be used to enforce complex validations:

define_formats({
  zip_code: -> record, value { raise ArgumentError.new("must be a valid ZIP code") unless value =~ /^[0-9]{5}(?:-[0-9]{4})?$/ }
})

define_schemas :profile, {
  zip_code: { type: :string, format: :zip_code }
}

person.profile['zip_code'] = '90210'      # person.valid? == true
person.profile['zip_code'] = '90210-1234' # person.valid? == true
person.profile['zip_code'] = '90210-12'   # person.valid? == false
person.errors.messages                    # "Invalid format at '/zip_code' on column 'profile': must be a valid ZIP code"

Note that unlike types and normalizers, you can only use one format at a time for each schema.

Nested JSON

Schemas can be nested with subschemas:

define_schemas :profile, {
  address: { type: :object, subschemas: {
      number: { type: :number },
      street: { type: :string },
      zip_code: { type: :string, default: '90210' }
    }
  }
}

person.profile['address'] # nil
person.address            # { number: nil, street: nil, zip_code: '90210' }
person.valid?             # false

Exporting

TODO

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Using Golden Fleece with other ORM's

TODO

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/earksiinni/golden_fleece. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.