dry-rb/dry-validation

View on GitHub
lib/dry/validation/contract.rb

Summary

Maintainability
A
35 mins
Test Coverage
A
100%
# frozen_string_literal: true

require 'concurrent/map'

require 'dry/equalizer'
require 'dry/initializer'
require 'dry/schema/path'

require 'dry/validation/config'
require 'dry/validation/constants'
require 'dry/validation/rule'
require 'dry/validation/evaluator'
require 'dry/validation/messages/resolver'
require 'dry/validation/result'
require 'dry/validation/contract/class_interface'

module Dry
  module Validation
    # Contract objects apply rules to input
    #
    # A contract consists of a schema and rules. The schema is applied to the
    # input before rules are applied, this way you can be sure that your rules
    # won't be applied to values that didn't pass schema checks.
    #
    # It's up to you how exactly you're going to separate schema checks from
    # your rules.
    #
    # @example
    #   class NewUserContract < Dry::Validation::Contract
    #     params do
    #       required(:email).filled(:string)
    #       required(:age).filled(:integer)
    #       optional(:login).maybe(:string, :filled?)
    #       optional(:password).maybe(:string, min_size?: 10)
    #       optional(:password_confirmation).maybe(:string)
    #     end
    #
    #     rule(:password) do
    #       key.failure('is required') if values[:login] && !values[:password]
    #     end
    #
    #     rule(:age) do
    #       key.failure('must be greater or equal 18') if values[:age] < 18
    #     end
    #   end
    #
    #   new_user_contract = NewUserContract.new
    #   new_user_contract.call(email: 'jane@doe.org', age: 21)
    #
    # @api public
    class Contract
      include Dry::Equalizer(:schema, :rules, :messages, inspect: false)

      extend Dry::Initializer
      extend ClassInterface

      config.messages.top_namespace = DEFAULT_ERRORS_NAMESPACE
      config.messages.load_paths << DEFAULT_ERRORS_PATH

      # @!attribute [r] config
      #   @return [Config] Contract's configuration object
      #   @api public
      option :config, default: -> { self.class.config }

      # @!attribute [r] macros
      #   @return [Macros::Container] Configured macros
      #   @see Macros::Container#register
      #   @api public
      option :macros, default: -> { config.macros }

      # @!attribute [r] schema
      #   @return [Dry::Schema::Params, Dry::Schema::JSON, Dry::Schema::Processor]
      #   @api private
      option :schema, default: -> { self.class.__schema__ || raise(SchemaMissingError, self.class) }

      # @!attribute [r] rules
      #   @return [Hash]
      #   @api private
      option :rules, default: -> { self.class.rules }

      # @!attribute [r] message_resolver
      #   @return [Messages::Resolver]
      #   @api private
      option :message_resolver, default: -> { Messages::Resolver.new(messages) }

      # Apply the contract to an input
      #
      # @param [Hash] input The input to validate
      #
      # @return [Result]
      #
      # @api public
      def call(input)
        Result.new(schema.(input), Concurrent::Map.new) do |result|
          rules.each do |rule|
            next if rule.keys.any? { |key| error?(result, key) }

            rule_result = rule.(self, result)

            rule_result.failures.each do |failure|
              result.add_error(message_resolver.(**failure))
            end
          end
        end
      end

      # Return a nice string representation
      #
      # @return [String]
      #
      # @api public
      def inspect
        %(#<#{self.class} schema=#{schema.inspect} rules=#{rules.inspect}>)
      end

      private

      # @api private
      def error?(result, spec)
        path = Schema::Path[spec]

        if path.multi_value?
          return path.expand.any? { |nested_path| error?(result, nested_path) }
        end

        return true if result.error?(path)

        path
          .to_a[0..-2]
          .any? { |key|
            curr_path = Schema::Path[path.keys[0..path.keys.index(key)]]

            return false unless result.error?(curr_path)

            result.errors.any? { |err|
              (other = Schema::Path[err.path]).same_root?(curr_path) && other == curr_path
            }
          }
      end

      # Get a registered macro
      #
      # @return [Proc,#to_proc]
      #
      # @api private
      def macro(name, *args)
        (macros.key?(name) ? macros[name] : Macros[name]).with(args)
      end

      # Return configured messages backend
      #
      # @return [Dry::Schema::Messages::YAML, Dry::Schema::Messages::I18n]
      #
      # @api private
      def messages
        self.class.messages
      end
    end
  end
end