Skip to content

Commit adaf5b8

Browse files
committed
Merge pull request #1248 from beauby/jsonapi-parse
JSON API deserialization.
2 parents d466466 + 20a58d7 commit adaf5b8

File tree

7 files changed

+421
-0
lines changed

7 files changed

+421
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Breaking changes:
1616

1717
Features:
1818

19+
- [#1248](https://github.com/rails-api/active_model_serializers/pull/1248) Experimental: Add support for JSON API deserialization (@beauby)
1920
- [#1378](https://github.com/rails-api/active_model_serializers/pull/1378) Change association blocks
2021
to be evaluated in *serializer* scope, rather than *association* scope. (@bf4)
2122
* Syntax changes from e.g.

lib/active_model/serializer/adapter/json_api.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class JsonApi < Base
66
autoload :PaginationLinks
77
autoload :FragmentCache
88
autoload :Link
9+
autoload :Deserialization
910

1011
# TODO: if we like this abstraction and other API objects to it,
1112
# then extract to its own file and require it.
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
module ActiveModel
2+
class Serializer
3+
module Adapter
4+
class JsonApi
5+
# NOTE(Experimental):
6+
# This is an experimental feature. Both the interface and internals could be subject
7+
# to changes.
8+
module Deserialization
9+
InvalidDocument = Class.new(ArgumentError)
10+
11+
module_function
12+
13+
# Transform a JSON API document, containing a single data object,
14+
# into a hash that is ready for ActiveRecord::Base.new() and such.
15+
# Raises InvalidDocument if the payload is not properly formatted.
16+
#
17+
# @param [Hash|ActionController::Parameters] document
18+
# @param [Hash] options
19+
# only: Array of symbols of whitelisted fields.
20+
# except: Array of symbols of blacklisted fields.
21+
# keys: Hash of translated keys (e.g. :author => :user).
22+
# polymorphic: Array of symbols of polymorphic fields.
23+
# @return [Hash]
24+
#
25+
# @example
26+
# document = {
27+
# data: {
28+
# id: 1,
29+
# type: 'post',
30+
# attributes: {
31+
# title: 'Title 1',
32+
# date: '2015-12-20'
33+
# },
34+
# associations: {
35+
# author: {
36+
# data: {
37+
# type: 'user',
38+
# id: 2
39+
# }
40+
# },
41+
# second_author: {
42+
# data: nil
43+
# },
44+
# comments: {
45+
# data: [{
46+
# type: 'comment',
47+
# id: 3
48+
# },{
49+
# type: 'comment',
50+
# id: 4
51+
# }]
52+
# }
53+
# }
54+
# }
55+
# }
56+
#
57+
# parse(document) #=>
58+
# # {
59+
# # title: 'Title 1',
60+
# # date: '2015-12-20',
61+
# # author_id: 2,
62+
# # second_author_id: nil
63+
# # comment_ids: [3, 4]
64+
# # }
65+
#
66+
# parse(document, only: [:title, :date, :author],
67+
# keys: { date: :published_at },
68+
# polymorphic: [:author]) #=>
69+
# # {
70+
# # title: 'Title 1',
71+
# # published_at: '2015-12-20',
72+
# # author_id: '2',
73+
# # author_type: 'people'
74+
# # }
75+
#
76+
def parse!(document, options = {})
77+
parse(document, options) do |invalid_payload, reason|
78+
fail InvalidDocument, "Invalid payload (#{reason}): #{invalid_payload}"
79+
end
80+
end
81+
82+
# Same as parse!, but returns an empty hash instead of raising InvalidDocument
83+
# on invalid payloads.
84+
def parse(document, options = {})
85+
document = document.dup.permit!.to_h if document.is_a?(ActionController::Parameters)
86+
87+
validate_payload(document) do |invalid_document, reason|
88+
yield invalid_document, reason if block_given?
89+
return {}
90+
end
91+
92+
primary_data = document['data']
93+
attributes = primary_data['attributes'] || {}
94+
attributes['id'] = primary_data['id'] if primary_data['id']
95+
relationships = primary_data['relationships'] || {}
96+
97+
filter_fields(attributes, options)
98+
filter_fields(relationships, options)
99+
100+
hash = {}
101+
hash.merge!(parse_attributes(attributes, options))
102+
hash.merge!(parse_relationships(relationships, options))
103+
104+
hash
105+
end
106+
107+
# Checks whether a payload is compliant with the JSON API spec.
108+
#
109+
# @api private
110+
# rubocop:disable Metrics/CyclomaticComplexity
111+
def validate_payload(payload)
112+
unless payload.is_a?(Hash)
113+
yield payload, 'Expected hash'
114+
return
115+
end
116+
117+
primary_data = payload['data']
118+
unless primary_data.is_a?(Hash)
119+
yield payload, { data: 'Expected hash' }
120+
return
121+
end
122+
123+
attributes = primary_data['attributes'] || {}
124+
unless attributes.is_a?(Hash)
125+
yield payload, { data: { attributes: 'Expected hash or nil' } }
126+
return
127+
end
128+
129+
relationships = primary_data['relationships'] || {}
130+
unless relationships.is_a?(Hash)
131+
yield payload, { data: { relationships: 'Expected hash or nil' } }
132+
return
133+
end
134+
135+
relationships.each do |(key, value)|
136+
unless value.is_a?(Hash) && value.key?('data')
137+
yield payload, { data: { relationships: { key => 'Expected hash with :data key' } } }
138+
end
139+
end
140+
end
141+
# rubocop:enable Metrics/CyclomaticComplexity
142+
143+
# @api private
144+
def filter_fields(fields, options)
145+
if (only = options[:only])
146+
fields.slice!(*Array(only).map(&:to_s))
147+
elsif (except = options[:except])
148+
fields.except!(*Array(except).map(&:to_s))
149+
end
150+
end
151+
152+
# @api private
153+
def field_key(field, options)
154+
(options[:keys] || {}).fetch(field.to_sym, field).to_sym
155+
end
156+
157+
# @api private
158+
def parse_attributes(attributes, options)
159+
attributes
160+
.map { |(k, v)| { field_key(k, options) => v } }
161+
.reduce({}, :merge)
162+
end
163+
164+
# Given an association name, and a relationship data attribute, build a hash
165+
# mapping the corresponding ActiveRecord attribute to the corresponding value.
166+
#
167+
# @example
168+
# parse_relationship(:comments, [{ 'id' => '1', 'type' => 'comments' },
169+
# { 'id' => '2', 'type' => 'comments' }],
170+
# {})
171+
# # => { :comment_ids => ['1', '2'] }
172+
# parse_relationship(:author, { 'id' => '1', 'type' => 'users' }, {})
173+
# # => { :author_id => '1' }
174+
# parse_relationship(:author, nil, {})
175+
# # => { :author_id => nil }
176+
# @param [Symbol] assoc_name
177+
# @param [Hash] assoc_data
178+
# @param [Hash] options
179+
# @return [Hash{Symbol, Object}]
180+
#
181+
# @api private
182+
def parse_relationship(assoc_name, assoc_data, options)
183+
prefix_key = field_key(assoc_name, options).to_s.singularize
184+
hash =
185+
if assoc_data.is_a?(Array)
186+
{ "#{prefix_key}_ids".to_sym => assoc_data.map { |ri| ri['id'] } }
187+
else
188+
{ "#{prefix_key}_id".to_sym => assoc_data ? assoc_data['id'] : nil }
189+
end
190+
191+
polymorphic = (options[:polymorphic] || []).include?(assoc_name.to_sym)
192+
hash.merge!("#{prefix_key}_type".to_sym => assoc_data['type']) if polymorphic
193+
194+
hash
195+
end
196+
197+
# @api private
198+
def parse_relationships(relationships, options)
199+
relationships
200+
.map { |(k, v)| parse_relationship(k, v['data'], options) }
201+
.reduce({}, :merge)
202+
end
203+
end
204+
end
205+
end
206+
end
207+
end

lib/active_model_serializers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def self.config
1212
extend ActiveSupport::Autoload
1313
autoload :Model
1414
autoload :Callbacks
15+
autoload :Deserialization
1516
autoload :Logging
1617
end
1718

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module ActiveModelSerializers
2+
module Deserialization
3+
module_function
4+
5+
def jsonapi_parse(*args)
6+
ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse(*args)
7+
end
8+
9+
def jsonapi_parse!(*args)
10+
ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse!(*args)
11+
end
12+
end
13+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
require 'test_helper'
2+
3+
module ActionController
4+
module Serialization
5+
class JsonApi
6+
class DeserializationTest < ActionController::TestCase
7+
class DeserializationTestController < ActionController::Base
8+
def render_parsed_payload
9+
parsed_hash = ActiveModelSerializers::Deserialization.jsonapi_parse(params)
10+
render json: parsed_hash
11+
end
12+
end
13+
14+
tests DeserializationTestController
15+
16+
def test_deserialization
17+
hash = {
18+
'data' => {
19+
'type' => 'photos',
20+
'id' => 'zorglub',
21+
'attributes' => {
22+
'title' => 'Ember Hamster',
23+
'src' => 'http://example.com/images/productivity.png'
24+
},
25+
'relationships' => {
26+
'author' => {
27+
'data' => nil
28+
},
29+
'photographer' => {
30+
'data' => { 'type' => 'people', 'id' => '9' }
31+
},
32+
'comments' => {
33+
'data' => [
34+
{ 'type' => 'comments', 'id' => '1' },
35+
{ 'type' => 'comments', 'id' => '2' }
36+
]
37+
}
38+
}
39+
}
40+
}
41+
42+
post :render_parsed_payload, hash
43+
44+
response = JSON.parse(@response.body)
45+
expected = {
46+
'id' => 'zorglub',
47+
'title' => 'Ember Hamster',
48+
'src' => 'http://example.com/images/productivity.png',
49+
'author_id' => nil,
50+
'photographer_id' => '9',
51+
'comment_ids' => %w(1 2)
52+
}
53+
54+
assert_equal(expected, response)
55+
end
56+
end
57+
end
58+
end
59+
end

0 commit comments

Comments
 (0)