openaustralia/atdis

View on GitHub
lib/atdis/model.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# frozen_string_literal: true

require "multi_json"
require "active_model"
require "date"

module ATDIS
  module TypeCastAttributes
    extend ActiveSupport::Concern

    included do
      class_attribute :attribute_types
    end

    module ClassMethods
      # of the form {section: Integer, address: String}
      def field_mappings(params)
        define_attribute_methods(params.keys.map(&:to_s))
        # Convert all values to arrays. Doing this for the sake of tidier notation
        self.attribute_types = {}
        params.each do |k, v|
          v = [v] unless v.is_a?(Array)
          attribute_types[k] = v
        end
      end
    end
  end

  ErrorMessage = Struct.new :message, :spec_section do
    def empty?
      message.empty?
    end

    # Make this behave pretty much like a string
    def to_s
      message
    end
  end

  class Model
    include ActiveModel::Validations
    include Validators
    include ActiveModel::AttributeMethods
    include TypeCastAttributes
    attribute_method_suffix "_before_type_cast"
    attribute_method_suffix "="

    attr_reader :attributes, :attributes_before_type_cast, :timezone
    # Stores any part of the json that could not be interpreted. Usually
    # signals an error if it isn't empty.
    attr_accessor :json_left_overs, :json_load_error
    attr_accessor :url

    validate :json_loaded_correctly!
    validate :json_left_overs_is_empty

    # Partition the data into used and unused by returning [used, unused]
    def self.partition_by_used(data)
      used = {}
      unused = {}
      if data.respond_to?(:each)
        data.each do |key, value|
          if attribute_keys.include?(key)
            used[key] = value
          else
            unused[key] = value
          end
        end
      else
        unused = data
      end
      [used, unused]
    end

    def self.read_url_raw(url, ignore_ssl_certificate = false)
      RestClient::Resource.new(
        url.to_s,
        verify_ssl: (OpenSSL::SSL::VERIFY_NONE if ignore_ssl_certificate)
      ).get.to_str
    end

    def self.read_url(url, timezone, ignore_ssl_certificate = false)
      r = read_json(read_url_raw(url, ignore_ssl_certificate), timezone)
      r.url = url.to_s
      r
    end

    def self.read_json(text, timezone)
      data = MultiJson.load(text, symbolize_keys: true)
      interpret(data, timezone)
    rescue MultiJson::LoadError => e
      a = interpret({ response: [] }, timezone)
      a.json_load_error = e.to_s
      a
    end

    def self.interpret(data, timezone)
      used, unused = partition_by_used(data)
      new(used.merge(json_left_overs: unused), timezone)
    end

    def json_loaded_correctly!
      return unless json_load_error

      errors.add(:json, ErrorMessage["Invalid JSON: #{json_load_error}", nil])
    end

    def json_errors_local
      r = []
      # First show special json error
      errors.attribute_names.each do |attribute|
        r << [nil, errors[:json]] unless errors[:json].empty?
        # The :json attribute is special
        next if attribute == :json

        e = errors[attribute]
        next if e.empty?

        r << [
          { attribute => attributes_before_type_cast[attribute.to_s] },
          e.map { |m| ErrorMessage["#{attribute} #{m}", m.spec_section] }
        ]
      end
      r
    end

    def json_errors_in_children
      r = []
      attributes.each do |attribute_as_string, value|
        attribute = attribute_as_string.to_sym
        if value.respond_to?(:json_errors)
          r += value.json_errors.map { |a, b| [{ attribute => a }, b] }
        elsif value.is_a?(Array)
          f = value.find { |v| v.respond_to?(:json_errors) && !v.json_errors.empty? }
          r += f.json_errors.map { |a, b| [{ attribute => [a] }, b] } if f
        end
      end
      r
    end

    def json_errors
      json_errors_local + json_errors_in_children
    end

    # Have we tried to use this attribute?
    def used_attribute?(attribute)
      !attributes_before_type_cast[attribute].nil?
    end

    def json_left_overs_is_empty
      return unless json_left_overs && !json_left_overs.empty?

      # We have extra parameters that shouldn't be there
      errors.add(
        :json,
        ErrorMessage["Unexpected parameters in json data: #{MultiJson.dump(json_left_overs)}", "4"]
      )
    end

    def initialize(params, timezone)
      @timezone = timezone
      @attributes = {}
      @attributes_before_type_cast = {}
      return unless params

      params.each do |attr, value|
        send("#{attr}=", value)
      end
    end

    def self.attribute_keys
      attribute_types.keys
    end

    # Does what the equivalent on Activerecord does
    def self.attribute_names
      attribute_types.keys.map(&:to_s)
    end

    def self.cast(value, type, timezone)
      # If it's already the correct type (or nil) then we don't need to do anything
      if value.nil? || value.is_a?(type)
        value
      # Special handling for arrays. When we typecast arrays we actually
      # typecast each member of the array
      elsif value.is_a?(Array)
        value.map { |v| cast(v, type, timezone) }
      elsif type == DateTime
        cast_datetime(value, timezone)
      elsif type == URI
        cast_uri(value)
      elsif type == String
        cast_string(value)
      elsif type == Integer
        cast_integer(value)
      elsif type == RGeo::GeoJSON
        cast_geojson(value)
      # Otherwise try to use Type.interpret to do the typecasting
      elsif type.respond_to?(:interpret)
        type.interpret(value, timezone) if value
      else
        raise
      end
    end

    # If timezone is given in the string then the datetime is read in using
    # the timezone in the string and then converted to the timezone "zone"
    # If the timezone isn't given in the string then the datetime is read
    # in using the timezone in "zone"
    def self.cast_datetime(value, timezone)
      ActiveSupport::TimeZone.new(timezone).iso8601(value).to_datetime
    rescue ArgumentError, KeyError
      nil
    end

    def self.cast_uri(value)
      URI.parse(value)
    rescue URI::InvalidURIError
      nil
    end

    def self.cast_string(value)
      value.to_s
    end

    # This casting allows nil values
    def self.cast_integer(value)
      value&.to_i
    end

    def self.cast_geojson(value)
      RGeo::GeoJSON.decode(hash_symbols_to_string(value))
    end

    # Converts {foo: {bar: "yes"}} to {"foo" => {"bar" => "yes"}}
    def self.hash_symbols_to_string(hash)
      if hash.respond_to?(:each_pair)
        result = {}
        hash.each_pair do |key, value|
          result[key.to_s] = hash_symbols_to_string(value)
        end
        result
      else
        hash
      end
    end

    private

    def attribute(attr)
      @attributes[attr]
    end

    def attribute_before_type_cast(attr)
      @attributes_before_type_cast[attr]
    end

    def attribute=(attr, value)
      @attributes_before_type_cast[attr] = value
      @attributes[attr] = Model.cast(value, attribute_types[attr.to_sym][0], timezone)
    end
  end
end