cerebris/jsonapi-resources

View on GitHub
lib/jsonapi/basic_resource.rb

Summary

Maintainability
F
6 days
Test Coverage
# frozen_string_literal: true

require 'jsonapi/callbacks'
require 'jsonapi/configuration'

module JSONAPI
  class BasicResource
    include Callbacks

    @abstract = true
    @immutable = true
    @root = true

    attr_reader :context

    define_jsonapi_resources_callbacks :create,
                                       :update,
                                       :remove,
                                       :save,
                                       :create_to_many_link,
                                       :replace_to_many_links,
                                       :create_to_one_link,
                                       :replace_to_one_link,
                                       :replace_polymorphic_to_one_link,
                                       :remove_to_many_link,
                                       :remove_to_one_link,
                                       :replace_fields

    def initialize(model, context)
      @model = model
      @context = context
      @reload_needed = false
      @changing = false
      @save_needed = false
    end

    def _model
      @model
    end

    def id
      _model.public_send(self.class._primary_key)
    end

    def identity
      JSONAPI::ResourceIdentity.new(self.class, id)
    end

    def cache_field_value
      _model.public_send(self.class._cache_field)
    end

    def cache_id
      [id, self.class.hash_cache_field(cache_field_value)]
    end

    def is_new?
      id.nil?
    end

    def change(callback)
      completed = false

      if @changing
        run_callbacks callback do
          completed = (yield == :completed)
        end
      else
        run_callbacks is_new? ? :create : :update do
          @changing = true
          run_callbacks callback do
            completed = (yield == :completed)
          end

          completed = (save == :completed) if @save_needed || is_new?
        end
      end

      return completed ? :completed : :accepted
    end

    def remove
      run_callbacks :remove do
        _remove
      end
    end

    def create_to_many_links(relationship_type, relationship_key_values, options = {})
      change :create_to_many_link do
        _create_to_many_links(relationship_type, relationship_key_values, options)
      end
    end

    def replace_to_many_links(relationship_type, relationship_key_values, options = {})
      change :replace_to_many_links do
        _replace_to_many_links(relationship_type, relationship_key_values, options)
      end
    end

    def replace_to_one_link(relationship_type, relationship_key_value, options = {})
      change :replace_to_one_link do
        _replace_to_one_link(relationship_type, relationship_key_value, options)
      end
    end

    def replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options = {})
      change :replace_polymorphic_to_one_link do
        _replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options)
      end
    end

    def remove_to_many_link(relationship_type, key, options = {})
      change :remove_to_many_link do
        _remove_to_many_link(relationship_type, key, options)
      end
    end

    def remove_to_one_link(relationship_type, options = {})
      change :remove_to_one_link do
        _remove_to_one_link(relationship_type, options)
      end
    end

    def replace_fields(field_data)
      change :replace_fields do
        _replace_fields(field_data)
      end
    end

    # Override this on a resource instance to override the fetchable keys
    def fetchable_fields
      self.class.fields
    end

    def model_error_messages
      _model.errors.messages
    end

    # Add metadata to validation error objects.
    #
    # Suppose `model_error_messages` returned the following error messages
    # hash:
    #
    #   {password: ["too_short", "format"]}
    #
    # Then to add data to the validation error `validation_error_metadata`
    # could return:
    #
    #   {
    #     password: {
    #       "too_short": {"minimum_length" => 6},
    #       "format": {"requirement" => "must contain letters and numbers"}
    #     }
    #   }
    #
    # The specified metadata is then be merged into the validation error
    # object.
    def validation_error_metadata
      {}
    end

    # Override this to return resource level meta data
    # must return a hash, and if the hash is empty the meta section will not be serialized with the resource
    # meta keys will be not be formatted with the key formatter for the serializer by default. They can however use the
    # serializer's format_key and format_value methods if desired
    # the _options hash will contain the serializer and the serialization_options
    def meta(_options)
      {}
    end

    # Override this to return custom links
    # must return a hash, which will be merged with the default { self: 'self-url' } links hash
    # links keys will be not be formatted with the key formatter for the serializer by default.
    # They can however use the serializer's format_key and format_value methods if desired
    # the _options hash will contain the serializer and the serialization_options
    def custom_links(_options)
      {}
    end

    private

    def save
      run_callbacks :save do
        _save
      end
    end

    # Override this on a resource to return a different result code. Any
    # value other than :completed will result in operations returning
    # `:accepted`
    #
    # For example to return `:accepted` if your model does not immediately
    # save resources to the database you could override `_save` as follows:
    #
    # ```
    # def _save
    #   super
    #   return :accepted
    # end
    # ```
    def _save(validation_context = nil)
      unless @model.valid?(validation_context)
        fail JSONAPI::Exceptions::ValidationErrors.new(self)
      end

      if defined? @model.save
        saved = @model.save(validate: false)

        unless saved
          if @model.errors.present?
            fail JSONAPI::Exceptions::ValidationErrors.new(self)
          else
            fail JSONAPI::Exceptions::SaveFailed.new
          end
        end
      else
        saved = true
      end
      @model.reload if @reload_needed
      @reload_needed = false

      @save_needed = !saved

      :completed
    end

    def _remove
      unless @model.destroy
        fail JSONAPI::Exceptions::ValidationErrors.new(self)
      end
      :completed

    rescue ActiveRecord::DeleteRestrictionError => e
      fail JSONAPI::Exceptions::RecordLocked.new(e.message)
    end

    def reflect_relationship?(relationship, options)
      return false if !relationship.reflect ||
        (!JSONAPI.configuration.use_relationship_reflection || options[:reflected_source])

      inverse_relationship = relationship.resource_klass._relationships[relationship.inverse_relationship]
      if inverse_relationship.nil?
        warn "Inverse relationship could not be found for #{self.class.name}.#{relationship.name}. Relationship reflection disabled."
        return false
      end
      true
    end

    def _create_to_many_links(relationship_type, relationship_key_values, options)
      relationship = self.class._relationships[relationship_type]
      relation_name = relationship.relation_name(context: @context)

      if options[:reflected_source]
        @model.public_send(relation_name) << options[:reflected_source]._model
        return :completed
      end

      # load requested related resources
      # make sure they all exist (also based on context) and add them to relationship

      related_resources = relationship.resource_klass.find_by_keys(relationship_key_values, context: @context)

      if related_resources.count != relationship_key_values.count
        # todo: obscure id so not to leak info
        fail JSONAPI::Exceptions::RecordNotFound.new('unspecified')
      end

      reflect = reflect_relationship?(relationship, options)

      related_resources.each do |related_resource|
        if reflect
          if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany)
            related_resource.create_to_many_links(relationship.inverse_relationship, [id], reflected_source: self)
          else
            related_resource.replace_to_one_link(relationship.inverse_relationship, id, reflected_source: self)
          end
          @reload_needed = true
        else
          unless @model.public_send(relation_name).include?(related_resource._model)
            @model.public_send(relation_name) << related_resource._model
          end
        end
      end

      :completed
    end

    def _replace_to_many_links(relationship_type, relationship_key_values, options)
      relationship = self.class._relationship(relationship_type)

      reflect = reflect_relationship?(relationship, options)

      if reflect
        existing = find_related_ids(relationship, options)

        to_delete = existing - (relationship_key_values & existing)
        to_delete.each do |key|
          _remove_to_many_link(relationship_type, key, reflected_source: self)
        end

        to_add = relationship_key_values - (relationship_key_values & existing)
        _create_to_many_links(relationship_type, to_add, {})

        @reload_needed = true
      elsif relationship.polymorphic?
        relationship_key_values.each do |relationship_key_value|
          relationship_resource_klass = self.class.resource_klass_for(relationship_key_value[:type])
          ids = relationship_key_value[:ids]

          related_records = relationship_resource_klass
            .records(options)
            .where({relationship_resource_klass._primary_key => ids})

          missed_ids = ids - related_records.pluck(relationship_resource_klass._primary_key)

          if missed_ids.present?
            fail JSONAPI::Exceptions::RecordNotFound.new(missed_ids)
          end

          relation_name = relationship.relation_name(context: @context)
          @model.send("#{relation_name}") << related_records
        end

        @reload_needed = true
      else
        send("#{relationship.foreign_key}=", relationship_key_values)
        @save_needed = true
      end

      :completed
    end

    def _replace_to_one_link(relationship_type, relationship_key_value, _options)
      relationship = self.class._relationships[relationship_type]

      send("#{relationship.foreign_key}=", relationship_key_value)
      @save_needed = true

      :completed
    end

    def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, _options)
      relationship = self.class._relationships[relationship_type.to_sym]

      send("#{relationship.foreign_key}=", {type: key_type, id: key_value})
      @save_needed = true

      :completed
    end

    def _remove_to_many_link(relationship_type, key, options)
      relationship = self.class._relationships[relationship_type]

      reflect = reflect_relationship?(relationship, options)

      if reflect

        related_resource = relationship.resource_klass.find_by_key(key, context: @context)

        if related_resource.nil?
          fail JSONAPI::Exceptions::RecordNotFound.new(key)
        else
          if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany)
            related_resource.remove_to_many_link(relationship.inverse_relationship, id, reflected_source: self)
          else
            related_resource.remove_to_one_link(relationship.inverse_relationship, reflected_source: self)
          end
        end

        @reload_needed = true
      else
        @model.public_send(relationship.relation_name(context: @context)).delete(key)
      end

      :completed

    rescue ActiveRecord::DeleteRestrictionError => e
      fail JSONAPI::Exceptions::RecordLocked.new(e.message)
    rescue ActiveRecord::RecordNotFound
      fail JSONAPI::Exceptions::RecordNotFound.new(key)
    end

    def _remove_to_one_link(relationship_type, _options)
      relationship = self.class._relationships[relationship_type]

      send("#{relationship.foreign_key}=", nil)
      @save_needed = true

      :completed
    end

    def _replace_fields(field_data)
      field_data[:attributes].each do |attribute, value|
        begin
          send "#{attribute}=", value
          @save_needed = true
        rescue ArgumentError
          # :nocov: Will be thrown if an enum value isn't allowed for an enum. Currently not tested as enums are a rails 4.1 and higher feature
          raise JSONAPI::Exceptions::InvalidFieldValue.new(attribute, value)
          # :nocov:
        end
      end

      field_data[:to_one].each do |relationship_type, value|
        if value.nil?
          remove_to_one_link(relationship_type)
        else
          case value
          when Hash
            replace_polymorphic_to_one_link(relationship_type.to_s, value.fetch(:id), value.fetch(:type))
          else
            replace_to_one_link(relationship_type, value)
          end
        end
      end if field_data[:to_one]

      field_data[:to_many].each do |relationship_type, values|
        replace_to_many_links(relationship_type, values)
      end if field_data[:to_many]

      :completed
    end

    def find_related_ids(relationship, options = {})
      send(relationship.foreign_key)
    end

    class << self
      def inherited(subclass)
        super
        subclass.abstract(false)
        subclass.immutable(false)
        subclass.caching(_caching)
        subclass.cache_field(_cache_field) if @_cache_field
        subclass.singleton(singleton?, (_singleton_options.dup || {}))
        subclass.exclude_links(_exclude_links)
        subclass.paginator(@_paginator)
        subclass._attributes = (_attributes || {}).dup
        subclass.polymorphic(false)
        subclass.key_type(@_resource_key_type)

        subclass._model_hints = (_model_hints || {}).dup

        unless _model_name.empty? || _immutable
          subclass.model_name(_model_name, add_model_hint: (_model_hints && !_model_hints[_model_name].nil?) == true)
        end

        subclass.rebuild_relationships(_relationships || {})

        subclass._allowed_filters = (_allowed_filters || Set.new).dup

        subclass._allowed_sort = _allowed_sort.dup

        type = subclass.name.demodulize.sub(/Resource$/, '').underscore
        subclass._type = type.pluralize.to_sym

        unless subclass._attributes[:id]
          subclass.attribute :id, format: :id, readonly: true
        end

        check_reserved_resource_name(subclass._type, subclass.name)

        subclass._routed = false
        subclass._warned_missing_route = false

        subclass._clear_cached_attribute_options
        subclass._clear_fields_cache
      end

      def rebuild_relationships(relationships)
        original_relationships = relationships.deep_dup

        @_relationships = {}

        if original_relationships.is_a?(Hash)
          original_relationships.each_value do |relationship|
            options = relationship.options.dup
            options[:parent_resource] = self
            options[:inverse_relationship] = relationship.inverse_relationship
            _add_relationship(relationship.class, relationship.name, options)
          end
        end
      end

      def resource_klass_for(type)
        type = type.underscore
        type_with_module = type.start_with?(module_path) ? type : module_path + type

        resource_name = _resource_name_from_type(type_with_module)
        resource = resource_name.safe_constantize if resource_name
        if resource.nil?
          fail NameError, "JSONAPI: Could not find resource '#{type}'. (Class #{resource_name} not found)"
        end
        resource
      end

      def resource_klass_for_model(model)
        resource_klass_for(resource_type_for(model))
      end

      def _resource_name_from_type(type)
        "#{type.to_s.underscore.singularize}_resource".camelize
      end

      def resource_type_for(model)
        model_name = model.class.to_s.underscore
        if _model_hints[model_name]
          _model_hints[model_name]
        else
          model_name.rpartition('/').last
        end
      end

      attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_routed, :_warned_missing_route
      attr_writer :_allowed_filters, :_paginator, :_allowed_sort

      def create(context)
        new(create_model, context)
      end

      def create_model
        _model_class.new
      end

      def routing_options(options)
        @_routing_resource_options = options
      end

      def routing_resource_options
        @_routing_resource_options ||= {}
      end

      # Methods used in defining a resource class
      def attributes(*attrs)
        options = attrs.extract_options!.dup
        attrs.each do |attr|
          attribute(attr, options)
        end
      end

      def attribute(attribute_name, options = {})
        _clear_cached_attribute_options
        _clear_fields_cache

        attr = attribute_name.to_sym

        check_reserved_attribute_name(attr)

        if (attr == :id) && (options[:format].nil?)
          ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.')
        end

        check_duplicate_attribute_name(attr) if options[:format].nil?

        @_attributes ||= {}
        @_attributes[attr] = options
        define_method attr do
          @model.public_send(options[:delegate] ? options[:delegate].to_sym : attr)
        end unless method_defined?(attr)

        define_method "#{attr}=" do |value|
          @model.public_send("#{options[:delegate] ? options[:delegate].to_sym : attr}=", value)
        end unless method_defined?("#{attr}=")

        if options.fetch(:sortable, true) && !_has_sort?(attr)
          sort attr
        end
      end

      def attribute_to_model_field(attribute)
        field_name = if attribute == :_cache_field
                       _cache_field
                     else
                       # Note: this will allow the returning of model attributes without a corresponding
                       # resource attribute, for example a belongs_to id such as `author_id` or bypassing
                       # the delegate.
                       attr = @_attributes[attribute]
                       attr && attr[:delegate] ? attr[:delegate].to_sym : attribute
                     end

        { name: field_name, type: _model_class.attribute_types[field_name.to_s]}
      end

      def cast_to_attribute_type(value, type)
        type.cast(value)
      end

      def default_attribute_options
        { format: :default }
      end

      def relationship(*attrs)
        options = attrs.extract_options!
        klass = case options[:to]
                  when :one
                    Relationship::ToOne
                  when :many
                    Relationship::ToMany
                  else
                    #:nocov:#
                    fail ArgumentError.new('to: must be either :one or :many')
                    #:nocov:#
                end
        _add_relationship(klass, *attrs, options.except(:to))
      end

      def has_one(*attrs)
        _add_relationship(Relationship::ToOne, *attrs)
      end

      def belongs_to(*attrs)
        ActiveSupport::Deprecation.warn "In #{name} you exposed a `has_one` relationship "\
                                        " using the `belongs_to` class method. We think `has_one`" \
                                        " is more appropriate. If you know what you're doing," \
                                        " and don't want to see this warning again, override the" \
                                        " `belongs_to` class method on your resource."
        _add_relationship(Relationship::ToOne, *attrs)
      end

      def has_many(*attrs)
        _add_relationship(Relationship::ToMany, *attrs)
      end

      # @model_class is inherited from superclass, and this causes some issues:
      # ```
      # CarResource._model_class #=> Vehicle # it should be Car
      # ```
      # so in order to invoke the right class from subclasses,
      # we should call this method to override it.
      def model_name(model, options = {})
        @model_class = nil
        @_model_name = model.to_sym

        model_hint(model: @_model_name, resource: self) unless options[:add_model_hint] == false

        rebuild_relationships(_relationships)
      end

      def model_hint(model: _model_name, resource: _type)
        resource_type = ((resource.is_a?(Class)) && (resource < JSONAPI::BasicResource)) ? resource._type : resource.to_s

        _model_hints[model.to_s.gsub('::', '/').underscore] = resource_type.to_s
      end

      def singleton(*attrs)
        @_singleton = (!!attrs[0] == attrs[0]) ? attrs[0] : true
        @_singleton_options = attrs.extract_options!
      end

      def _singleton_options
        @_singleton_options ||= {}
      end

      def singleton?
        @_singleton ||= false
      end

      def filters(*attrs)
        @_allowed_filters.merge!(attrs.inject({}) { |h, attr| h[attr] = {}; h })
      end

      def filter(attr, *args)
        @_allowed_filters[attr.to_sym] = args.extract_options!
      end

      def sort(sorting, options = {})
        self._allowed_sort[sorting.to_sym] = options
      end

      def sorts(*args)
        options = args.extract_options!
        _allowed_sort.merge!(args.inject({}) { |h, sorting| h[sorting.to_sym] = options.dup; h })
      end

      def primary_key(key)
        @_primary_key = key.to_sym
      end

      def cache_field(field)
        @_cache_field = field.to_sym
      end

      # Override in your resource to filter the updatable keys
      def updatable_fields(_context = nil)
        _updatable_relationships | _updatable_attributes - [:id]
      end

      # Override in your resource to filter the creatable keys
      def creatable_fields(_context = nil)
        _updatable_relationships | _updatable_attributes
      end

      # Override in your resource to filter the sortable keys
      def sortable_fields(_context = nil)
        _allowed_sort.keys
      end

      def sortable_field?(key, context = nil)
        sortable_fields(context).include? key.to_sym
      end

      def fields
        @_fields_cache ||= _relationships.keys | _attributes.keys
      end

      def resources_for(records, context)
        records.collect do |record|
          resource_for(record, context)
        end
      end

      def resource_for(model_record, context)
        resource_klass = resource_klass_for_model(model_record)
        resource_klass.new(model_record, context)
      end

      def verify_filters(filters, context = nil)
        verified_filters = {}
        filters.each do |filter, raw_value|
          verified_filter = verify_filter(filter, raw_value, context)
          verified_filters[verified_filter[0]] = verified_filter[1]
        end
        verified_filters
      end

      def is_filter_relationship?(filter)
        filter == _type || _relationships.include?(filter)
      end

      def verify_filter(filter, raw, context = nil)
        filter_values = []
        if raw.present?
          begin
            filter_values += raw.is_a?(String) ? CSV.parse_line(raw) : [raw]
          rescue CSV::MalformedCSVError
            filter_values << raw
          end
        end

        strategy = _allowed_filters.fetch(filter, Hash.new)[:verify]

        if strategy
          values = call_method_or_proc(strategy, filter_values, context)
          [filter, values]
        else
          if is_filter_relationship?(filter)
            verify_relationship_filter(filter, filter_values, context)
          else
            verify_custom_filter(filter, filter_values, context)
          end
        end
      end

      def call_method_or_proc(strategy, *args)
        if strategy.is_a?(Symbol) || strategy.is_a?(String)
          send(strategy, *args)
        else
          strategy.call(*args)
        end
      end

      def key_type(key_type)
        @_resource_key_type = key_type
      end

      def resource_key_type
        @_resource_key_type || JSONAPI.configuration.resource_key_type
      end

      # override to all resolution of masked ids to actual ids. Because singleton routes do not specify the id this
      # will be needed to allow lookup of singleton resources. Alternately singleton resources can override
      # `verify_key`
      def singleton_key(context)
        if @_singleton_options && @_singleton_options[:singleton_key]
          strategy = @_singleton_options[:singleton_key]
          case strategy
            when Proc
              key = strategy.call(context)
            when Symbol, String
              key = send(strategy, context)
            else
              raise "singleton_key must be a proc or function name"
          end
        end
        key
      end

      def verify_key(key, context = nil)
        key_type = resource_key_type

        case key_type
        when :integer
          return if key.nil?
          Integer(key)
        when :string
          return if key.nil?
          if key.to_s.include?(',')
            raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key)
          else
            key
          end
        when :uuid
          return if key.nil?
          if key.to_s.match(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)
            key
          else
            raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key)
          end
        else
          key_type.call(key, context)
        end
      rescue
        raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key)
      end

      # override to allow for key processing and checking
      def verify_keys(keys, context = nil)
        return keys.collect do |key|
          verify_key(key, context)
        end
      end

      # Either add a custom :verify lambda or override verify_custom_filter to allow for custom filters
      def verify_custom_filter(filter, value, _context = nil)
        [filter, value]
      end

      # Either add a custom :verify lambda or override verify_relationship_filter to allow for custom
      # relationship logic, such as uuids, multiple keys or permission checks on keys
      def verify_relationship_filter(filter, raw, _context = nil)
        [filter, raw]
      end

      # quasi private class methods
      def _attribute_options(attr)
        @_cached_attribute_options[attr] ||= default_attribute_options.merge(@_attributes[attr])
      end

      def _attribute_delegated_name(attr)
        @_attributes.fetch(attr.to_sym, {}).fetch(:delegate, attr)
      end

      def _has_attribute?(attr)
        @_attributes.keys.include?(attr.to_sym)
      end

      def _updatable_attributes
        _attributes.map { |key, options| key unless options[:readonly] }.compact
      end

      def _updatable_relationships
        @_relationships.map { |key, relationship| key unless relationship.readonly? }.compact
      end

      def _relationship(type)
        return nil unless type
        type = type.to_sym
        @_relationships[type]
      end

      def _model_name
        if _abstract
           ''
        else
          return @_model_name.to_s if defined?(@_model_name)
          class_name = self.name
          return '' if class_name.nil?
          @_model_name = class_name.demodulize.sub(/Resource$/, '')
          @_model_name.to_s
        end
      end

      def _polymorphic_name
        if !_polymorphic
          ''
        else
          @_polymorphic_name ||= _model_name.to_s.downcase
        end
      end

      def _primary_key
        @_primary_key ||= _default_primary_key
      end

      def _default_primary_key
        @_default_primary_key ||=_model_class.respond_to?(:primary_key) ? _model_class.primary_key : :id
      end

      def _cache_field
        @_cache_field || JSONAPI.configuration.default_resource_cache_field
      end

      def _table_name
        @_table_name ||= _model_class.respond_to?(:table_name) ? _model_class.table_name : _model_name.tableize
      end

      def _as_parent_key
        @_as_parent_key ||= "#{_type.to_s.singularize}_id"
      end

      def _allowed_filters
        defined?(@_allowed_filters) ? @_allowed_filters : { id: {} }
      end

      def _allowed_sort
        @_allowed_sort ||= {}
      end

      def _paginator
        @_paginator || JSONAPI.configuration.default_paginator
      end

      def paginator(paginator)
        @_paginator = paginator
      end

      def _polymorphic
        @_polymorphic
      end

      def polymorphic(polymorphic = true)
        @_polymorphic = polymorphic
      end

      def _polymorphic_types
        @poly_hash ||= {}.tap do |hash|
          ObjectSpace.each_object do |klass|
            next unless Module === klass
            if klass < ActiveRecord::Base
              klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection|
                (hash[reflection.options[:as]] ||= []) << klass.name.downcase
              end
            end
          end
        end
        @poly_hash[_polymorphic_name.to_sym]
      end

      def _polymorphic_resource_klasses
        @_polymorphic_resource_klasses ||= _polymorphic_types.collect do |type|
          resource_klass_for(type)
        end
      end

      def root_resource
        @abstract = true
        @immutable = true
        @root = true
      end

      def root?
        @root
      end

      def abstract(val = true)
        @abstract = val
      end

      def _abstract
        @abstract
      end

      def immutable(val = true)
        @immutable = val
      end

      def _immutable
        @immutable
      end

      def mutable?
        !@immutable
      end

      def parse_exclude_links(exclude)
        case exclude
          when :default, "default"
            [:self]
          when :none, "none"
            []
          when Array
            exclude.collect {|link| link.to_sym}
          else
            fail "Invalid exclude_links"
        end
      end

      def exclude_links(exclude)
        @_exclude_links = parse_exclude_links(exclude)
      end

      def _exclude_links
        @_exclude_links ||= parse_exclude_links(JSONAPI.configuration.default_exclude_links)
      end

      def exclude_link?(link)
        _exclude_links.include?(link.to_sym)
      end

      def caching(val = true)
        @caching = val
      end

      def _caching
        @caching
      end

      def caching?
        if @caching.nil?
          !JSONAPI.configuration.resource_cache.nil? && JSONAPI.configuration.default_caching
        else
          @caching && !JSONAPI.configuration.resource_cache.nil?
        end
      end

      def attribute_caching_context(_context)
        nil
      end

      # Generate a hashcode from the value to be used as part of the cache lookup
      def hash_cache_field(value)
        value.hash
      end

      def _model_class
        return nil if _abstract

        return @model_class if @model_class

        model_name = _model_name
        return nil if model_name.to_s.blank?

        @model_class = model_name.to_s.safe_constantize
        if @model_class.nil?
          warn "[MODEL NOT FOUND] Model could not be found for #{self.name}. If this is a base Resource declare it as abstract."
        end

        @model_class
      end

      def _allowed_filter?(filter)
        !_allowed_filters[filter].nil?
      end

      def _has_sort?(sorting)
        !_allowed_sort[sorting.to_sym].nil?
      end

      def module_path
        if name == 'JSONAPI::Resource'
          ''
        else
          name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').underscore : ''
        end
      end

      def default_sort
        [{field: 'id', direction: :asc}]
      end

      def construct_order_options(sort_params)
        sort_params = default_sort if sort_params.blank?

        return {} unless sort_params

        sort_params.each_with_object({}) do |sort, order_hash|
          field = sort[:field].to_s == 'id' ? _primary_key : sort[:field].to_s
          order_hash[field] = sort[:direction]
        end
      end

      def _add_relationship(klass, *attrs)
        _clear_fields_cache

        options = attrs.extract_options!
        options[:parent_resource] = self

        attrs.each do |name|
          relationship_name = name.to_sym
          check_reserved_relationship_name(relationship_name)
          check_duplicate_relationship_name(relationship_name)

          define_relationship_methods(relationship_name.to_sym, klass, options)
        end
      end

      # ResourceBuilder methods
      def define_relationship_methods(relationship_name, relationship_klass, options)
        relationship = register_relationship(
            relationship_name,
            relationship_klass.new(relationship_name, options)
        )

        define_foreign_key_setter(relationship)
      end

      def define_foreign_key_setter(relationship)
        if relationship.polymorphic?
          define_on_resource "#{relationship.foreign_key}=" do |v|
            _model.method("#{relationship.foreign_key}=").call(v[:id])
            _model.public_send("#{relationship.polymorphic_type}=", v[:type])
          end
        else
          define_on_resource "#{relationship.foreign_key}=" do |value|
            _model.method("#{relationship.foreign_key}=").call(value)
          end
        end
      end

      def define_on_resource(method_name, &block)
        return if method_defined?(method_name)
        define_method(method_name, block)
      end

      def register_relationship(name, relationship_object)
        @_relationships[name] = relationship_object
      end

      def _clear_cached_attribute_options
        @_cached_attribute_options = {}
      end

      def _clear_fields_cache
        @_fields_cache = nil
      end

      private

      def check_reserved_resource_name(type, name)
        if [:ids, :types, :hrefs, :links].include?(type)
          warn "[NAME COLLISION] `#{name}` is a reserved resource name."
          return
        end
      end

      def check_reserved_attribute_name(name)
        # Allow :id since it can be used to specify the format. Since it is a method on the base Resource
        # an attribute method won't be created for it.
        if [:type, :_cache_field, :cache_field].include?(name.to_sym)
          warn "[NAME COLLISION] `#{name}` is a reserved key in #{_resource_name_from_type(_type)}."
        end
      end

      def check_reserved_relationship_name(name)
        if [:id, :ids, :type, :types].include?(name.to_sym)
          warn "[NAME COLLISION] `#{name}` is a reserved relationship name in #{_resource_name_from_type(_type)}."
        end
      end

      def check_duplicate_relationship_name(name)
        if _relationships.include?(name.to_sym)
          warn "[DUPLICATE RELATIONSHIP] `#{name}` has already been defined in #{_resource_name_from_type(_type)}."
        end
      end

      def check_duplicate_attribute_name(name)
        if _attributes.include?(name.to_sym)
          warn "[DUPLICATE ATTRIBUTE] `#{name}` has already been defined in #{_resource_name_from_type(_type)}."
        end
      end
    end
  end
end