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

Basic deserialization. #1248

Merged
merged 1 commit into from
Jan 13, 2016
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Breaking changes:

Features:

- [#1248](https://github.com/rails-api/active_model_serializers/pull/1248) Experimental: Add support for JSON API deserialization (@beauby)
- [#1378](https://github.com/rails-api/active_model_serializers/pull/1378) Change association blocks
to be evaluated in *serializer* scope, rather than *association* scope. (@bf4)
* Syntax changes from e.g.
Expand Down
1 change: 1 addition & 0 deletions lib/active_model/serializer/adapter/json_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class JsonApi < Base
autoload :PaginationLinks
autoload :FragmentCache
autoload :Link
autoload :Deserialization

# TODO: if we like this abstraction and other API objects to it,
# then extract to its own file and require it.
Expand Down
207 changes: 207 additions & 0 deletions lib/active_model/serializer/adapter/json_api/deserialization.rb
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.
Copy link
Member

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. :).

Copy link
Contributor Author

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?

Copy link
Member

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 :)

# 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]
# # }
#
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@example please.. needs more docs :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the flow?

  1. deserialize_document
    1. deserialize_data
      1. deserialize_resource
        1. deserialize_relationships
    2. deserialize_meta
    3. deserialize_included
    4. deserialize_links

Copy link
Contributor

Choose a reason for hiding this comment

The 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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

# polymorphic: [:author]) #=>
Copy link
Member

Choose a reason for hiding this comment

The 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? 😄

Copy link
Member

Choose a reason for hiding this comment

The 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 = {})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue with from_json is that it should be called on a model. Here, we make no assumption about the model. I guess we could override ActiveModel::Serializers::JSON so that it calls the parse method and updates the model. But it would be JSON API specific. Thoughts?

Copy link
Member

@bf4 bf4 Oct 26, 2015 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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, parse and return an instance of a model.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this converts options[:fields] into nil or a Hash? Would extract_options work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes and nope, respectively

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beauby looks like you need to add some method header comments :-\

Copy link
Member

Choose a reason for hiding this comment

The 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
1 change: 1 addition & 0 deletions lib/active_model_serializers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def self.config
extend ActiveSupport::Autoload
autoload :Model
autoload :Callbacks
autoload :Deserialization
autoload :Logging
end

Expand Down
13 changes: 13 additions & 0 deletions lib/active_model_serializers/deserialization.rb
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
59 changes: 59 additions & 0 deletions test/action_controller/json_api/deserialization_test.rb
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
Loading