dry-validation 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/initializer"
4
+ require "dry/validation/constants"
5
+
6
+ module Dry
7
+ module Validation
8
+ # Abstract class for handling rule blocks
9
+ #
10
+ # @see Rule
11
+ # @see Macro
12
+ #
13
+ # @api private
14
+ class Function
15
+ extend Dry::Initializer
16
+
17
+ # @!attribute [r] block
18
+ # @return [Proc]
19
+ # @api private
20
+ option :block
21
+
22
+ # @!attribute [r] block_options
23
+ # @return [Hash]
24
+ # @api private
25
+ option :block_options, default: -> { block ? map_keywords(block) : EMPTY_HASH }
26
+
27
+ private
28
+
29
+ # Extract options for the block kwargs
30
+ #
31
+ # @param [Proc] block Callable
32
+ # @return Hash
33
+ #
34
+ # @api private
35
+ def map_keywords(block)
36
+ block
37
+ .parameters
38
+ .select { |arg,| arg.equal?(:keyreq) }
39
+ .map { |_, name| [name, BLOCK_OPTIONS_MAPPINGS[name]] }
40
+ .to_h
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation/constants"
4
+ require "dry/validation/function"
5
+
6
+ module Dry
7
+ module Validation
8
+ # A wrapper for macro validation blocks
9
+ #
10
+ # @api public
11
+ class Macro < Function
12
+ # @!attribute [r] name
13
+ # @return [Symbol]
14
+ # @api public
15
+ param :name
16
+
17
+ # @!attribute [r] args
18
+ # @return [Array]
19
+ # @api public
20
+ option :args
21
+
22
+ # @!attribute [r] block
23
+ # @return [Proc]
24
+ # @api private
25
+ option :block
26
+
27
+ # @api private
28
+ def with(args)
29
+ self.class.new(name, args: args, block: block)
30
+ end
31
+
32
+ # @api private
33
+ def extract_block_options(options)
34
+ block_options.map { |key, value| [key, options[value]] }.to_h
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/container"
4
+ require "dry/validation/macro"
5
+
6
+ module Dry
7
+ module Validation
8
+ # API for registering and accessing Rule macros
9
+ #
10
+ # @api public
11
+ module Macros
12
+ module Registrar
13
+ # Register a macro
14
+ #
15
+ # @example register a global macro
16
+ # Dry::Validation.register_macro(:even_numbers) do
17
+ # key.failure('all numbers must be even') unless values[key_name].all?(&:even?)
18
+ # end
19
+ #
20
+ # @example register a contract macro
21
+ # class MyContract < Dry::Validation::Contract
22
+ # register_macro(:even_numbers) do
23
+ # key.failure('all numbers must be even') unless values[key_name].all?(&:even?)
24
+ # end
25
+ # end
26
+ #
27
+ # @param [Symbol] name The name of the macro
28
+ # @param [Array] args Optional default positional arguments for the macro
29
+ #
30
+ # @return [self]
31
+ #
32
+ # @see Macro
33
+ #
34
+ # @api public
35
+ def register_macro(name, *args, &block)
36
+ macros.register(name, *args, &block)
37
+ self
38
+ end
39
+ end
40
+
41
+ # Registry for macros
42
+ #
43
+ # @api public
44
+ class Container
45
+ include Dry::Container::Mixin
46
+
47
+ # Register a new macro
48
+ #
49
+ # @example in a contract class
50
+ # class MyContract < Dry::Validation::Contract
51
+ # register_macro(:even_numbers) do
52
+ # key.failure('all numbers must be even') unless values[key_name].all?(&:even?)
53
+ # end
54
+ # end
55
+ #
56
+ # @param [Symbol] name The name of the macro
57
+ #
58
+ # @return [self]
59
+ #
60
+ # @api public
61
+ def register(name, *args, &block)
62
+ macro = Macro.new(name, args: args, block: block)
63
+ super(name, macro, call: false, &nil)
64
+ self
65
+ end
66
+ end
67
+
68
+ # Return a registered macro
69
+ #
70
+ # @param [Symbol] name The name of the macro
71
+ #
72
+ # @return [Proc]
73
+ #
74
+ # @api public
75
+ def self.[](name)
76
+ container[name]
77
+ end
78
+
79
+ # Register a global macro
80
+ #
81
+ # @see Container#register
82
+ #
83
+ # @return [Macros]
84
+ #
85
+ # @api public
86
+ def self.register(name, *args, &block)
87
+ container.register(name, *args, &block)
88
+ self
89
+ end
90
+
91
+ # @api private
92
+ def self.container
93
+ @container ||= Container.new
94
+ end
95
+ end
96
+
97
+ # Acceptance macro
98
+ #
99
+ # @api public
100
+ Macros.register(:acceptance) do
101
+ key.failure(:acceptance, key: key_name) unless values[key_name].equal?(true)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/equalizer"
4
+
5
+ require "dry/schema/constants"
6
+ require "dry/schema/message"
7
+
8
+ module Dry
9
+ module Validation
10
+ # Message message
11
+ #
12
+ # @api public
13
+ class Message < Schema::Message
14
+ include Dry::Equalizer(:text, :path, :meta)
15
+
16
+ # The error message text
17
+ #
18
+ # @return [String] text
19
+ #
20
+ # @api public
21
+ attr_reader :text
22
+
23
+ # The path to the value with the error
24
+ #
25
+ # @return [Array<Symbol, Integer>]
26
+ #
27
+ # @api public
28
+ attr_reader :path
29
+
30
+ # Optional hash with meta-data
31
+ #
32
+ # @return [Hash]
33
+ #
34
+ # @api public
35
+ attr_reader :meta
36
+
37
+ # A localized message type
38
+ #
39
+ # Localized messsages can be translated to other languages at run-time
40
+ #
41
+ # @api public
42
+ class Localized < Message
43
+ # Evaluate message text using provided locale
44
+ #
45
+ # @example
46
+ # result.errors[:email].evaluate(locale: :en, full: true)
47
+ # # "email is invalid"
48
+ #
49
+ # @param [Hash] opts
50
+ # @option opts [Symbol] :locale Which locale to use
51
+ # @option opts [Boolean] :full Whether message text should include the key name
52
+ #
53
+ # @api public
54
+ def evaluate(**opts)
55
+ evaluated_text, rest = text.(**opts)
56
+ Message.new(evaluated_text, path: path, meta: rest.merge(meta))
57
+ end
58
+ end
59
+
60
+ # Build an error
61
+ #
62
+ # @return [Message, Message::Localized]
63
+ #
64
+ # @api private
65
+ def self.[](text, path, meta)
66
+ klass = text.respond_to?(:call) ? Localized : Message
67
+ klass.new(text, path: path, meta: meta)
68
+ end
69
+
70
+ # Initialize a new error object
71
+ #
72
+ # @api private
73
+ def initialize(text, path:, meta: EMPTY_HASH)
74
+ @text = text
75
+ @path = Array(path)
76
+ @meta = meta
77
+ end
78
+
79
+ # Check if this is a base error not associated with any key
80
+ #
81
+ # @return [Boolean]
82
+ #
83
+ # @api public
84
+ def base?
85
+ @base ||= path.compact.empty?
86
+ end
87
+
88
+ # Dump error to a string
89
+ #
90
+ # @return [String]
91
+ #
92
+ # @api public
93
+ def to_s
94
+ text
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/schema/message_set"
4
+
5
+ require "dry/validation/constants"
6
+ require "dry/validation/message"
7
+
8
+ module Dry
9
+ module Validation
10
+ # MessageSet is a specialized message set for handling validation messages
11
+ #
12
+ # @api public
13
+ class MessageSet < Schema::MessageSet
14
+ # Return the source set of messages used to produce final evaluated messages
15
+ #
16
+ # @return [Array<Message, Message::Localized, Schema::Message>]
17
+ #
18
+ # @api private
19
+ attr_reader :source_messages
20
+
21
+ # Configured locale
22
+ #
23
+ # @return [Symbol]
24
+ #
25
+ # @api public
26
+ attr_reader :locale
27
+
28
+ # @api private
29
+ def initialize(messages, options = EMPTY_HASH)
30
+ @locale = options[:locale]
31
+ @source_messages = options.fetch(:source) { messages.dup }
32
+ super
33
+ end
34
+
35
+ # Return a new message set using updated options
36
+ #
37
+ # @return [MessageSet]
38
+ #
39
+ # @api private
40
+ def with(other, new_options = EMPTY_HASH)
41
+ return self if new_options.empty? && other.eql?(messages)
42
+
43
+ self.class.new(
44
+ other | select { |err| err.is_a?(Message) },
45
+ options.merge(source: source_messages, **new_options)
46
+ ).freeze
47
+ end
48
+
49
+ # Add a new message
50
+ #
51
+ # This is used when result is being prepared
52
+ #
53
+ # @return [MessageSet]
54
+ #
55
+ # @api private
56
+ def add(message)
57
+ @empty = nil
58
+ source_messages << message
59
+ messages << message
60
+ self
61
+ end
62
+
63
+ # Filter message set using provided predicates
64
+ #
65
+ # This method is open to any predicate because messages can be anything that
66
+ # implements Message API, thus they can implement whatever predicates you
67
+ # may need.
68
+ #
69
+ # @example get a list of base messages
70
+ # message_set = contract.(input).errors
71
+ # message_set.filter(:base?)
72
+ #
73
+ # @param [Array<Symbol>] predicates
74
+ #
75
+ # @return [MessageSet]
76
+ #
77
+ # @api public
78
+ def filter(*predicates)
79
+ messages = select { |msg|
80
+ predicates.all? { |predicate| msg.respond_to?(predicate) && msg.public_send(predicate) }
81
+ }
82
+ self.class.new(messages)
83
+ end
84
+
85
+ # @api private
86
+ def freeze
87
+ source_messages.select { |err| err.respond_to?(:evaluate) }.each do |err|
88
+ idx = messages.index(err) || source_messages.index(err)
89
+ msg = err.evaluate(locale: locale, full: options[:full])
90
+ messages[idx] = msg
91
+ end
92
+ to_h
93
+ self
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation/message"
4
+
5
+ module Dry
6
+ module Validation
7
+ module Messages
8
+ # Resolve translated messages from failure arguments
9
+ #
10
+ # @api public
11
+ class Resolver
12
+ # @!attribute [r] messages
13
+ # @return [Messages::I18n, Messages::YAML] messages backend
14
+ # @api private
15
+ attr_reader :messages
16
+
17
+ # @api private
18
+ def initialize(messages)
19
+ @messages = messages
20
+ end
21
+
22
+ # Resolve Message object from provided args and path
23
+ #
24
+ # This is used internally by contracts when rules are applied
25
+ # If message argument is a Hash, then it MUST have a :text key,
26
+ # which value will be used as the message value
27
+ #
28
+ # @return [Message, Message::Localized]
29
+ #
30
+ # @api public
31
+ def call(message:, tokens:, path:, meta: EMPTY_HASH)
32
+ case message
33
+ when Symbol
34
+ Message[->(**opts) { message(message, path: path, tokens: tokens, **opts) }, path, meta]
35
+ when String
36
+ Message[->(**opts) { [message_text(message, path, **opts), meta] }, path, meta]
37
+ when Hash
38
+ meta = message.dup
39
+ text = meta.delete(:text) { |key|
40
+ raise ArgumentError, <<~STR
41
+ +message+ Hash must contain :#{key} key (#{message.inspect} given)
42
+ STR
43
+ }
44
+
45
+ call(message: text, tokens: tokens, path: path, meta: meta)
46
+ else
47
+ raise ArgumentError, <<~STR
48
+ +message+ must be either a Symbol, String or Hash (#{message.inspect} given)
49
+ STR
50
+ end
51
+ end
52
+ alias_method :[], :call
53
+
54
+ # Resolve a message
55
+ #
56
+ # @return String
57
+ #
58
+ # @api public
59
+ def message_text(message, path, locale: nil, full: false, **opts)
60
+ keys = path.to_a.compact
61
+ msg_opts = EMPTY_HASH.merge(path: keys, locale: locale || messages.default_locale)
62
+
63
+ full ? "#{messages.rule(keys.last, msg_opts) || keys.last} #{message}" : message
64
+ end
65
+
66
+ # Resolve a message
67
+ #
68
+ # @return [String]
69
+ #
70
+ # @api public
71
+ #
72
+ # rubocop:disable Metrics/AbcSize
73
+ def message(rule, tokens: EMPTY_HASH, locale: nil, full: false, path:)
74
+ keys = path.to_a.compact
75
+ msg_opts = tokens.merge(path: keys, locale: locale || messages.default_locale)
76
+
77
+ if keys.empty?
78
+ template, meta = messages["rules.#{rule}", msg_opts]
79
+ else
80
+ template, meta = messages[rule, msg_opts.merge(path: keys.join(DOT))]
81
+ template, meta = messages[rule, msg_opts.merge(path: keys.last)] unless template
82
+ end
83
+
84
+ unless template
85
+ raise MissingMessageError, <<~STR
86
+ Message template for #{rule.inspect} under #{keys.join(DOT).inspect} was not found
87
+ STR
88
+ end
89
+
90
+ parsed_tokens = parse_tokens(tokens)
91
+ text = template.(template.data(parsed_tokens))
92
+
93
+ [full ? "#{messages.rule(keys.last, msg_opts)} #{text}" : text, meta]
94
+ end
95
+ # rubocop:enable Metrics/AbcSize
96
+
97
+ private
98
+
99
+ def parse_tokens(tokens)
100
+ Hash[
101
+ tokens.map do |key, token|
102
+ [key, parse_token(token)]
103
+ end
104
+ ]
105
+ end
106
+
107
+ def parse_token(token)
108
+ case token
109
+ when Array
110
+ token.join(", ")
111
+ else
112
+ token
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end