dry-validation 1.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 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,142 @@
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) }).uniq,
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
+ source_messages << message
58
+ messages << message
59
+ initialize_placeholders!
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 = 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
+
96
+ private
97
+
98
+ # @api private
99
+ def unique_paths
100
+ source_messages.uniq(&:path).map(&:path)
101
+ end
102
+
103
+ # @api private
104
+ def messages_map
105
+ @messages_map ||= reduce(placeholders) { |hash, msg|
106
+ node = msg.path.reduce(hash) { |a, e| a.is_a?(Hash) ? a[e] : a.last[e] }
107
+ (node[0].is_a?(::Array) ? node[0] : node) << msg.dump
108
+ hash
109
+ }
110
+ end
111
+
112
+ # @api private
113
+ #
114
+ # rubocop:disable Metrics/AbcSize
115
+ # rubocop:disable Metrics/PerceivedComplexity
116
+ def initialize_placeholders!
117
+ @placeholders = unique_paths.each_with_object(EMPTY_HASH.dup) { |path, hash|
118
+ curr_idx = 0
119
+ last_idx = path.size - 1
120
+ node = hash
121
+
122
+ while curr_idx <= last_idx
123
+ key = path[curr_idx]
124
+
125
+ next_node =
126
+ if node.is_a?(Array) && key.is_a?(Symbol)
127
+ node_hash = (node << [] << {}).last
128
+ node_hash[key] || (node_hash[key] = curr_idx < last_idx ? {} : [])
129
+ else
130
+ node[key] || (node[key] = curr_idx < last_idx ? {} : [])
131
+ end
132
+
133
+ node = next_node
134
+ curr_idx += 1
135
+ end
136
+ }
137
+ end
138
+ # rubocop:enable Metrics/AbcSize
139
+ # rubocop:enable Metrics/PerceivedComplexity
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,79 @@
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
+ #
26
+ # @return [Message, Message::Localized]
27
+ #
28
+ # @api public
29
+ def call(message:, tokens:, path:, meta: EMPTY_HASH)
30
+ case message
31
+ when Symbol
32
+ Message[->(**opts) { message(message, path: path, tokens: tokens, **opts) }, path, meta]
33
+ when String
34
+ Message[message, path, meta]
35
+ when Hash
36
+ meta = message.dup
37
+ text = meta.delete(:text)
38
+ call(message: text, tokens: tokens, path: path, meta: meta)
39
+ else
40
+ raise ArgumentError, <<~STR
41
+ +message+ must be either a Symbol, String or Hash (#{message.inspect} given)
42
+ STR
43
+ end
44
+ end
45
+ alias_method :[], :call
46
+
47
+ # Resolve a message
48
+ #
49
+ # @return [String]
50
+ #
51
+ # @api public
52
+ #
53
+ # rubocop:disable Metrics/AbcSize
54
+ def message(rule, tokens: EMPTY_HASH, locale: nil, full: false, path:)
55
+ keys = path.to_a.compact
56
+ msg_opts = tokens.merge(path: keys, locale: locale || messages.default_locale)
57
+
58
+ if keys.empty?
59
+ template, meta = messages["rules.#{rule}", msg_opts]
60
+ else
61
+ template, meta = messages[rule, msg_opts.merge(path: keys.join(DOT))]
62
+ template, meta = messages[rule, msg_opts.merge(path: keys.last)] unless template
63
+ end
64
+
65
+ unless template
66
+ raise MissingMessageError, <<~STR
67
+ Message template for #{rule.inspect} under #{keys.join(DOT).inspect} was not found
68
+ STR
69
+ end
70
+
71
+ text = template.(template.data(tokens))
72
+
73
+ [full ? "#{messages.rule(keys.last, msg_opts)} #{text}" : text, meta]
74
+ end
75
+ # rubocop:enable Metrics/AbcSize
76
+ end
77
+ end
78
+ end
79
+ end