diff --git a/README.md b/README.md index 3866cf2..cacba0a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,57 @@ Ruby gem for deserializing [JSON API](http://jsonapi.org) payloads into custom hashes. + + +## Usage + +#### Support for included documents + +To insert the included documents to ``has_one`` and ``has_many`` relation ship, use the ``with_included: true`` option to the relationship: + +```ruby +class DeserializableBook < JSONAPI::Deserializable::Resource + id + type + attributes :id, + :title + + has_one :author, with_included: true +end +``` + + + +To use a custom deserializer for the included relationship, use the ``deserializer`` option: + +```ruby +class DeserializableBook < JSONAPI::Deserializable::Resource + id + type + attributes :id, + :title + + has_one :author, with_included: true, deserializer: DeserialzableAuthor +end +``` + + + +If the property name is different than the included object type, pass the ``type`` option: + + + +```ruby +class DeserializableBook < JSONAPI::Deserializable::Resource + id + type + attributes :id, + :title + + has_one :author, with_included: true, deserializer: DeserializablePerson, type: 'people' +end +``` + ## Status [![Gem Version](https://badge.fury.io/rb/jsonapi-deserializable.svg)](https://badge.fury.io/rb/jsonapi-deserializable) diff --git a/lib/jsonapi/deserializable/resource.rb b/lib/jsonapi/deserializable/resource.rb index c46e4ad..1aef5e5 100644 --- a/lib/jsonapi/deserializable/resource.rb +++ b/lib/jsonapi/deserializable/resource.rb @@ -8,6 +8,7 @@ class Resource class << self attr_accessor :type_block, :id_block, :attr_blocks, :has_one_rel_blocks, :has_many_rel_blocks, + :has_one_rel_options, :has_many_rel_options, :default_attr_block, :default_has_one_rel_block, :default_has_many_rel_block, :key_formatter @@ -16,6 +17,10 @@ class << self self.attr_blocks = {} self.has_one_rel_blocks = {} self.has_many_rel_blocks = {} + + self.has_one_rel_options = {} + self.has_many_rel_options = {} + self.key_formatter = proc { |k| k } def self.inherited(klass) @@ -25,6 +30,10 @@ def self.inherited(klass) klass.attr_blocks = attr_blocks.dup klass.has_one_rel_blocks = has_one_rel_blocks.dup klass.has_many_rel_blocks = has_many_rel_blocks.dup + + klass.has_one_rel_options = has_one_rel_options.dup + klass.has_many_rel_options = has_many_rel_options.dup + klass.default_attr_block = default_attr_block klass.default_has_one_rel_block = default_has_one_rel_block klass.default_has_many_rel_block = default_has_many_rel_block @@ -36,12 +45,17 @@ def self.call(payload) end def initialize(payload, root: '/data') - @data = payload || {} + @data = ((payload || {}).key?('data') ? payload['data'] : payload) || {} + @root = root @type = @data['type'] @id = @data['id'] @attributes = @data['attributes'] || {} @relationships = @data['relationships'] || {} + + # Objectifies each included object + @included = initialize_included(payload.key?('included') ? payload['included'] : []) + deserialize! freeze @@ -52,16 +66,60 @@ def to_hash end alias to_h to_hash - attr_reader :reverse_mapping + attr_reader :reverse_mapping, :key_to_type_mapping private + def initialize_included(included) + return nil unless included.present? + + # For each included, create an object of the correct type + included.map do |data| + + # Find the key of type + key = key_to_type_mapping_inverted[data['type']&.to_s&.to_sym] + + # Finds the deserializer + deserializer = merged_rel_options&.[](key)&.[](:deserializer) + + # If the deserializer is not available, uses the current class to create the object + if deserializer.blank? + # Important to wrap this around this hash. This will be crucial for use in method `find_in_included/2` defined in the same class. + # If the deserializer is created using the current class, we will need to pluck all its attributes + { has_deserializer: false, object: self.class.new({ 'data' => data }) } + else + + # If the deserializer is created using a given class, we will need to call .to_h on it instead of plucking all its attributes + { has_deserializer: true, object: deserializer.new({ 'data' => data }) } + end + end + end + + def included_types + return [] unless @included.present? + @included.map { |doc| doc.instance_variable_get(:@type) }.uniq + end + def register_mappings(keys, path) keys.each do |k| @reverse_mapping[k] = @root + path end end + def key_to_type_mapping_inverted + # Goes through the options of has_many / has_one and creates a hash of type => key + # Example: { books: 'books', people: 'author' } + # In the example above, people is the type of the objects in "included", but the name of the key is 'author' + # It creates this mapping so that to find the right derserializer for the given key (if any) + self.class.has_one_rel_options.map { |h, k| { h => k[:type]} }.reduce({}, :merge).invert.merge( + self.class.has_many_rel_options.map { |h, k| { h => k[:type]} }.reduce({}, :merge) + ) + end + + def merged_rel_options + self.class.has_one_rel_options.merge(self.class.has_many_rel_options) + end + def deserialize! @reverse_mapping = {} hashes = [deserialize_type, deserialize_id, @@ -103,7 +161,7 @@ def deserialize_attr(key, val) end def deserialize_rels - @relationships + @relationships .map { |key, val| deserialize_rel(key, val) } .reduce({}, :merge) end @@ -120,12 +178,21 @@ def deserialize_rel(key, val) def deserialize_has_one_rel(key, val) block = self.class.has_one_rel_blocks[key] || self.class.default_has_one_rel_block + + options = self.class.has_one_rel_options[key] || {} + return {} unless block id = val['data'] && val['data']['id'] type = val['data'] && val['data']['type'] hash = block.call(val, id, type, self.class.key_formatter.call(key)) + register_mappings(hash.keys, "/relationships/#{key}") + + if options.[](:with_included) + return {**hash, key.to_sym => find_in_included(id:, type:)} + end + hash end # rubocop: enable Metrics/AbcSize @@ -134,14 +201,32 @@ def deserialize_has_one_rel(key, val) def deserialize_has_many_rel(key, val) block = self.class.has_many_rel_blocks[key] || self.class.default_has_many_rel_block + + + options = self.class.has_many_rel_options[key] || {} + return {} unless block && val['data'].is_a?(Array) ids = val['data'].map { |ri| ri['id'] } types = val['data'].map { |ri| ri['type'] } hash = block.call(val, ids, types, self.class.key_formatter.call(key)) + register_mappings(hash.keys, "/relationships/#{key}") + + if options.[](:with_included) + return {**hash, key.to_sym => ids.map { |id| find_in_included(id: id, type: types[ids.index(id)]) }} + end + hash end + + def find_in_included(id:, type:) + # Cross referencing the relationship id and type with the included objects + cross_reference = @included.select { |doc| doc[:object]&.instance_variable_get(:@id) == id && doc[:object].instance_variable_get(:@type) == type }&.first + + # If the deserializer is created using a given class, we will need to call .to_h on it instead of plucking all its attributes + cross_reference[:has_deserializer] ? cross_reference[:object].to_h : cross_reference[:object].instance_variable_get(:@attributes).to_h + end # rubocop: enable Metrics/AbcSize end end diff --git a/lib/jsonapi/deserializable/resource/dsl.rb b/lib/jsonapi/deserializable/resource/dsl.rb index ffe37fb..5e2fd32 100644 --- a/lib/jsonapi/deserializable/resource/dsl.rb +++ b/lib/jsonapi/deserializable/resource/dsl.rb @@ -32,17 +32,19 @@ def attributes(*keys, &block) end end - def has_one(key = nil, &block) + def has_one(key = nil, with_included: false, deserializer: nil, type: key, &block) if key has_one_rel_blocks[key.to_s] = block || DEFAULT_HAS_ONE_BLOCK + has_one_rel_options[key.to_s] = { with_included:, deserializer:, type: type&.to_s&.to_sym } else self.default_has_one_rel_block = block || DEFAULT_HAS_ONE_BLOCK end end - def has_many(key = nil, &block) + def has_many(key = nil, with_included: false, deserializer: nil, type: key, &block) if key has_many_rel_blocks[key.to_s] = block || DEFAULT_HAS_MANY_BLOCK + has_many_rel_options[key.to_s] = { with_included:, deserializer:, type: type&.to_s&.to_sym } else self.default_has_many_rel_block = block || DEFAULT_HAS_MANY_BLOCK end