-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1248 from beauby/jsonapi-parse
JSON API deserialization.
- Loading branch information
Showing
7 changed files
with
421 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
207 changes: 207 additions & 0 deletions
207
lib/active_model/serializer/adapter/json_api/deserialization.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
# # } | ||
# | ||
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.