Merge pull request #1248 from beauby/jsonapi-parse

JSON API deserialization.
This commit is contained in:
Lucas Hosseini 2016-01-13 06:18:52 +01:00
commit adaf5b87df
7 changed files with 421 additions and 0 deletions

View File

@ -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.

View File

@ -6,6 +6,7 @@ module ActiveModel
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.

View File

@ -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]
# # }
#
# parse(document, only: [:title, :date, :author],
# keys: { date: :published_at },
# polymorphic: [:author]) #=>
# # {
# # 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 = {})
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
# 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

View File

@ -12,6 +12,7 @@ module ActiveModelSerializers
extend ActiveSupport::Autoload
autoload :Model
autoload :Callbacks
autoload :Deserialization
autoload :Logging
end

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,139 @@
require 'test_helper'
module ActiveModel
class Serializer
module Adapter
class JsonApi
module Deserialization
class ParseTest < Minitest::Test
def setup
@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' }
]
}
}
}
}
@params = ActionController::Parameters.new(@hash)
@expected = {
id: 'zorglub',
title: 'Ember Hamster',
src: 'http://example.com/images/productivity.png',
author_id: nil,
photographer_id: '9',
comment_ids: %w(1 2)
}
@illformed_payloads = [nil,
{},
{
'data' => nil
}, {
'data' => { 'attributes' => [] }
}, {
'data' => { 'relationships' => [] }
}, {
'data' => {
'relationships' => { 'rel' => nil }
}
}, {
'data' => {
'relationships' => { 'rel' => {} }
}
}]
end
def test_hash
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(@hash)
assert_equal(@expected, parsed_hash)
end
def test_actioncontroller_parameters
assert_equal(false, @params.permitted?)
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(@params)
assert_equal(@expected, parsed_hash)
end
def test_illformed_payloads_safe
@illformed_payloads.each do |p|
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse(p)
assert_equal({}, parsed_hash)
end
end
def test_illformed_payloads_unsafe
@illformed_payloads.each do |p|
assert_raises(InvalidDocument) do
ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(p)
end
end
end
def test_filter_fields_only
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(@hash, only: [:id, :title, :author])
expected = {
id: 'zorglub',
title: 'Ember Hamster',
author_id: nil
}
assert_equal(expected, parsed_hash)
end
def test_filter_fields_except
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(@hash, except: [:id, :title, :author])
expected = {
src: 'http://example.com/images/productivity.png',
photographer_id: '9',
comment_ids: %w(1 2)
}
assert_equal(expected, parsed_hash)
end
def test_keys
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(@hash, keys: { author: :user, title: :post_title })
expected = {
id: 'zorglub',
post_title: 'Ember Hamster',
src: 'http://example.com/images/productivity.png',
user_id: nil,
photographer_id: '9',
comment_ids: %w(1 2)
}
assert_equal(expected, parsed_hash)
end
def test_polymorphic
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(@hash, polymorphic: [:photographer])
expected = {
id: 'zorglub',
title: 'Ember Hamster',
src: 'http://example.com/images/productivity.png',
author_id: nil,
photographer_id: '9',
photographer_type: 'people',
comment_ids: %w(1 2)
}
assert_equal(expected, parsed_hash)
end
end
end
end
end
end
end