bbean86/jsonapi_rails

View on GitHub
lib/jsonapi_rails/params_to_object.rb

Summary

Maintainability
B
4 hrs
Test Coverage
module JsonApiRails
  # {ParamsToObject} converts a JSONAPI compliant hash-like object into a
  # ready-to-use model.
  class ParamsToObject
    attr_reader :object
    attr_reader :attributes
    # Array of attribute names not set on the Resource but that should be allowed
    # Useful for transient attributes, such as a credit card number
    attr_reader :permitted

    def initialize(data_hash, ar_relation = nil, resource_class = nil, permitted = [])
      validate_json(data_hash)

      data = data_hash[:data]
      @object = setup_object(data, ar_relation)
      @resource_class = resource_class
      @permitted = permitted

      @attributes = Hash(data[:attributes])
      @relationships = Hash(data[:relationships])

      assign_attributes
      assign_relationships
    end

    # Finds or initializes (if given a uuid) the object specified by the type
    # and, if present, the id from the data hash
    #
    # @param [Hash] data The data element of the JSON API hash
    # @param [ActiveRecord::Relation] ar_relation An ActiveRecord::Relation
    # object, used to build new objects from. This allows a controller to
    # properly scope found/initialized objects.
    # @option data [String] :id The ID of the object we should be searching
    # for. This can be either a database id or a UUID. If given a UUID, will
    # initialize a new object if it does not exist.
    # @option data [String] :type The type of object (person, article, etc)
    #
    # @return [klass] An instance of the object specified by *klass*
    def setup_object(data, ar_relation)
      type = data[:type]
      klass = ar_relation || type.classify.constantize

      return klass.new if data[:id].blank?

      # such hack
      # assume it's a UUID and *klass* responds to find_or_initialize_by_uuid
      if data[:id].index('-') != nil
        if klass.respond_to? :find_or_initialize_by_uuid
          # handle Rails 3 ActiveRecord classes
          klass.find_or_initialize_by_uuid(data[:id])
        else
          # handle Rails 4 ActiveRecord classes
          klass.find_or_initialize_by(uuid: data[:id])
        end
      else
        klass.find_by_id(data[:id])
      end
    end

    # Check that we receive a hash that contains a *data* object and that the
    # *data* object contains a *type* field
    #
    # @param [Hash] json_data The JSON data to be validated.
    #   Should have a structure similar to the following:
    #     {
    #       data: {
    #         type: 'people'
    #       }
    #     }
    def validate_json(json_data)
      validation_hash = {
        data: { type: 'string' }
      }

      validator = HashValidator.validate(json_data, validation_hash)
      check_validation_errors(validator)
    end

    # Calls *save* on the underlying object
    def save
      object.save
    end

    # Calls *save!* on the underlying object. This will raise an error if the save fails
    def save!
      object.save!
    end

    # Calls the setter method "attr=" with the attribute value for each
    # attribute passed into this class on initialization
    #
    # @raise [UnknownAttributeError] raised if the `resource` does not define
    #   the attribute
    def assign_attributes
      attributes.each_with_object({}) do |(attr, value), hsh|
        unless permitted?(attr)
          message = "`#{resource.class}' does not have attribute " \
                    "`#{attr.to_s.gsub('=', '')}'"
          fail UnknownAttributeError.new(message)
        end
        hsh[attr] = value if assignable_attribute_names.include? attr.to_s
      end.each do |attr,value|
        check_method("#{attr}=")
        object.send("#{attr}=", value)
      end
    end

    # For each relationship specified in the relationships hash and defined on
    # the Resource, find the underlying object and assign it to the *object*
    # relationship.
    #
    # Example:
    #   class Person
    #     has_many :articles
    #   end
    #
    #   class Article
    #     belongs_to :person
    #   end
    #
    # With this relationship hash on article update/create:
    #   {
    #     person: {
    #       data: { id: '1', type: 'people' }
    #     }
    #   }
    #
    # This method would find the Person object identified by id *1* and
    # assign it to the instance of *article*
    #
    # Going the other way, given the following relationship hash when
    # creating/updating a person:
    #   {
    #     articles {
    #       data: [{id: '1', type: 'articles'}]
    #     }
    #   }
    #
    # This method would find the article identified by id *1* and _append_
    # that article to the *articles* relationship on the dynamically found
    # *person* object
    #
    # @raise [UnknownRelationshipError] raised if the `resource` does not
    #   define the given relationship
    def assign_relationships
      relationships.each do |rel|
        next if rel.blank?
        json_api_relationship = resource.relationships.detect do |relationship|
          relationship.name == rel[:name].to_s
        end
        unless json_api_relationship
          message = "`#{resource.class}' does not have relationship " \
                    "`#{rel[:name].to_s.gsub('=', '')}'"
          raise UnknownRelationshipError.new(message)
        end
        relationship_value =
          if json_api_relationship.cardinality == :many
            # cast to an array to prevent assigning `null` to an ActiveRecord
            # to-many relationship
            rel[:relations].to_a
          else
            # no need to cast potential `null`s, since an ActiveRecord to-one
            # can be set to `null`
            rel[:relations]
          end
        check_method("#{rel[:name]}=", relationship: true)
        object.send("#{rel[:name]}=", relationship_value)
      end
    end

    # Check if *object* responds to a specific method. Raise an error if it
    # doesn't
    #
    # @param method_name [String] The name of the method we're checking
    #
    # @raise [UnknownRelationshipError] if the relationship specified by
    #   *method_name* does not exist on the object
    # @raise [UnknownAttributeError] if the attribute specified by
    #   *method_name* does not exist on the object
    def check_method(method_name, relationship: false)
      return if object.respond_to?(method_name)

      # looking for setter attributes and relationships
      message = "`#{object.class}' does not have %{what} `#{method_name.to_s.gsub('=', '')}'"
      if relationship
        fail UnknownRelationshipError.new(message % {what: :relationship})
      else
        fail UnknownAttributeError.new(message % {what: :attribute})
      end
    end

    # Parse through the relationships object and returns an instance of a
    # class specified by *type*
    #
    # Given the following relationships structure:
    #   {
    #     person: {
    #       data: { id: '1', type: 'people' }
    #     }
    #   }
    #
    # This method will call
    #   Person.find_by_id('1')
    #
    # @return [Array] An array representing the method to be called on
    #   *object* and the objects found
    #     { name: :person, relations: [discovered_object] }
    def relationships
      @relationships.flat_map do |relationship_name, rel|
        next unless rel.key?(:data)
        if rel[:data].nil?
          {name: relationship_name, relations: nil}
        elsif rel[:data].blank?
          {name: relationship_name, relations: []}
        else
          created_objects = if rel[:data].is_a?(Array)
            rel[:data].map do |nested_rel|
              next if nested_rel.blank?
              validate_relationship_hash(nested_rel)
              self.class.new({data: nested_rel}).object
            end
          else
            validate_relationship_hash(rel[:data])
            self.class.new(rel).object
          end
          {name: relationship_name, relations: created_objects}
        end
      end
    end

    # Validates that the specified hash has both an id and a type
    #
    # @param [Hash] rel_hash The hash that will be validated
    def validate_relationship_hash(rel_hash)
      validation = { id: 'string', type: 'string' }
      validator = HashValidator.validate(rel_hash, validation)
      check_validation_errors(validator)
    end

    # Ensures that the validator has no errors. If the validator has errors,
    # they'll be stringified and passed in as the message to
    # {ValidationError}
    #
    # @param [HashValidator] validator The *validator* object that will be
    #   checked for errors
    def check_validation_errors(validator)
      error_template = "Field `%{field_name}' is malformed or missing"
      errors = validator.errors.each_with_object([]) do |(attr, _), result|
        result << error_template % {field_name: attr}
      end
      fail ValidationError.new(errors.join(', ')) if errors.present?
    end

    # Inspects the model using `attribute_names` and selects the attributes
    # which are also present in the Resource's `fields_array`. Alternatively,
    # properties from the model can be permitted, and will also be returned.
    #
    # @return [Array<String>] list of attributes available to assign to the
    # underlying model
    def assignable_attribute_names
      stored_attributes = object.attribute_names.select &method(:permitted?)
      transient_attributes = permitted.select do |attribute_name|
        check_method("#{attribute_name}=")
        true
      end.map(&:to_s)
      stored_attributes + transient_attributes
    end

    # Instanciates a Resource built with `object`
    #
    # @return [Resource]
    def resource
      @resource ||= resource_klass.new object
    end

    # Resolves the Resource class for `object`
    #
    # @return [Resource] the resolved Resource class for `object`
    def resource_klass
      @resource_klass ||= JsonApi::Resources::Discovery
        .resource_for_name object,
                           resource_class: @resource_class
    end

    # Checks for the attribute's presence in the Resource's fields array or the
    # permitted whitelist
    def permitted?(attribute_name)
      resource.fields_array.include?(attribute_name.to_sym) ||
      permitted.include?(attribute_name.to_sym)
    end
  end
end