diff --git a/active_model_serializers.gemspec b/active_model_serializers.gemspec index 3581ce408..75100ab8e 100644 --- a/active_model_serializers.gemspec +++ b/active_model_serializers.gemspec @@ -42,7 +42,8 @@ Gem::Specification.new do |spec| # 'minitest' # 'thread_safe' - spec.add_runtime_dependency 'jsonapi', '0.1.1.beta2' + spec.add_runtime_dependency 'jsonapi-deserializable', '~> 0.1.1' + spec.add_runtime_dependency 'jsonapi-renderer', '~> 0.1.1' spec.add_runtime_dependency 'case_transform', '>= 0.2' spec.add_development_dependency 'activerecord', rails_versions diff --git a/lib/active_model_serializers.rb b/lib/active_model_serializers.rb index 18cdd9f70..3ad66d418 100644 --- a/lib/active_model_serializers.rb +++ b/lib/active_model_serializers.rb @@ -1,5 +1,6 @@ require 'active_model' require 'active_support' +require 'jsonapi/deserializable' require 'active_support/core_ext/object/with_options' require 'active_support/core_ext/string/inflections' require 'active_support/json' diff --git a/lib/active_model_serializers/adapter/json_api/deserialization.rb b/lib/active_model_serializers/adapter/json_api/deserialization.rb index b79125ac4..fc376e94f 100644 --- a/lib/active_model_serializers/adapter/json_api/deserialization.rb +++ b/lib/active_model_serializers/adapter/json_api/deserialization.rb @@ -5,8 +5,6 @@ class JsonApi # 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, @@ -73,140 +71,46 @@ module Deserialization # # } # def parse!(document, options = {}) - parse(document, options) do |invalid_payload, reason| - fail InvalidDocument, "Invalid payload (#{reason}): #{invalid_payload}" + parse(document, options) do |exception| + fail exception 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) - transform_keys(attributes, options) - .map { |(k, v)| { field_key(k, options) => v } } - .reduce({}, :merge) + result = JSONAPI::Deserializable::Resource.call(document) + result = apply_options(result, options) + result + rescue JSONAPI::Parser::InvalidDocument => e + return {} unless block_given? + yield e 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) - if polymorphic - hash["#{prefix_key}_type".to_sym] = assoc_data.present? ? assoc_data['type'] : nil - end - + def apply_options(hash, options) + hash = transform_keys(hash, options) if options[:key_transform] + hash = hash.deep_symbolize_keys + hash = rename_fields(hash, options) hash end - # @api private - def parse_relationships(relationships, options) - transform_keys(relationships, options) - .map { |(k, v)| parse_relationship(k, v['data'], options) } - .reduce({}, :merge) - end - + # TODO: transform the keys after parsing # @api private def transform_keys(hash, options) transform = options[:key_transform] || :underscore CaseTransform.send(transform, hash) end + + def rename_fields(hash, options) + return hash unless options[:keys] + + keys = options[:keys] + hash.each_with_object({}) do |(k, v), h| + k = keys.fetch(k, k) + h[k] = v + h + end + end end end end diff --git a/test/action_controller/json_api/deserialization_test.rb b/test/action_controller/json_api/deserialization_test.rb deleted file mode 100644 index 025f857b7..000000000 --- a/test/action_controller/json_api/deserialization_test.rb +++ /dev/null @@ -1,112 +0,0 @@ -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 - - def render_polymorphic_parsed_payload - parsed_hash = ActiveModelSerializers::Deserialization.jsonapi_parse( - params, - polymorphic: [:restriction_for, :restricted_to] - ) - render json: parsed_hash - end - end - - tests DeserializationTestController - - def test_deserialization_of_relationship_only_object - hash = { - 'data' => { - 'type' => 'restraints', - 'relationships' => { - 'restriction_for' => { - 'data' => { - 'type' => 'discounts', - 'id' => '67' - } - }, - 'restricted_to' => { - 'data' => nil - } - } - }, - 'restraint' => {} - } - - post :render_polymorphic_parsed_payload, params: hash - - response = JSON.parse(@response.body) - expected = { - 'restriction_for_id' => '67', - 'restriction_for_type' => 'discounts', - 'restricted_to_id' => nil, - 'restricted_to_type' => nil - } - - assert_equal(expected, response) - end - - def test_deserialization - hash = { - 'data' => { - 'type' => 'photos', - 'id' => 'zorglub', - 'attributes' => { - 'title' => 'Ember Hamster', - 'src' => 'http://example.com/images/productivity.png', - 'image-width' => '200', - 'imageHeight' => '200', - 'ImageSize' => '1024' - }, - 'relationships' => { - 'author' => { - 'data' => nil - }, - 'photographer' => { - 'data' => { 'type' => 'people', 'id' => '9' } - }, - 'comments' => { - 'data' => [ - { 'type' => 'comments', 'id' => '1' }, - { 'type' => 'comments', 'id' => '2' } - ] - }, - 'related-images' => { - 'data' => [ - { 'type' => 'image', 'id' => '7' }, - { 'type' => 'image', 'id' => '8' } - ] - } - } - } - } - - post :render_parsed_payload, params: hash - - response = JSON.parse(@response.body) - expected = { - 'id' => 'zorglub', - 'title' => 'Ember Hamster', - 'src' => 'http://example.com/images/productivity.png', - 'image_width' => '200', - 'image_height' => '200', - 'image_size' => '1024', - 'author_id' => nil, - 'photographer_id' => '9', - 'comment_ids' => %w(1 2), - 'related_image_ids' => %w(7 8) - } - - 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 deleted file mode 100644 index bee79c8c1..000000000 --- a/test/adapter/json_api/parse_test.rb +++ /dev/null @@ -1,137 +0,0 @@ -require 'test_helper' -module ActiveModelSerializers - 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 = ActiveModelSerializers::Adapter::JsonApi::Deserialization.parse!(@hash) - assert_equal(@expected, parsed_hash) - end - - def test_actioncontroller_parameters - assert_equal(false, @params.permitted?) - parsed_hash = ActiveModelSerializers::Adapter::JsonApi::Deserialization.parse!(@params) - assert_equal(@expected, parsed_hash) - end - - def test_illformed_payloads_safe - @illformed_payloads.each do |p| - parsed_hash = ActiveModelSerializers::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 - ActiveModelSerializers::Adapter::JsonApi::Deserialization.parse!(p) - end - end - end - - def test_filter_fields_only - parsed_hash = ActiveModelSerializers::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 = ActiveModelSerializers::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 = ActiveModelSerializers::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 = ActiveModelSerializers::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