sanger/sequencescape

View on GitHub
app/models/api/base.rb

Summary

Maintainability
B
6 hrs
Test Coverage
C
71%
# frozen_string_literal: true
# Base class for API v0.5, now used for Warehouse message rendering, the actual
# api is no longer exposed.
# @note Originally the warehouse was built nightly by calls the the v0.5 API.
#       When the warehouse was switched to a queue based system the same JSON
#       exposed via the API was used to form the message payload.
class Api::Base # rubocop:todo Metrics/ClassLength
  class_attribute :includes
  self.includes = []

  # TODO[xxx]: This class is in a state of flux at the moment, please don't hack at this too much!
  #
  # Basically this is in a transition as I move more of the behaviour of the API into these model classes,
  # and out of the controllers, and will eventually be much clearer.  And, although this class looks
  # extremely complex, it's purpose is to make subclasses much, much easier to write and maintain.

  #--
  # The following block defines the methods used by the Api::BaseController class.
  #++
  class << self
    def create!(params)
      model_class.create!(attributes_from_json(params))
    end

    def update!(object, params)
      object.update!(attributes_from_json(params))
    end

    # Maps the attribute names in the errors to their JSON counterparts, so that the end user gets
    # the correct information.
    def map_attribute_to_json_attribute_in_errors(attribute_errors)
      attribute_errors.transform_keys { |a| json_attribute_for_attribute(*a.to_s.split('.')) }
    end
  end

  #--
  # This block defines the methods used to convert objects to JSON.  You'll find the code that calls this
  # in lib/api_tools.rb, as well as in the Api::AssetsController.
  #++
  class << self
    # rubocop:todo Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
    def to_hash(object) # rubocop:todo Metrics/CyclomaticComplexity
      # If the object is nil we get a chance to use the 'default' object that was specified.  By
      # default the "default" object is nil, but you can override it for associations through the
      # with_association(:name, :if_nil_use => :some_method).
      object ||= default_object
      return {} if object.nil?

      json_attributes = {}
      json_attributes['deleted_at'] = Time.zone.now if object.destroyed?

      attribute_to_json_attribute_mappings.each do |attribute, json_attribute|
        json_attributes[json_attribute] = object.send(attribute)
      end
      associations.each do |_association, helper|
        value = helper.target(object)
        json_attributes.update(helper.to_hash(value))
        helper.newer_than(value, json_attributes['updated_at']) do |timestamp|
          json_attributes['updated_at'] = timestamp
        end
      end
      nested_has_many_associations.each do |_association, helper|
        values = helper.target(object)
        all_targets =
          values.map do |value|
            helper.newer_than(value, json_attributes['updated_at']) do |timestamp|
              json_attributes['updated_at'] = timestamp
            end
            helper.to_hash(value)
          end
        json_attributes.update(helper.alias.to_s => all_targets)
      end
      extra_json_attribute_handlers.each { |handler| handler.call(object, json_attributes) }
      json_attributes
    end

    # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity

    def to_hash_for_list(object)
      raise StandardError, 'The object is nil, which is highly unexpected!' if object.nil?

      json_attributes = {}
      attribute_to_json_attribute_mappings_for_list.each do |attribute, json_attribute|
        json_attributes[json_attribute] = object.send(attribute)
      end
      json_attributes
    end
  end

  #--
  # This code is called when constructing a runtime class for the I/O of a class that does not have
  # a specific I/O class.
  #++
  class << self
    # The default behaviour for any model I/O is to write out all of the columns as they appear.  Some of
    # the columns are ignored, a few manipulated, but mostly it's a direct copy.
    # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
    def render_class_for_model(model) # rubocop:todo Metrics/CyclomaticComplexity
      render_class = Class.new(self)

      # NOTE: It's quite annoying that you don't have any access to the inheritable class attributes from
      # within the Class.new block above, so we have to do a separate instance_eval to get it to work.
      render_class.instance_eval do
        self.model_class = model
        model.column_names.each { |column| map_attribute_to_json_attribute(column, column) }

        # TODO[xxx]: It's better that some of these are decided at generation, rather than execution, time.
        extra_json_attributes do |object, json_attributes|
          json_attributes['uuid'] = object.uuid if object.respond_to?(:uuid)

          # Users and roles
          json_attributes['user'] = object.user.nil? ? 'unknown' : object.user.login if object.respond_to?(:user)
          if object.respond_to?(:roles)
            object.roles.each do |role|
              json_attributes[role.name.underscore] =
                role.users.map { |user| { login: user.login, email: user.email, name: user.name } }
            end
          end
        end
      end
      render_class
    end
    # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
  end

  # The model class that our I/O methods are responsible for
  class_attribute :model_class

  def self.renders_model(model)
    self.model_class = model
  end

  # Contains the mapping from the ActiveRecord attribute to the key in the JSON hash
  class_attribute :attribute_to_json_attribute_mappings, instance_writer: false
  self.attribute_to_json_attribute_mappings = {}

  # TODO[xxx]: Need to warn about 'id' not being 'internal_id'
  def self.map_attribute_to_json_attribute(attribute, json_attribute = attribute)
    self.attribute_to_json_attribute_mappings =
      attribute_to_json_attribute_mappings.merge(attribute.to_sym => json_attribute.to_s)
  end

  # Contains the mapping from the ActiveRecord association to the I/O object that can output it.
  class_attribute :associations, instance_writer: false
  self.associations = {}

  # Contains the mapping from the ActiveRecord association to the I/O object that can output it.
  class_attribute :nested_has_many_associations
  self.nested_has_many_associations = {}

  # rubocop:todo Metrics/PerceivedComplexity, Metrics/AbcSize
  def self.newer_than(object, timestamp) # rubocop:todo Metrics/CyclomaticComplexity
    return if object.nil? || timestamp.nil?

    modified, object_timestamp = false, (object.respond_to?(:updated_at) ? object.updated_at : timestamp) || timestamp
    timestamp, modified = object_timestamp, true if object_timestamp > timestamp
    associations.each_value do |helper|
      helper.newer_than(helper.target(object), timestamp) { |t| timestamp, modified = t, true }
    end
    nested_has_many_associations.each_value do |helper|
      helper.target(object).each { |child| helper.newer_than(child, timestamp) { |t| timestamp, modified = t, true } }
    end
    yield(timestamp) if modified
  end

  # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity

  # Returns the default object to use (by default this is 'nil') and can be overridden by passing
  # ':if_nil_use => :some_function_that_returns_default_object' to with_association.
  def self.default_object
    nil
  end

  # rubocop:todo Metrics/MethodLength
  def self.with_association(association, options = {}, &block) # rubocop:todo Metrics/AbcSize
    association_helper = Class.new(Api::Base)
    association_helper.class_eval(&block)
    association_helper.singleton_class.class_eval do
      alias_method(:default_object, options[:if_nil_use]) if options.key?(:if_nil_use)
      define_method(:lookup_by) { options[:lookup_by] }
      define_method(:association) { association }
      define_method(:target) do |parent|
        target_object = parent.send(association)
        options[:decorator] && target_object ? options[:decorator].new(target_object) : target_object
      end
    end
    self.associations = Hash.new if associations.empty?
    associations[association.to_sym] = association_helper
  end

  # rubocop:enable Metrics/MethodLength

  # rubocop:todo Metrics/MethodLength
  def self.with_nested_has_many_association(association, options = {}, &block) # rubocop:todo Metrics/AbcSize
    association_helper = Class.new(Api::Base)
    association_helper.class_eval(&block)
    association_helper.singleton_class.class_eval do
      define_method(:association) { association }
      define_method(:alias) { options[:as] || association }
      define_method(:target) do |parent|
        target_object = parent.send(association)
        options[:decorator] && target_object ? options[:decorator].new(target_object) : target_object
      end
    end
    self.nested_has_many_associations = Hash.new if nested_has_many_associations.empty?
    nested_has_many_associations[association.to_sym] = association_helper
  end

  # rubocop:enable Metrics/MethodLength

  def self.performs_lookup?
    !!lookup_by
  end

  def self.lookup_associated_record_from(json_attributes)
    attributes = convert_json_attributes_to_attributes(json_attributes)
    return unless attributes.key?(lookup_by)

    search_parameters = { lookup_by => attributes[lookup_by] }
    yield(association.to_s.classify.constantize.find_by(search_parameters))
  end

  # Contains the mapping from the ActiveRecord attribute to the key in the JSON hash when listing objects
  class_attribute :attribute_to_json_attribute_mappings_for_list

  self.attribute_to_json_attribute_mappings_for_list = {
    id: 'id',
    uuid: 'uuid', # TODO[xxx]: if respond_to?(:uuid)
    url: 'url', # TODO[xxx]: if respond_to?(:uuid)
    name: 'name' # TODO[xxx]: if respond_to?(:name)
  }

  # Additional JSON attribute handling, that cannot be done with the simple stuff, should be passed
  # done through a block
  class_attribute :extra_json_attribute_handlers, instance_writer: false
  self.extra_json_attribute_handlers = []

  def self.extra_json_attributes(&block)
    self.extra_json_attribute_handlers = Array.new if extra_json_attribute_handlers.empty?
    extra_json_attribute_handlers.push(block)
  end

  class << self
    def attributes_from_json(params)
      convert_json_attributes_to_attributes(params[model_class.name.underscore])
    end

    # rubocop:todo Metrics/MethodLength
    def convert_json_attributes_to_attributes(json_attributes) # rubocop:todo Metrics/AbcSize
      return {} if json_attributes.blank?

      attributes = {}
      attribute_to_json_attribute_mappings.each do |attribute, json_attribute|
        attributes[attribute] = json_attributes[json_attribute] if json_attributes.key?(json_attribute)
      end
      associations.each do |association, helper|
        if helper.performs_lookup?
          helper.lookup_associated_record_from(json_attributes) do |associated_record|
            attributes[:"#{association}_id"] = associated_record.try(:id)
          end
        else
          association_attributes = helper.convert_json_attributes_to_attributes(json_attributes)
          attributes[:"#{association}_attributes"] = association_attributes unless association_attributes.empty?
        end
      end
      attributes
    end

    # rubocop:enable Metrics/MethodLength

    # rubocop:todo Metrics/MethodLength
    def json_attribute_for_attribute(attribute_or_association, *rest) # rubocop:todo Metrics/AbcSize
      json_attribute = attribute_to_json_attribute_mappings[attribute_or_association.to_sym]
      if json_attribute.blank?
        # If we have reached the end of the line, and the attribute_or_association is for what looks like
        # an association, then we'll look it up without the '_id' and return that value.
        if attribute_or_association.to_s =~ (/_id$/) && rest.empty?
          association = associations[attribute_or_association.to_s.sub(/_id$/, '').to_sym]
          raise StandardError, "Unexpected association #{attribute_or_association.inspect}" if association.nil?

          return association.json_attribute_for_attribute(:name)
        end
        json_attribute = associations[attribute_or_association.to_sym].json_attribute_for_attribute(*rest)
      end
      if json_attribute.blank?
        raise StandardError, "Unexpected attribute #{attribute_or_association.inspect} does not appear to be mapped"
      end

      json_attribute
    end
    # rubocop:enable Metrics/MethodLength
  end
end