tiagopog/jsonapi-utils

View on GitHub
lib/jsonapi/utils/response/formatters.rb

Summary

Maintainability
A
1 hr
Test Coverage
module JSONAPI
  module Utils
    module Response
      module Formatters
        # Helper method to format ActiveRecord or Hash objects into JSON API-compliant ones.
        #
        # @note The return of this method represents what will actually be displayed in the response body.
        # @note It can also be called as #jsonapi_serialize due to backward compatibility issues.
        #
        # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array<Hash>]
        #   Object to be formatted into JSON
        #   e.g.: User.first, User.all, { data: { id: 1, first_name: 'Tiago' } },
        #   [{ data: { id: 1, first_name: 'Tiago' } }]
        #
        # @option options [JSONAPI::Resource] resource: it tells the formatter which resource
        #   class to be used rather than use an infered one (default behaviour)
        #
        # @option options [JSONAPI::Resource] source: it tells the formatter that this response is from a related resource
        #   and the result should be interpreted as a related resources response
        #
        # @option options [String, Symbol] relationship_type: it tells that the formatter which relationship the data is from
        #
        # @option options [ActiveRecord::Base] model: ActiveRecord model class to be instantiated
        #   when a Hash or Array of Hashes is passed as the "object" argument
        #
        # @option options [Integer] count: if it's rendering a collection of resources, the default
        #   gem's counting method can be bypassed by the use of this options. It's shows then the total
        #   records resulting from that request and also calculates the pagination.
        #
        # @return [Hash]
        #
        # @api public
        def jsonapi_format(object, options = {})
          if object.is_a?(Hash)
            hash = object.with_indifferent_access
            object = hash_to_active_record(hash[:data], options[:model])
          end
          fix_custom_request_options(object)
          build_response_document(object, options).contents
        end

        alias_method :jsonapi_serialize, :jsonapi_format

        # Helper method to format ActiveRecord or any object that responds to #errors
        # into JSON API-compliant error response bodies.
        #
        # @note The return of this method represents what will actually be displayed in the response body.
        # @note It can also be called as #jsonapi_serialize_errors due to backward compatibility issues.
        #
        # @param object [ActiveRecord::Base or any object that responds to #errors]
        #   Error object to be serialized into JSON
        #   e.g.: User.new(name: nil).tap(&:save), MyErrorDecorator.new(invalid_object)
        #
        # @return [Array]
        #
        # @api public
        def jsonapi_format_errors(object)
          if active_record_obj?(object)
            object = JSONAPI::Utils::Exceptions::ActiveRecord.new(object, @request.resource_klass, context)
          end
          errors = object.respond_to?(:errors) ? object.errors : object
          JSONAPI::Utils::Support::Error.sanitize(errors).uniq
        end

        alias_method :jsonapi_serialize_errors, :jsonapi_format_errors

        private

        # Check whether the given object is an ActiveRecord-like one.
        #
        # @param object [Object] Object to be checked
        #
        # @return [TrueClass, FalseClass]
        #
        # @api private
        def active_record_obj?(object)
          defined?(ActiveRecord::Base) &&
            (object.is_a?(ActiveRecord::Base) ||
            object.singleton_class.include?(ActiveModel::Model))
        end

        # Build the full response document.
        #
        # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array<Hash>]
        #   Object to be formatted into JSON.
        #
        # @option options [JSONAPI::Resource] :resource which resource class to be used
        #   rather than using the default one (inferred)
        #
        # @option options [ActiveRecord::Base, JSONAPI::Resource] :source source of related resource,
        #   the result should be interpreted as a related resources response
        #
        # @option options [String, Symbol] :relationship which relationship the data is from
        #
        # @option options [Integer] count: if it's rendering a collection of resources, the default
        #   gem's counting method can be bypassed by the use of this options. It's shows then the total
        #   records resulting from that request and also calculates the pagination.
        #
        # @return [JSONAPI::ResponseDocument]
        #
        # @api private
        def build_response_document(object, options)
          results = JSONAPI::OperationResults.new

          if object.respond_to?(:to_ary)
            results.add_result(build_collection_result(object, options))
          else
            record = turn_into_resource(object, options)
            results.add_result(JSONAPI::ResourceOperationResult.new(:ok, record))
          end

          @_response_document = create_response_document(results)
        end

        # Build the result operation object for collection actions.
        #
        # @param object [ActiveRecord::Relation, Array<Hash>]
        #   Object to be formatted into JSON.
        #
        # @option options [JSONAPI::Resource] :resource which resource class to be used
        #   rather than using the default one (inferred)
        #
        # @option options [ActiveRecord::Base, JSONAPI::Resource] :source parent model/resource
        #   of the related resource
        #
        # @option options [String, Symbol] :relationship which relationship the data is from
        #
        # @option options [Integer] count: if it's rendering a collection of resources, the default
        #   gem's counting method can be bypassed by the use of this options. It's shows then the total
        #   records resulting from that request and also calculates the pagination.
        #
        # @return [JSONAPI::ResourcesOperationResult, JSONAPI::RelatedResourcesOperationResult]
        #
        # @api private
        def build_collection_result(object, options)
          records = build_collection(object, options)
          result_options = result_options(object, options)

          if related_resource_operation?(options)
            source_resource = turn_source_into_resource(options[:source])
            relationship_type = get_source_relationship(options)

            JSONAPI::RelatedResourcesOperationResult.new(
              :ok,
              source_resource,
              relationship_type,
              records,
              result_options
            )
          else
            JSONAPI::ResourcesOperationResult.new(:ok, records, result_options)
          end
        end

        # Is this a request for related resources?
        #
        # In order to answer that it needs to check for some {options}
        # controller params like {params[:source]} and {params[:relationship]}.
        #
        # @option options [Boolean] :related when true, jsonapi-utils infers the parent and
        #   related resources from controller's {params} values.
        #
        # @option options [ActiveRecord::Base, JSONAPI::Resource] :source parent model/resource
        #   of the related resource
        #
        # @option options [String, Symbol] :relationship which relationship the data is from
        #
        # @return [Boolean]
        #
        # @api private
        def related_resource_operation?(options)
          (options[:related] || options[:source].present?) &&
            params[:source].present? &&
            params[:relationship].present?
        end

        # Apply a proper action setup for custom requests/actions.
        #
        # @note The setup_(index|show)_action comes from JSONAPI::Resources' API.
        #
        # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array<Hash>]
        #   It's checked whether this object refers to a collection or not.
        #
        # @api private
        def fix_custom_request_options(object)
          return unless custom_get_request_with_params?
          action = object.respond_to?(:to_ary) ? 'index' : 'show'
          @request.send("setup_#{action}_action", params)
        end

        # Check whether it's a custom GET request with params.
        #
        # @return [TrueClass, FalseClass]
        #
        # @api private
        def custom_get_request_with_params?
          request.method =~ /get/i && !%w(index show).include?(params[:action]) && !params.nil?
        end

        # Turn a collection of AR or Hash objects into a collection of JSONAPI::Resource ones.
        #
        # @param records [ActiveRecord::Relation, Hash, Array<Hash>]
        #   Objects to be instantiated as JSONAPI::Resource ones.
        #   e.g.: User.all, [{ data: { id: 1, first_name: 'Tiago' } }]
        #
        # @option options [JSONAPI::Resource] :resource it resource class to be used rather than default one (infered)
        #
        # @option options [Integer] :count if it's rendering a collection of resources, the default
        #   gem's counting method can be bypassed by the use of this options. It's shows then the total
        #   records resulting from that request and also calculates the pagination.
        #
        # @return [Array]
        #
        # @api private
        def build_collection(records, options)
          records = apply_filter(records, options)
          records = apply_sort(records)
          records = apply_pagination(records, options)
          records.respond_to?(:to_ary) ? records.map { |record| turn_into_resource(record, options) } : []
        end

        # Turn an AR or Hash object into a JSONAPI::Resource one.
        #
        # @param records [ActiveRecord::Relation, Hash, Array<Hash>]
        #   Object to be instantiated as a JSONAPI::Resource one.
        #   e.g.: User.first, { data: { id: 1, first_name: 'Tiago' } }
        #
        # @option options [JSONAPI::Resource] resource: it tells which resource
        #   class to be used rather than use an infered one (default behaviour)
        #
        # @return [JSONAPI::Resource]
        #
        # @api private
        def turn_into_resource(record, options)
          if options[:resource]
            options[:resource].to_s.constantize.new(record, context)
          else
            @request.resource_klass.new(record, context)
          end
        end

        # Get JSONAPI::Resource for source object
        #
        # @param record [ActiveRecord::Base, JSONAPI::Resource]
        #
        # @return [JSONAPI::Resource]
        #
        # @api private
        def turn_source_into_resource(record)
          return record if record.kind_of?(JSONAPI::Resource)
          @request.source_klass.new(record, context)
        end

        # Get relationship type of source object
        #
        # @option options [Symbol] relationship: it tells which relationship
        #   to be used rather than use an infered one (default behaviour)
        #
        # @return [Symbol]
        #
        # @api private
        def get_source_relationship(options)
          options[:relationship]&.to_sym || @request.resource_klass._type
        end

        # Apply some result options like pagination params and record count to collection responses.
        #
        # @param records [ActiveRecord::Relation, Hash, Array<Hash>]
        #   Object to be formatted into JSON
        #   e.g.: User.all, [{ data: { id: 1, first_name: 'Tiago' } }]
        #
        # @option options [Integer] count: if it's rendering a collection of resources, the default
        #   gem's counting method can be bypassed by the use of this options. It's shows then the total
        #   records resulting from that request and also calculates the pagination.
        #
        # @return [Hash]
        #
        # @api private
        def result_options(records, options)
          {}.tap do |data|
            if include_pagination_links?
              data[:pagination_params] = pagination_params(records, options)
            end

            if JSONAPI.configuration.top_level_meta_include_record_count
              data[:record_count] = record_count_for(records, options)
            end

            if include_page_count?
              data[:page_count] = page_count_for(data[:record_count])
            end
          end
        end

        # Convert Hash or collection of Hashes into AR objects.
        #
        # @param data [Hash, Array<Hash>] Hash or collection to be converted
        #   e.g.: { data: { id: 1, first_name: 'Tiago' } },
        #         [{ data: { id: 1, first_name: 'Tiago' } }],
        #
        # @option options [ActiveRecord::Base] model: ActiveRecord model class to be
        #   used as base for the objects' intantialization.
        #
        # @return [ActiveRecord::Base, ActiveRecord::Relation]
        #
        # @api private
        def hash_to_active_record(data, model)
          return data if model.nil?
          coerced = [data].flatten.map { |hash| model.new(hash) }
          data.is_a?(Array) ? coerced : coerced.first
        rescue ActiveRecord::UnknownAttributeError
          if data.is_a?(Array)
            ids = data.map { |e| e[:id] }
            model.where(id: ids)
          else
            model.find_by(id: data[:id])
          end
        end
      end
    end
  end
end