archivesspace/archivesspace

View on GitHub
common/jsonmodel_type.rb

Summary

Maintainability
C
1 day
Test Coverage
# A common base class for all JSONModel classes
class JSONModelType

  # Class instance variables store the bits specific to this model
  def self.init(type, schema, mixins = [])
    @record_type = type
    @schema = schema

    # In client mode, mix in some extra convenience methods for querying the
    # ArchivesSpace backend service via HTTP.
    if JSONModel.client_mode?
      require_relative 'jsonmodel_client'
      include JSONModel::Client
    end


    define_accessors(schema['properties'].keys)


    mixins.each do |mixin|
      include(mixin)
    end
  end


  # If a JSONModel is extended, make its schema and record type class variables
  # available on the subclass too.
  def self.inherited(child)
    child.instance_variable_set(:@schema, schema)
    child.instance_variable_set(:@record_type, record_type)
  end


  # Return the JSON schema that defines this JSONModel class
  def self.schema
    @schema
  end


  # Return the version number of this JSONModel's schema
  def self.schema_version
    self.schema['version']
  end


  # Return the type of this JSONModel class (a keyword like
  # :archival_object)
  def self.record_type
    @record_type
  end


  def self.to_s
    "JSONModel(:#{self.record_type})"
  end


  # Add a custom validation to this model type.
  #
  # The validation is a block that takes a hash of properties and returns an array of pairs like:
  # [["propertyname", "the problem with it"], ...]
  def self.add_validation(name, level = :error, &block)
    raise "Validation name already taken: #{name}" if JSONModel.custom_validations[name]

    JSONModel.custom_validations[name] = block

    self.schema["validations"] ||= []
    self.schema["validations"] << [level, name]
  end


  # Create an instance of this JSONModel from the data contained in 'hash'.
  def self.from_hash(hash, raise_errors = true, trusted = false)
    hash["jsonmodel_type"] = self.record_type.to_s

    # If we're running in client mode, leave 'readonly' properties in place,
    # since they're intended for use by clients.  Otherwise, we drop them.
    drop_system_properties = !JSONModel.client_mode?

    if trusted
      # We got this data from a trusted source (such as another JSONModel
      # that had already been validated itself).  No need to double up
      self.new(hash, true)
    else
      cleaned = JSONSchemaUtils.drop_unknown_properties(hash, self.schema, drop_system_properties)
      cleaned = ASUtils.jsonmodels_to_hashes(cleaned)

      validate(cleaned, raise_errors)

      self.new(cleaned)
    end
  end


  # Create an instance of this JSONModel from a JSON string.
  def self.from_json(s, raise_errors = true)
    self.from_hash(ASUtils.json_parse(s), raise_errors)
  end


  def self.uri_and_remaining_options_for(id = nil, opts = {})
    # Some schemas (like name schemas) don't have a URI because they don't
    # need endpoints.  That's fine.
    if not self.schema['uri']
      return nil
    end

    uri = self.schema['uri']

    if not id.nil?
      uri += "/#{URI.escape(id.to_s)}"
    end

    self.substitute_parameters(uri, opts)
  end


  # Given a numeric internal ID and additional options produce a pair containing a URI reference.
  # For example:
  #
  #     JSONModel(:archival_object).uri_for(500, :repo_id => 123)
  #
  #  might yield "/repositories/123/archival_objects/500"
  #
  def self.uri_for(id = nil, opts = {})
    result = self.uri_and_remaining_options_for(id, opts)

    result ? result[0] : nil
  end


  # The inverse of uri_for:
  #
  #     JSONModel(:archival_object).id_for("/repositories/123/archival_objects/500", :repo_id => 123)
  #
  #  might yield 500
  #
  # IDs are either positive integers, or importer-provided logical IDs
  ID_REGEXP = /([0-9]+|import_[a-f0-9-]+)/

  def self.id_for(uri, opts = {}, noerror = false)
    if not self.schema['uri']
      if noerror
        return nil
      else
        raise "Missing a URI definition for class #{self.class}"
      end
    end

    pattern = self.schema['uri']
    pattern = pattern.gsub(/\/:[a-zA-Z_]+(\/|$)/, '/[^/ ]+\1')

    if uri =~ /#{pattern}\/#{ID_REGEXP}(\#.*)?$/
      return id_to_int($1)
    elsif uri =~ /#{pattern.gsub(/\[\^\/ \]\+\/tree/, '')}#{ID_REGEXP}\/(tree|ordered_records)$/
      # FIXME: gross hardcoding...
      return id_to_int($1)
    else
      if noerror
        nil
      else
        raise "Couldn't make an ID out of URI: #{uri}"
      end
    end
  end


  # Return the type of the schema property defined by 'path'
  #
  # For example, type_of("names/items/type") might return a JSONModel class
  def self.type_of(path)
    type = JSONSchemaUtils.schema_path_lookup(self.schema, path)["type"]

    ref = JSONModel.parse_jsonmodel_ref(type)

    if ref
      JSONModel.JSONModel(ref.first)
    else
      Kernel.const_get(type.capitalize)
    end
  end


  def initialize(params = {}, trusted = false)
    set_data(params)

    @uri ||= params['uri']

    # a hash to store transient instance data
    @instance_data = {}

    self.class.define_accessors(@data.keys)

    if trusted
      @validated = {}
      @cleaned_data = @data
    end
  end


  attr_reader :uri

  def uri=(val)
    @uri = val
    self['uri'] = val
  end

  def instance_data
    @instance_data
  end


  def [](key)
    @data[key.to_s]
  end


  def []=(key, val)
    @validated = false
    @data[key.to_s] = val
  end


  def has_key?(key)
    @data.has_key?(key)
  end
  alias :key? :has_key?


  # Validate the current JSONModel instance and return a list of exceptions
  # produced.
  def _exceptions
    return @validated if @validated && @errors.nil?

    exceptions = {}
    if not @always_valid
      exceptions = self.validate(@data, false)
    end

    if @errors
      exceptions[:errors] = (exceptions[:errors] or {}).merge(@errors)
    end

    exceptions
  end


  def clear_errors
    # reset validation
    @validated = false
    @errors = nil
  end


  def add_error(attribute, message)
    # reset validation
    @validated = false

    # call JSONModel::Client's version
    super
  end


  def _warnings
    exceptions = self._exceptions

    if exceptions.has_key?(:warnings)
      exceptions[:warnings]
    else
      []
    end
  end


  # Set this object instance to always pass validation.  Used so the
  # frontend can create intentionally incomplete objects that will be filled
  # out by the user.
  def _always_valid!
    @always_valid = true
    self
  end


  # Update the values of the current JSONModel instance with the contents of
  # 'params', validating before accepting the update.
  def update(params)
    @validated = false
    replace(ASUtils.deep_merge(@data, params))
  end

  # Update the values of the current JSONModel instance with the contents of
  # 'params', validating before accepting the update.
  def update_concat(params)
    @validated = false
    replace(ASUtils.deep_merge_concat(@data, params))
  end

  # Replace the values of the current JSONModel instance with the contents
  # of 'params', validating before accepting the replacement.
  def replace(params)
    @validated = false
    set_data(params)
  end


  def reset_from(another_jsonmodel)
    @data = another_jsonmodel.instance_eval { @data }
  end


  def to_s
    "#<JSONModel(:#{self.class.record_type}) #{@data.inspect}>"
  end


  def inspect
    self.to_s
  end


  # Produce a (possibly nested) hash from the values of this JSONModel.  Any
  # values that don't appear in the JSON schema will not appear in the
  # result.
  def to_hash(mode = nil)
    mode = (mode || :validated)

    raise "Invalid .to_hash mode: #{mode}" unless [:trusted, :validated, :raw].include?(mode)

    return @data if mode == :raw

    if @validated and @cleaned_data
      return @cleaned_data
    end

    cleaned = JSONSchemaUtils.drop_unknown_properties(@data, self.class.schema)
    cleaned = ASUtils.jsonmodels_to_hashes(cleaned)

    if mode == :validated
      @validated = false
      self.validate(cleaned)
    end

    @cleaned_data = cleaned
  end


  # Produce a JSON string from the values of this JSONModel.  Any values
  # that don't appear in the JSON schema will not appear in the result.
  def to_json(opts = {})
    ASUtils.to_json(self.to_hash(opts[:mode]), opts.is_a?(Hash) ? opts.merge(:max_nesting => false) : {})
  end


  # Return the internal ID of this JSONModel.
  def id
    ref = JSONModel::parse_reference(self.uri)

    if ref
      ref[:id]
    else
      nil
    end
  end


  protected


  def validate(data, raise_errors = true)
    @validated = self.class.validate(data, raise_errors)
  end


  # Validate the supplied hash using the JSON schema for this model.  Raise
  # a ValidationException if there are any fatal validation problems, or if
  # strict mode is enabled and warnings were produced.
  def self.validate(hash, raise_errors = true)
    properties = JSONSchemaUtils.drop_unknown_properties(hash, self.schema)
    ValidatorCache.with_validator_for(self, properties) do |validator|

      messages = validator.validate
      exceptions = JSONSchemaUtils.parse_schema_messages(messages, validator)

      if raise_errors && (!exceptions[:errors].empty? || (JSONModel.strict_mode? && !exceptions[:warnings].empty?))
        raise JSONModel::ValidationException.new(:invalid_object => self.new(hash),
                                      :warnings => exceptions[:warnings],
                                      :errors => exceptions[:errors],
                                      :attribute_types => exceptions[:attribute_types])
      end

      exceptions.reject {|k, v| v.empty?}
    end
  end


  # Given a URI template like /repositories/:repo_id/something/:somevar, and
  # a hash containing keys and replacement strings, return [uri, opts],
  # where 'uri' is the template with values substituted for their
  # placeholders, and 'opts' is any parameters that weren't consumed.
  #
  def self.substitute_parameters(uri, opts = {})
    matched = []
    opts.each do |k, v|
      old = uri
      uri = uri.gsub(":#{k}", URI.escape(v.to_s))

      if old != uri

        if v.is_a? Symbol
          raise ("Tried to substitute the value '#{v.inspect}' for ':#{k}'." +
                 "  This is usually a sign that something has gone wrong" +
                 " further up the stack. (URI was: '#{uri}')")
        end

        # Matched on this parameter.  Remove it from the passed in hash
        matched << k
      end
    end

    if uri.include?(":")
      raise "Template substitution was incomplete: '#{uri}'"
    end

    remaining_opts = opts.clone
    matched.each do |k|
      remaining_opts.delete(k)
    end

    [uri, remaining_opts]
  end


  private

  # Define accessors for all variable names listed in 'attributes'
  def self.define_accessors(attributes)
    attributes.each do |attribute|

      if not method_defined? "#{attribute}"
        define_method "#{attribute}" do
          @data[attribute]
        end
      end


      if not method_defined? "#{attribute}="
        define_method "#{attribute}=" do |value|
          @validated = false
          @data[attribute] = JSONModel.clean_data(value)
        end
      end
    end
  end


  def self.id_to_int(id)
    if id =~ /^import_/
      id
    else
      id.to_i
    end
  end


  def set_data(data)
    hash = JSONModel.clean_data(data)
    hash["jsonmodel_type"] = self.class.record_type.to_s
    hash = JSONSchemaUtils.apply_schema_defaults(hash, self.class.schema)

    @data = hash
  end


  def self.properties_by_tag(tag)
    @schema['properties'].find_all { |p, cfg| cfg.fetch('tags', []).include?(tag) }
  end
end