-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
Basic deserialization. #1248
Basic deserialization. #1248
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
module ActiveModel | ||
class Serializer | ||
module Adapter | ||
class JsonApi | ||
# NOTE(Experimental): | ||
# This is an experimental feature. Both the interface and internals could be subject | ||
# to changes. | ||
module Deserialization | ||
InvalidDocument = Class.new(ArgumentError) | ||
|
||
module_function | ||
|
||
# Transform a JSON API document, containing a single data object, | ||
# into a hash that is ready for ActiveRecord::Base.new() and such. | ||
# Raises InvalidDocument if the payload is not properly formatted. | ||
# | ||
# @param [Hash|ActionController::Parameters] document | ||
# @param [Hash] options | ||
# only: Array of symbols of whitelisted fields. | ||
# except: Array of symbols of blacklisted fields. | ||
# keys: Hash of translated keys (e.g. :author => :user). | ||
# polymorphic: Array of symbols of polymorphic fields. | ||
# @return [Hash] | ||
# | ||
# @example | ||
# document = { | ||
# data: { | ||
# id: 1, | ||
# type: 'post', | ||
# attributes: { | ||
# title: 'Title 1', | ||
# date: '2015-12-20' | ||
# }, | ||
# associations: { | ||
# author: { | ||
# data: { | ||
# type: 'user', | ||
# id: 2 | ||
# } | ||
# }, | ||
# second_author: { | ||
# data: nil | ||
# }, | ||
# comments: { | ||
# data: [{ | ||
# type: 'comment', | ||
# id: 3 | ||
# },{ | ||
# type: 'comment', | ||
# id: 4 | ||
# }] | ||
# } | ||
# } | ||
# } | ||
# } | ||
# | ||
# parse(document) #=> | ||
# # { | ||
# # title: 'Title 1', | ||
# # date: '2015-12-20', | ||
# # author_id: 2, | ||
# # second_author_id: nil | ||
# # comment_ids: [3, 4] | ||
# # } | ||
# | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this the flow?
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be in a method header comment on the parse method (or something like it) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. except there is no ii. or iv. |
||
# parse(document, only: [:title, :date, :author], | ||
# keys: { date: :published_at }, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! |
||
# polymorphic: [:author]) #=> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure I got this polymorphic thing, can you help me to understand the need? 😄 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nvm! got it! pretty obvious actually 😁 |
||
# # { | ||
# # title: 'Title 1', | ||
# # published_at: '2015-12-20', | ||
# # author_id: '2', | ||
# # author_type: 'people' | ||
# # } | ||
# | ||
def parse!(document, options = {}) | ||
parse(document, options) do |invalid_payload, reason| | ||
fail InvalidDocument, "Invalid payload (#{reason}): #{invalid_payload}" | ||
end | ||
end | ||
|
||
# Same as parse!, but returns an empty hash instead of raising InvalidDocument | ||
# on invalid payloads. | ||
def parse(document, options = {}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The issue with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just meant as a consistent interface
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My question still holds though. As a consistent interface of what? to what? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think calling from_json on json-api data should generate a model / associations / whatever. so, imo, from_json could call this method, |
||
document = document.dup.permit!.to_h if document.is_a?(ActionController::Parameters) | ||
|
||
validate_payload(document) do |invalid_document, reason| | ||
yield invalid_document, reason if block_given? | ||
return {} | ||
end | ||
|
||
primary_data = document['data'] | ||
attributes = primary_data['attributes'] || {} | ||
attributes['id'] = primary_data['id'] if primary_data['id'] | ||
relationships = primary_data['relationships'] || {} | ||
|
||
filter_fields(attributes, options) | ||
filter_fields(relationships, options) | ||
|
||
hash = {} | ||
hash.merge!(parse_attributes(attributes, options)) | ||
hash.merge!(parse_relationships(relationships, options)) | ||
|
||
hash | ||
end | ||
|
||
# Checks whether a payload is compliant with the JSON API spec. | ||
# | ||
# @api private | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this converts options[:fields] into nil or a Hash? Would There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes and nope, respectively There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @beauby looks like you need to add some method header comments :-\ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. # Given an Array
# For each element in the array, built a hash
# Then a symbol element is is used as both the key and value
# And a Hash element is merged into return value
# And all other values in the array are ignored
# @example
# parse_fields_options(['a']) # => nil
# parse_fields_options([:a]) # => { a: :a }
# parse_fields_options([{'a' => :a]) # => { 'a' => :a }
# parse_fields_options(nil) # => nil
# parse_fields_options({}) # => nil
# parse_fields_options(:a) # => nil
# parse_fields_options([[:a]]) # => {}
# parse_fields_options([:a, {a: :b}]) # => { a: :b }
# parse_fields_options([{a: :b}, :a]) # => { a: :a }
# etc
# etc
# @params fields [Array<Hash, Symbol>] object to transform into a hash or nil
# @return [Hash] for array inputs
# @return [Nil] otherwise |
||
# rubocop:disable Metrics/CyclomaticComplexity | ||
def validate_payload(payload) | ||
unless payload.is_a?(Hash) | ||
yield payload, 'Expected hash' | ||
return | ||
end | ||
|
||
primary_data = payload['data'] | ||
unless primary_data.is_a?(Hash) | ||
yield payload, { data: 'Expected hash' } | ||
return | ||
end | ||
|
||
attributes = primary_data['attributes'] || {} | ||
unless attributes.is_a?(Hash) | ||
yield payload, { data: { attributes: 'Expected hash or nil' } } | ||
return | ||
end | ||
|
||
relationships = primary_data['relationships'] || {} | ||
unless relationships.is_a?(Hash) | ||
yield payload, { data: { relationships: 'Expected hash or nil' } } | ||
return | ||
end | ||
|
||
relationships.each do |(key, value)| | ||
unless value.is_a?(Hash) && value.key?('data') | ||
yield payload, { data: { relationships: { key => 'Expected hash with :data key' } } } | ||
end | ||
end | ||
end | ||
# rubocop:enable Metrics/CyclomaticComplexity | ||
|
||
# @api private | ||
def filter_fields(fields, options) | ||
if (only = options[:only]) | ||
fields.slice!(*Array(only).map(&:to_s)) | ||
elsif (except = options[:except]) | ||
fields.except!(*Array(except).map(&:to_s)) | ||
end | ||
end | ||
|
||
# @api private | ||
def field_key(field, options) | ||
(options[:keys] || {}).fetch(field.to_sym, field).to_sym | ||
end | ||
|
||
# @api private | ||
def parse_attributes(attributes, options) | ||
attributes | ||
.map { |(k, v)| { field_key(k, options) => v } } | ||
.reduce({}, :merge) | ||
end | ||
|
||
# Given an association name, and a relationship data attribute, build a hash | ||
# mapping the corresponding ActiveRecord attribute to the corresponding value. | ||
# | ||
# @example | ||
# parse_relationship(:comments, [{ 'id' => '1', 'type' => 'comments' }, | ||
# { 'id' => '2', 'type' => 'comments' }], | ||
# {}) | ||
# # => { :comment_ids => ['1', '2'] } | ||
# parse_relationship(:author, { 'id' => '1', 'type' => 'users' }, {}) | ||
# # => { :author_id => '1' } | ||
# parse_relationship(:author, nil, {}) | ||
# # => { :author_id => nil } | ||
# @param [Symbol] assoc_name | ||
# @param [Hash] assoc_data | ||
# @param [Hash] options | ||
# @return [Hash{Symbol, Object}] | ||
# | ||
# @api private | ||
def parse_relationship(assoc_name, assoc_data, options) | ||
prefix_key = field_key(assoc_name, options).to_s.singularize | ||
hash = | ||
if assoc_data.is_a?(Array) | ||
{ "#{prefix_key}_ids".to_sym => assoc_data.map { |ri| ri['id'] } } | ||
else | ||
{ "#{prefix_key}_id".to_sym => assoc_data ? assoc_data['id'] : nil } | ||
end | ||
|
||
polymorphic = (options[:polymorphic] || []).include?(assoc_name.to_sym) | ||
hash.merge!("#{prefix_key}_type".to_sym => assoc_data['type']) if polymorphic | ||
|
||
hash | ||
end | ||
|
||
# @api private | ||
def parse_relationships(relationships, options) | ||
relationships | ||
.map { |(k, v)| parse_relationship(k, v['data'], options) } | ||
.reduce({}, :merge) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
module ActiveModelSerializers | ||
module Deserialization | ||
module_function | ||
|
||
def jsonapi_parse(*args) | ||
ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse(*args) | ||
end | ||
|
||
def jsonapi_parse!(*args) | ||
ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(*args) | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
require 'test_helper' | ||
|
||
module ActionController | ||
module Serialization | ||
class JsonApi | ||
class DeserializationTest < ActionController::TestCase | ||
class DeserializationTestController < ActionController::Base | ||
def render_parsed_payload | ||
parsed_hash = ActiveModelSerializers::Deserialization.jsonapi_parse(params) | ||
render json: parsed_hash | ||
end | ||
end | ||
|
||
tests DeserializationTestController | ||
|
||
def test_deserialization | ||
hash = { | ||
'data' => { | ||
'type' => 'photos', | ||
'id' => 'zorglub', | ||
'attributes' => { | ||
'title' => 'Ember Hamster', | ||
'src' => 'http://example.com/images/productivity.png' | ||
}, | ||
'relationships' => { | ||
'author' => { | ||
'data' => nil | ||
}, | ||
'photographer' => { | ||
'data' => { 'type' => 'people', 'id' => '9' } | ||
}, | ||
'comments' => { | ||
'data' => [ | ||
{ 'type' => 'comments', 'id' => '1' }, | ||
{ 'type' => 'comments', 'id' => '2' } | ||
] | ||
} | ||
} | ||
} | ||
} | ||
|
||
post :render_parsed_payload, hash | ||
|
||
response = JSON.parse(@response.body) | ||
expected = { | ||
'id' => 'zorglub', | ||
'title' => 'Ember Hamster', | ||
'src' => 'http://example.com/images/productivity.png', | ||
'author_id' => nil, | ||
'photographer_id' => '9', | ||
'comment_ids' => %w(1 2) | ||
} | ||
|
||
assert_equal(expected, response) | ||
end | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
or
ActiveModelSerializers::Model.new
, etc. :).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kinda true, although the whole
blah_id
/blah_ids
is not really handled by non-AR models, right?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I leave that up to the non-AR implementor :)