Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds support for included and custom deserializers #25

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
91 changes: 88 additions & 3 deletions lib/jsonapi/deserializable/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions lib/jsonapi/deserializable/resource/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down