ruby-grape/grape

View on GitHub
lib/grape/validations/types/custom_type_coercer.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

module Grape
  module Validations
    module Types
      # This class will detect type classes that implement
      # a class-level +parse+ method. The method should accept one
      # +String+ argument and should return the value coerced to
      # the appropriate type. The method may raise an exception if
      # there are any problems parsing the string.
      #
      # Alternately an optional +method+ may be supplied (see the
      # +coerce_with+ option of {Grape::Dsl::Parameters#requires}).
      # This may be any class or object implementing +parse+ or +call+,
      # with the same contract as described above.
      #
      # Type Checking
      # -------------
      #
      # Calls to +coerced?+ will consult this class to check
      # that the coerced value produced above is in fact of the
      # expected type. By default this class performs a basic check
      # against the type supplied, but this behaviour will be
      # overridden if the class implements a class-level
      # +coerced?+ or +parsed?+ method. This method
      # will receive a single parameter that is the coerced value
      # and should return +true+ if the value meets type expectations.
      # Arbitrary assertions may be made here but the grape validation
      # system should be preferred.
      #
      # Alternately a proc or other object responding to +call+ may be
      # supplied in place of a type. This should implement the same
      # contract as +coerced?+, and must be supplied with a coercion
      # +method+.
      class CustomTypeCoercer
        # A new coercer for the given type specification
        # and coercion method.
        #
        # @param type [Class,#coerced?,#parsed?,#call?]
        #   specifier for the target type. See class docs.
        # @param method [#parse,#call]
        #   optional coercion method. See class docs.
        def initialize(type, method = nil)
          coercion_method = infer_coercion_method type, method
          @method = enforce_symbolized_keys type, coercion_method
          @type_check = infer_type_check(type)
        end

        # Coerces the given value.
        #
        # @param value [String] value to be coerced, in grape
        #   this should always be a string.
        # @return [Object] the coerced result
        def call(val)
          coerced_val = @method.call(val)

          return coerced_val if coerced_val.is_a?(InvalidValue)
          return InvalidValue.new unless coerced?(coerced_val)

          coerced_val
        end

        def coerced?(val)
          val.nil? || @type_check.call(val)
        end

        private

        # Determine the coercion method we're expected to use
        # based on the parameters given.
        #
        # @param type see #new
        # @param method see #new
        # @return [#call] coercion method
        def infer_coercion_method(type, method)
          if method
            if method.respond_to? :parse
              method.method :parse
            else
              method
            end
          else
            # Try to use parse() declared on the target type.
            # This may raise an exception, but we are out of ideas anyway.
            type.method :parse
          end
        end

        # Determine how the type validity of a coerced
        # value should be decided.
        #
        # @param type see #new
        # @return [#call] a procedure which accepts a single parameter
        #   and returns +true+ if the passed object is of the correct type.
        def infer_type_check(type)
          # First check for special class methods
          if type.respond_to? :coerced?
            type.method :coerced?
          elsif type.respond_to? :parsed?
            type.method :parsed?
          elsif type.respond_to? :call
            # Arbitrary proc passed for type validation.
            # Note that this will fail unless a method is also
            # passed, or if the type also implements a parse() method.
            type
          elsif type.is_a?(Enumerable)
            lambda do |value|
              value.is_a?(Enumerable) && value.all? do |val|
                recursive_type_check(type.first, val)
              end
            end
          else
            # By default, do a simple type check
            ->(value) { value.is_a? type }
          end
        end

        def recursive_type_check(type, value)
          if type.is_a?(Enumerable) && value.is_a?(Enumerable)
            value.all? { |val| recursive_type_check(type.first, val) }
          else
            !type.is_a?(Enumerable) && value.is_a?(type)
          end
        end

        # Enforce symbolized keys for complex types
        # by wrapping the coercion method such that
        # any Hash objects in the immediate heirarchy
        # have their keys recursively symbolized.
        # This helps common libs such as JSON to work easily.
        #
        # @param type see #new
        # @param method see #infer_coercion_method
        # @return [#call] +method+ wrapped in an additional
        #   key-conversion step, or just returns +method+
        #   itself if no conversion is deemed to be
        #   necessary.
        def enforce_symbolized_keys(type, method)
          # Collections have all values processed individually
          if [Array, Set].include?(type)
            lambda do |val|
              method.call(val).tap do |new_val|
                new_val.map do |item|
                  item.is_a?(Hash) ? item.deep_symbolize_keys : item
                end
              end
            end

          # Hash objects are processed directly
          elsif type == Hash
            lambda do |val|
              method.call(val).deep_symbolize_keys
            end

          # Simple types are not processed.
          # This includes Array<primitive> types.
          else
            method
          end
        end
      end
    end
  end
end