dry-validation 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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