jsonapi-deserializable/lib/jsonapi/deserializable/resource.rb

234 lines
7.6 KiB
Ruby

require 'jsonapi/deserializable/resource/dsl'
module JSONAPI
module Deserializable
class Resource
extend DSL
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
end
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)
super
klass.type_block = type_block
klass.id_block = id_block
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
klass.key_formatter = key_formatter
end
def self.call(payload)
new(payload).to_h
end
def initialize(payload, root: '/data')
@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
end
def to_hash
@hash
end
alias to_h to_hash
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,
deserialize_attrs, deserialize_rels]
@hash = hashes.reduce({}, :merge)
end
def deserialize_type
block = self.class.type_block
return {} unless block
hash = block.call(@type)
register_mappings(hash.keys, '/type')
hash
end
def deserialize_id
block = self.class.id_block
return {} unless @id && block
hash = block.call(@id)
register_mappings(hash.keys, '/id')
hash
end
def deserialize_attrs
@attributes
.map { |key, val| deserialize_attr(key, val) }
.reduce({}, :merge)
end
def deserialize_attr(key, val)
block = self.class.attr_blocks[key] || self.class.default_attr_block
return {} unless block
hash = block.call(val, self.class.key_formatter.call(key))
register_mappings(hash.keys, "/attributes/#{key}")
hash
end
def deserialize_rels
@relationships
.map { |key, val| deserialize_rel(key, val) }
.reduce({}, :merge)
end
def deserialize_rel(key, val)
if val['data'].is_a?(Array)
deserialize_has_many_rel(key, val)
else
deserialize_has_one_rel(key, val)
end
end
# rubocop: disable Metrics/AbcSize
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
# rubocop: disable Metrics/AbcSize
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
end