GitHub - bitex-la/jsonapi-mapper: Sanitize and Read a JSONAPI document with included resources into your ActiveRecord objects.

Sanitizes a jsonapi Document and maps it to ActiveRecord, creating or updating as needed.

$ gem install jsonapi_mapper

See the specs directory for more examples.

      class Person < ActiveRecord::Base
        belongs_to :parent, class_name: 'Person'
        has_many :children, class_name: 'Person', foreign_key: 'parent_id'
        belongs_to :pet, class_name: 'PetDog'
      end

      class PetDog < ActiveRecord::Base
        has_one :person, foreign_key: 'pet_id'
      end

      # This document should create a person and several associations.
      # Notice how these not-persisted resources can be referenced using
      # an internal id, which starts with @
      # The local @ ids shall be replaced with proper server assigned ids
      # once the resources are persisted.
      document = {
        data: {
          type: 'people',
          attributes: { name: 'ian', admin: true },
          relationships: {
            pet: { data: { type: 'pet_dogs', id: '@1' }},
            parent: { data: { type: 'people', id: '@1' }},
            children: { data: [
              { type: 'people', id: '@2' },
              { type: 'people', id: '@3' },
            ]},
          }
        },
        included: [
          { type: 'people', id: '@1', attributes: { name: 'ana', admin: true } },
          { type: 'people', id: '@2', attributes: { name: 'bob', admin: true } },
          { type: 'people', id: '@3', attributes: { name: 'zoe', admin: true } },
          { type: 'pet_dogs', id: '@1', attributes: { name: 'ace', age: 11 } }
        ]
      }

      # The mapper whitelists which types should be expected from the
      # jsonapi document. It also whitelists attributes and relationship names.
      # The last item of the attributes list is a Hash to be used as 'scope'
      # when attempting to fetch and/or modify any resource.
      mapper = JsonapiMapper.doc(document,
        people: [:name, :pet, :parent, :children, country: 'argentina'],
        pet_dogs: [:name, country: 'argentina']
      )

      # The document data lives in mapper.data
      # It could be a simple or multiple resource response.
      # If you want to check wether the document had a single resource
      # or a collection as its primary data you can use the following methods.
      mapper.collection? # Was primary document data a collection?
      mapper.single? # Was primary document data a single resource?


      # The rest of the included resources live in mapper.included
      others = mapper.included 

      # Attempts to save both data and included. Returns false if there
      # were any validation errors.
      mapper.save_all 
      
      # Four people have been created
      Person.count.should == 4

      # All of them from 'argentina' according to the provided scope.
      Person.where(country: 'argentina').count.should == Person.count

      # The 'admin' field was not set, because it wasn't in the mapper list.
      Person.where(admin: true).count.should == 0
      
      # This other document tries to update a bob's name and parent.
      # And it also creates a new dow and assigns it as pet for 'bob' and 'ana'
      other_document = {
        data: {
          type: 'people',
          id: '1',
          attributes: { name: 'rob' },
          relationships: {
            pet: { data: { type: 'pet_dogs', id: '@1' }},
            parent: { data: { type: 'people', id: '2' }},
          }
        },
        included: [
          { 
            type: 'people',
            id: ana.id,
            relationships: {
              pet: { data: { type: 'pet_dogs', id: '@1' }},
            }
          },
          { type: 'pet_dogs', id: '@1', attributes: { name: 'ace' } }
        ]
      }

      mapper = JsonapiMapper.doc other_document,
        people: [:name, :pet, :parent, country: 'uruguay'],
        pet_dogs: [:name, country: 'uruguay']

      mapper.save_all

      # Is dangerous to use unscoped queries
      # For those rare occassions where you don't need them they can be disabled.
      # The JsonapiMapper.doc_unsafe! method receives an argument with the names
      # of all the types for which a scope is not required.
      JsonapiMapper.doc_unsafe! document,
        [:pet_dogs],
        people: [:name, :pet, :parent, country: 'uruguay'],
        pet_dogs: [:name]

      # If you're needing to 'translate' between your jsonapi document names
      # and your ActiveRecord class and column names, you can do it like so:
      # Notice how the second hash has translations for type and attribute names.
      mapper = JsonapiMapper.doc(document, {
        persons: [:handle, :dog, :parental_figure, country: 'uruguay'],
        pets: [:nickname, country: 'uruguay']
      },
      { types: { persons: Person, pets: PetDog },
        attributes: {
          persons: {handle: :name, dog: :pet, parental_figure: :parent},
          pets: {nickname: :name},
        }  
      }).save_all


      # If any resource in your document has errors, you can get a collection
      # with pointers to the specific fields and the type and id of the resource
      # that has the error.
      document = {
        data: [
          { type: 'pets', attributes: { age: 3 } },
          { type: 'pets', attributes: { age: 6 } },
        ],
        included: [
          { type: 'pets', id: '@1', attributes: { age: 4 } }
        ]
      }
      mapper = JsonapiMapper.doc(document,
        { pets: [:nickname, country: 'uruguay'] },
        { types: { pets: PetDog }, attributes: { pets: {nickname: :name} } }
      )

      # all_valid? triggers all validations and sets up errors.
      mapper.all_valid?.should be_falsey

      # Then all errors are presented like so, honoring remapped names too.
      mapper.all_errors.should == {
        errors: [
          { status: 422,
            title: "can't be blank",
            detail: "can't be blank",
            code: "can_t_be_blank",
            meta: {type: "pets"},
            source: {pointer: "/data/0/attributes/nickname"}
          },
          { status: 422,
            title: "can't be blank",
            detail: "can't be blank",
            code: "can_t_be_blank",
            meta: {type: "pets"},
            source: {pointer: "/data/1/attributes/nickname"}
          },
          { status: 422,
            title: "can't be blank",
            detail: "can't be blank",
            code: "can_t_be_blank",
            meta: {type: "pets"},
            source: {pointer: "/included/0/attributes/nickname"}
          }
        ]
      }

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.

Bug reports and pull requests are welcome here.