rails-api/active_model_serializers

View on GitHub
lib/active_model_serializers/adapter/json_api.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

# {http://jsonapi.org/format/ JSON API specification}
# rubocop:disable Style/AsciiComments
# TODO: implement!
#  ☐ https://github.com/rails-api/active_model_serializers/issues/1235
# TODO: use uri_template in link generation?
#  ☐ https://github.com/rails-api/active_model_serializers/pull/1282#discussion_r42528812
#    see gem https://github.com/hannesg/uri_template
#    spec http://tools.ietf.org/html/rfc6570
#    impl https://developer.github.com/v3/#schema https://api.github.com/
# TODO: validate against a JSON schema document?
#  ☐ https://github.com/rails-api/active_model_serializers/issues/1162
#  ☑ https://github.com/rails-api/active_model_serializers/pull/1270
# TODO: Routing
#  ☐ https://github.com/rails-api/active_model_serializers/pull/1476
# TODO: Query Params
#  ☑ `include` https://github.com/rails-api/active_model_serializers/pull/1131
#  ☑ `fields` https://github.com/rails-api/active_model_serializers/pull/700
#  ☑ `page[number]=3&page[size]=1` https://github.com/rails-api/active_model_serializers/pull/1041
#  ☐ `filter`
#  ☐ `sort`
module ActiveModelSerializers
  module Adapter
    class JsonApi < Base
      extend ActiveSupport::Autoload
      eager_autoload do
        autoload :Jsonapi
        autoload :ResourceIdentifier
        autoload :Link
        autoload :PaginationLinks
        autoload :Meta
        autoload :Error
        autoload :Deserialization
        autoload :Relationship
      end

      def self.default_key_transform
        :dash
      end

      def self.fragment_cache(cached_hash, non_cached_hash, root = true)
        core_cached       = cached_hash.first
        core_non_cached   = non_cached_hash.first
        no_root_cache     = cached_hash.delete_if { |key, _value| key == core_cached[0] }
        no_root_non_cache = non_cached_hash.delete_if { |key, _value| key == core_non_cached[0] }
        cached_resource   = (core_cached[1]) ? core_cached[1].deep_merge(core_non_cached[1]) : core_non_cached[1]
        hash = root ? { root => cached_resource } : cached_resource

        hash.deep_merge no_root_non_cache.deep_merge no_root_cache
      end

      def initialize(serializer, options = {})
        super
        @include_directive = JSONAPI::IncludeDirective.new(options[:include], allow_wildcard: true)
        @fieldset = options[:fieldset] || ActiveModel::Serializer::Fieldset.new(options.delete(:fields))
      end

      # {http://jsonapi.org/format/#crud Requests are transactional, i.e. success or failure}
      # {http://jsonapi.org/format/#document-top-level data and errors MUST NOT coexist in the same document.}
      def serializable_hash(*)
        document = if serializer.success?
                     success_document
                   else
                     failure_document
                   end
        self.class.transform_key_casing!(document, instance_options)
      end

      def fragment_cache(cached_hash, non_cached_hash)
        root = !instance_options.include?(:include)
        self.class.fragment_cache(cached_hash, non_cached_hash, root)
      end

      # {http://jsonapi.org/format/#document-top-level Primary data}
      # definition:
      #   ☐ toplevel_data (required)
      #   ☐ toplevel_included
      #   ☑ toplevel_meta
      #   ☑ toplevel_links
      #   ☑ toplevel_jsonapi
      # structure:
      #  {
      #    data: toplevel_data,
      #    included: toplevel_included,
      #    meta: toplevel_meta,
      #    links: toplevel_links,
      #    jsonapi: toplevel_jsonapi
      #  }.reject! {|_,v| v.nil? }
      # rubocop:disable Metrics/CyclomaticComplexity
      def success_document
        is_collection = serializer.respond_to?(:each)
        serializers = is_collection ? serializer : [serializer]
        primary_data, included = resource_objects_for(serializers)

        hash = {}
        # toplevel_data
        # definition:
        #   oneOf
        #     resource
        #     array of unique items of type 'resource'
        #     null
        #
        # description:
        #   The document's "primary data" is a representation of the resource or collection of resources
        #   targeted by a request.
        #
        #   Singular: the resource object.
        #
        #   Collection: one of an array of resource objects, an array of resource identifier objects, or
        #   an empty array ([]), for requests that target resource collections.
        #
        #   None: null if the request is one that might correspond to a single resource, but doesn't currently.
        # structure:
        #  if serializable_resource.resource?
        #    resource
        #  elsif serializable_resource.collection?
        #    [
        #      resource,
        #      resource
        #    ]
        #  else
        #    nil
        #  end
        hash[:data] = is_collection ? primary_data : primary_data[0]
        # toplevel_included
        #   alias included
        # definition:
        #   array of unique items of type 'resource'
        #
        # description:
        #   To reduce the number of HTTP requests, servers **MAY** allow
        #   responses that include related resources along with the requested primary
        #   resources. Such responses are called "compound documents".
        # structure:
        #     [
        #       resource,
        #       resource
        #     ]
        hash[:included] = included if included.any?

        Jsonapi.add!(hash)

        if instance_options[:links]
          hash[:links] ||= {}
          hash[:links].update(instance_options[:links])
        end

        if is_collection && serializer.paginated?
          hash[:links] ||= {}
          hash[:links].update(pagination_links_for(serializer))
        end

        hash[:meta] = instance_options[:meta] unless instance_options[:meta].blank?

        hash
      end
      # rubocop:enable Metrics/CyclomaticComplexity

      # {http://jsonapi.org/format/#errors JSON API Errors}
      # TODO: look into caching
      # definition:
      #   ☑ toplevel_errors array (required)
      #   ☐ toplevel_meta
      #   ☐ toplevel_jsonapi
      # structure:
      #   {
      #     errors: toplevel_errors,
      #     meta: toplevel_meta,
      #     jsonapi: toplevel_jsonapi
      #   }.reject! {|_,v| v.nil? }
      # prs:
      #  https://github.com/rails-api/active_model_serializers/pull/1004
      def failure_document
        hash = {}
        # PR Please :)
        # Jsonapi.add!(hash)

        # toplevel_errors
        # definition:
        #   array of unique items of type 'error'
        # structure:
        #   [
        #     error,
        #     error
        #   ]
        if serializer.respond_to?(:each)
          hash[:errors] = serializer.flat_map do |error_serializer|
            Error.resource_errors(error_serializer, instance_options)
          end
        else
          hash[:errors] = Error.resource_errors(serializer, instance_options)
        end
        hash
      end

      protected

      attr_reader :fieldset

      private

      # {http://jsonapi.org/format/#document-resource-objects Primary data}
      # resource
      # definition:
      #   JSON Object
      #
      # properties:
      #   type (required) : String
      #   id   (required) : String
      #   attributes
      #   relationships
      #   links
      #   meta
      #
      # description:
      #   "Resource objects" appear in a JSON API document to represent resources
      # structure:
      #   {
      #     type: 'admin--some-user',
      #     id: '1336',
      #     attributes: attributes,
      #     relationships: relationships,
      #     links: links,
      #     meta: meta,
      #   }.reject! {|_,v| v.nil? }
      # prs:
      #   type
      #     https://github.com/rails-api/active_model_serializers/pull/1122
      #     [x] https://github.com/rails-api/active_model_serializers/pull/1213
      #     https://github.com/rails-api/active_model_serializers/pull/1216
      #     https://github.com/rails-api/active_model_serializers/pull/1029
      #   links
      #     [x] https://github.com/rails-api/active_model_serializers/pull/1246
      #     [x] url helpers https://github.com/rails-api/active_model_serializers/issues/1269
      #   meta
      #     [x] https://github.com/rails-api/active_model_serializers/pull/1340
      def resource_objects_for(serializers)
        @primary = []
        @included = []
        @resource_identifiers = Set.new
        serializers.each { |serializer| process_resource(serializer, true, @include_directive) }
        serializers.each { |serializer| process_relationships(serializer, @include_directive) }

        [@primary, @included]
      end

      def process_resource(serializer, primary, include_slice = {})
        resource_identifier = ResourceIdentifier.new(serializer, instance_options).as_json
        return false unless @resource_identifiers.add?(resource_identifier)

        resource_object = resource_object_for(serializer, include_slice)
        if primary
          @primary << resource_object
        else
          @included << resource_object
        end

        true
      end

      def process_relationships(serializer, include_slice)
        serializer.associations(include_slice).each do |association|
          # TODO(BF): Process relationship without evaluating lazy_association
          process_relationship(association.lazy_association.serializer, include_slice[association.key])
        end
      end

      def process_relationship(serializer, include_slice)
        if serializer.respond_to?(:each)
          serializer.each { |s| process_relationship(s, include_slice) }
          return
        end
        return unless serializer && serializer.object
        return unless process_resource(serializer, false, include_slice)

        process_relationships(serializer, include_slice)
      end

      # {http://jsonapi.org/format/#document-resource-object-attributes Document Resource Object Attributes}
      # attributes
      # definition:
      #   JSON Object
      #
      # patternProperties:
      #   ^(?!relationships$|links$)\\w[-\\w_]*$
      #
      # description:
      #   Members of the attributes object ("attributes") represent information about the resource
      #   object in which it's defined.
      #   Attributes may contain any valid JSON value
      # structure:
      #   {
      #     foo: 'bar'
      #   }
      def attributes_for(serializer, fields)
        serializer.attributes(fields).except(:id)
      end

      # {http://jsonapi.org/format/#document-resource-objects Document Resource Objects}
      def resource_object_for(serializer, include_slice = {})
        resource_object = data_for(serializer, include_slice)

        # toplevel_links
        # definition:
        #   allOf
        #      ☐ links
        #      ☐ pagination
        #
        # description:
        #  Link members related to the primary data.
        # structure:
        #   links.merge!(pagination)
        # prs:
        #   https://github.com/rails-api/active_model_serializers/pull/1247
        #   https://github.com/rails-api/active_model_serializers/pull/1018
        if (links = links_for(serializer)).any?
          resource_object ||= {}
          resource_object[:links] = links
        end

        # toplevel_meta
        #   alias meta
        # definition:
        #   meta
        # structure
        #   {
        #     :'git-ref' => 'abc123'
        #   }
        if (meta = meta_for(serializer)).present?
          resource_object ||= {}
          resource_object[:meta] = meta
        end

        resource_object
      end

      def data_for(serializer, include_slice)
        data = serializer.fetch(self) do
          resource_object = ResourceIdentifier.new(serializer, instance_options).as_json
          break nil if resource_object.nil?

          requested_fields = fieldset && fieldset.fields_for(resource_object[:type])
          attributes = attributes_for(serializer, requested_fields)
          resource_object[:attributes] = attributes if attributes.any?
          resource_object
        end
        data.tap do |resource_object|
          next if resource_object.nil?
          # NOTE(BF): the attributes are cached above, separately from the relationships, below.
          requested_associations = fieldset.fields_for(resource_object[:type]) || '*'
          relationships = relationships_for(serializer, requested_associations, include_slice)
          resource_object[:relationships] = relationships if relationships.any?
        end
      end

      # {http://jsonapi.org/format/#document-resource-object-relationships Document Resource Object Relationship}
      # relationships
      # definition:
      #   JSON Object
      #
      # patternProperties:
      #   ^\\w[-\\w_]*$"
      #
      # properties:
      #   data : relationshipsData
      #   links
      #   meta
      #
      # description:
      #
      #   Members of the relationships object ("relationships") represent references from the
      #   resource object in which it's defined to other resource objects."
      # structure:
      #   {
      #     links: links,
      #     meta: meta,
      #     data: relationshipsData
      #   }.reject! {|_,v| v.nil? }
      #
      # prs:
      #   links
      #     [x] https://github.com/rails-api/active_model_serializers/pull/1454
      #   meta
      #     [x] https://github.com/rails-api/active_model_serializers/pull/1454
      #   polymorphic
      #     [ ] https://github.com/rails-api/active_model_serializers/pull/1420
      #
      # relationshipsData
      # definition:
      #   oneOf
      #     relationshipToOne
      #     relationshipToMany
      #
      # description:
      #   Member, whose value represents "resource linkage"
      # structure:
      #   if has_one?
      #     relationshipToOne
      #   else
      #     relationshipToMany
      #   end
      #
      # definition:
      #   anyOf
      #     null
      #     linkage
      #
      # relationshipToOne
      # description:
      #
      #   References to other resource objects in a to-one ("relationship"). Relationships can be
      #   specified by including a member in a resource's links object.
      #
      #   None: Describes an empty to-one relationship.
      # structure:
      #   if has_related?
      #     linkage
      #   else
      #     nil
      #   end
      #
      # relationshipToMany
      # definition:
      #   array of unique items of type 'linkage'
      #
      # description:
      #   An array of objects each containing "type" and "id" members for to-many relationships
      # structure:
      #   [
      #     linkage,
      #     linkage
      #   ]
      # prs:
      #   polymorphic
      #     [ ] https://github.com/rails-api/active_model_serializers/pull/1282
      #
      # linkage
      # definition:
      #   type (required) : String
      #   id   (required) : String
      #   meta
      #
      # description:
      #   The "type" and "id" to non-empty members.
      # structure:
      #   {
      #     type: 'required-type',
      #     id: 'required-id',
      #     meta: meta
      #   }.reject! {|_,v| v.nil? }
      def relationships_for(serializer, requested_associations, include_slice)
        include_directive = JSONAPI::IncludeDirective.new(
          requested_associations,
          allow_wildcard: true
        )
        serializer.associations(include_directive, include_slice).each_with_object({}) do |association, hash|
          hash[association.key] = Relationship.new(serializer, instance_options, association).as_json
        end
      end

      # {http://jsonapi.org/format/#document-links Document Links}
      # links
      # definition:
      #  JSON Object
      #
      # properties:
      #   self    : URI
      #   related : link
      #
      # description:
      #   A resource object **MAY** contain references to other resource objects ("relationships").
      #   Relationships may be to-one or to-many. Relationships can be specified by including a member
      #   in a resource's links object.
      #
      #   A `self` member’s value is a URL for the relationship itself (a "relationship URL"). This
      #   URL allows the client to directly manipulate the relationship. For example, it would allow
      #   a client to remove an `author` from an `article` without deleting the people resource
      #   itself.
      # structure:
      #   {
      #     self: 'http://example.com/etc',
      #     related: link
      #   }.reject! {|_,v| v.nil? }
      def links_for(serializer)
        serializer._links.each_with_object({}) do |(name, value), hash|
          next if value.excluded?(serializer)
          result = Link.new(serializer, value.block).as_json
          hash[name] = result if result
        end
      end

      # {http://jsonapi.org/format/#fetching-pagination Pagination Links}
      # pagination
      # definition:
      #   first : pageObject
      #   last  : pageObject
      #   prev  : pageObject
      #   next  : pageObject
      # structure:
      #   {
      #     first: pageObject,
      #     last: pageObject,
      #     prev: pageObject,
      #     next: pageObject
      #   }
      #
      # pageObject
      # definition:
      #   oneOf
      #     URI
      #     null
      #
      # description:
      #   The <x> page of data
      # structure:
      #   if has_page?
      #     'http://example.com/some-page?page[number][x]'
      #   else
      #     nil
      #   end
      # prs:
      #   https://github.com/rails-api/active_model_serializers/pull/1041
      def pagination_links_for(serializer)
        PaginationLinks.new(serializer.object, instance_options).as_json
      end

      # {http://jsonapi.org/format/#document-meta Docment Meta}
      def meta_for(serializer)
        Meta.new(serializer).as_json
      end
    end
  end
end
# rubocop:enable Style/AsciiComments