From 20a58d7f5c621a7d305c6633fd306265a20b78ec Mon Sep 17 00:00:00 2001 From: Lucas Hosseini Date: Tue, 6 Oct 2015 23:51:54 +0200 Subject: [PATCH] Add support for JSON API deserialization (experimental). --- CHANGELOG.md | 1 + .../serializer/adapter/json_api.rb | 1 + .../adapter/json_api/deserialization.rb | 207 ++++++++++++++++++ lib/active_model_serializers.rb | 1 + .../deserialization.rb | 13 ++ .../json_api/deserialization_test.rb | 59 +++++ test/adapter/json_api/parse_test.rb | 139 ++++++++++++ 7 files changed, 421 insertions(+) create mode 100644 lib/active_model/serializer/adapter/json_api/deserialization.rb create mode 100644 lib/active_model_serializers/deserialization.rb create mode 100644 test/action_controller/json_api/deserialization_test.rb create mode 100644 test/adapter/json_api/parse_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f900f220..5f8bd834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/lib/active_model/serializer/adapter/json_api.rb b/lib/active_model/serializer/adapter/json_api.rb index 1c7f7226..baa69d50 100644 --- a/lib/active_model/serializer/adapter/json_api.rb +++ b/lib/active_model/serializer/adapter/json_api.rb @@ -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. diff --git a/lib/active_model/serializer/adapter/json_api/deserialization.rb b/lib/active_model/serializer/adapter/json_api/deserialization.rb new file mode 100644 index 00000000..5f35a882 --- /dev/null +++ b/lib/active_model/serializer/adapter/json_api/deserialization.rb @@ -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 diff --git a/lib/active_model_serializers.rb b/lib/active_model_serializers.rb index d2e7582e..b955d7a0 100644 --- a/lib/active_model_serializers.rb +++ b/lib/active_model_serializers.rb @@ -12,6 +12,7 @@ module ActiveModelSerializers extend ActiveSupport::Autoload autoload :Model autoload :Callbacks + autoload :Deserialization autoload :Logging end diff --git a/lib/active_model_serializers/deserialization.rb b/lib/active_model_serializers/deserialization.rb new file mode 100644 index 00000000..15b8e898 --- /dev/null +++ b/lib/active_model_serializers/deserialization.rb @@ -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 diff --git a/test/action_controller/json_api/deserialization_test.rb b/test/action_controller/json_api/deserialization_test.rb new file mode 100644 index 00000000..eb4806e4 --- /dev/null +++ b/test/action_controller/json_api/deserialization_test.rb @@ -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 diff --git a/test/adapter/json_api/parse_test.rb b/test/adapter/json_api/parse_test.rb new file mode 100644 index 00000000..c8098816 --- /dev/null +++ b/test/adapter/json_api/parse_test.rb @@ -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