dry-validation/lib/dry/validation/contract.rb

161 lines
4.5 KiB
Ruby

# 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