mirror of
https://github.com/ditkrg/active_model_serializers.git
synced 2026-01-23 06:16:50 +00:00
Add support for JSON API deserialization (experimental).
This commit is contained in:
parent
7d4f0c5c8a
commit
20a58d7f5c
@ -16,6 +16,7 @@ Breaking changes:
|
|||||||
|
|
||||||
Features:
|
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
|
- [#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)
|
to be evaluated in *serializer* scope, rather than *association* scope. (@bf4)
|
||||||
* Syntax changes from e.g.
|
* Syntax changes from e.g.
|
||||||
|
|||||||
@ -6,6 +6,7 @@ module ActiveModel
|
|||||||
autoload :PaginationLinks
|
autoload :PaginationLinks
|
||||||
autoload :FragmentCache
|
autoload :FragmentCache
|
||||||
autoload :Link
|
autoload :Link
|
||||||
|
autoload :Deserialization
|
||||||
|
|
||||||
# TODO: if we like this abstraction and other API objects to it,
|
# TODO: if we like this abstraction and other API objects to it,
|
||||||
# then extract to its own file and require it.
|
# then extract to its own file and require it.
|
||||||
|
|||||||
207
lib/active_model/serializer/adapter/json_api/deserialization.rb
Normal file
207
lib/active_model/serializer/adapter/json_api/deserialization.rb
Normal 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
|
||||||
@ -12,6 +12,7 @@ module ActiveModelSerializers
|
|||||||
extend ActiveSupport::Autoload
|
extend ActiveSupport::Autoload
|
||||||
autoload :Model
|
autoload :Model
|
||||||
autoload :Callbacks
|
autoload :Callbacks
|
||||||
|
autoload :Deserialization
|
||||||
autoload :Logging
|
autoload :Logging
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
13
lib/active_model_serializers/deserialization.rb
Normal file
13
lib/active_model_serializers/deserialization.rb
Normal 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
|
||||||
59
test/action_controller/json_api/deserialization_test.rb
Normal file
59
test/action_controller/json_api/deserialization_test.rb
Normal 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
|
||||||
139
test/adapter/json_api/parse_test.rb
Normal file
139
test/adapter/json_api/parse_test.rb
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user