dry-validation-rust 0.1.0.pre5
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +74 -0
- data/LICENSE +21 -0
- data/NOTICE.md +28 -0
- data/README.md +459 -0
- data/docs/ARCHITECTURE.md +256 -0
- data/docs/COMPATIBILITY.md +198 -0
- data/docs/FEASIBILITY.md +207 -0
- data/docs/SUPPORT_MATRIX.md +66 -0
- data/docs/VERIFICATION.md +128 -0
- data/dry-validation-rust.gemspec +58 -0
- data/ext/dry_validation_rust/Cargo.lock +809 -0
- data/ext/dry_validation_rust/Cargo.toml +44 -0
- data/ext/dry_validation_rust/benches/coercion.rs +77 -0
- data/ext/dry_validation_rust/benches/full_schema.rs +189 -0
- data/ext/dry_validation_rust/benches/plan_compile.rs +37 -0
- data/ext/dry_validation_rust/benches/predicates.rs +105 -0
- data/ext/dry_validation_rust/extconf.rb +29 -0
- data/ext/dry_validation_rust/src/coercion.rs +515 -0
- data/ext/dry_validation_rust/src/engine.rs +416 -0
- data/ext/dry_validation_rust/src/error.rs +82 -0
- data/ext/dry_validation_rust/src/extract_primitive.rs +23 -0
- data/ext/dry_validation_rust/src/generated_predicates.rs +33 -0
- data/ext/dry_validation_rust/src/lib.rs +228 -0
- data/ext/dry_validation_rust/src/plan.rs +611 -0
- data/ext/dry_validation_rust/src/predicates.rs +449 -0
- data/ext/dry_validation_rust/src/ruby_bridge.rs +78 -0
- data/lib/dry/schema.rb +6 -0
- data/lib/dry/validation/rust/block_keyword_parameters.rb +20 -0
- data/lib/dry/validation/rust/config.rb +74 -0
- data/lib/dry/validation/rust/contract/result.rb +180 -0
- data/lib/dry/validation/rust/contract/values.rb +73 -0
- data/lib/dry/validation/rust/contract.rb +400 -0
- data/lib/dry/validation/rust/errors.rb +14 -0
- data/lib/dry/validation/rust/evaluator.rb +295 -0
- data/lib/dry/validation/rust/failures.rb +57 -0
- data/lib/dry/validation/rust/generated_predicates.rb +14 -0
- data/lib/dry/validation/rust/macros.rb +45 -0
- data/lib/dry/validation/rust/message.rb +41 -0
- data/lib/dry/validation/rust/message_backend.rb +115 -0
- data/lib/dry/validation/rust/message_set.rb +159 -0
- data/lib/dry/validation/rust/native.rb +25 -0
- data/lib/dry/validation/rust/path.rb +65 -0
- data/lib/dry/validation/rust/path_trie.rb +57 -0
- data/lib/dry/validation/rust/result.rb +3 -0
- data/lib/dry/validation/rust/rule.rb +62 -0
- data/lib/dry/validation/rust/schema/dsl.rb +76 -0
- data/lib/dry/validation/rust/schema/field_builder.rb +156 -0
- data/lib/dry/validation/rust/schema/field_definition.rb +99 -0
- data/lib/dry/validation/rust/schema/predicate_block.rb +56 -0
- data/lib/dry/validation/rust/schema/processor_hooks.rb +46 -0
- data/lib/dry/validation/rust/schema/result.rb +67 -0
- data/lib/dry/validation/rust/schema/ruby_type_processor.rb +44 -0
- data/lib/dry/validation/rust/schema.rb +323 -0
- data/lib/dry/validation/rust/values.rb +3 -0
- data/lib/dry/validation/rust/version.rb +10 -0
- data/lib/dry/validation/rust.rb +55 -0
- data/lib/dry/validation.rb +66 -0
- data/lib/dry-schema.rb +3 -0
- data/lib/dry-validation.rb +3 -0
- data/lib/dry_validation_rust.rb +3 -0
- data/predicates.yml +67 -0
- data/rust-toolchain.toml +9 -0
- metadata +260 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
# The evaluation context for contract rules.
|
|
7
|
+
#
|
|
8
|
+
# Inside a {Contract.rule rule} block, `self` is an Evaluator instance.
|
|
9
|
+
# It exposes the validated values, the rule path, error collectors, and
|
|
10
|
+
# the mutable per-call context.
|
|
11
|
+
#
|
|
12
|
+
# @example Add a failure for the rule value
|
|
13
|
+
# rule(:age) { key.failure("must be at least 18") if value < 18 }
|
|
14
|
+
class Evaluator
|
|
15
|
+
# @api private
|
|
16
|
+
#
|
|
17
|
+
# Returns the contract executing the rule.
|
|
18
|
+
#
|
|
19
|
+
# @return [Contract]
|
|
20
|
+
# @example Read a contract option from a rule
|
|
21
|
+
# rule(:name) { key.failure("is reserved") if contract.reserved?(value) }
|
|
22
|
+
attr_reader :contract
|
|
23
|
+
# @api private
|
|
24
|
+
#
|
|
25
|
+
# Returns the result receiving rule failures.
|
|
26
|
+
#
|
|
27
|
+
# @return [Contract::Result]
|
|
28
|
+
# @example Inspect a schema error before adding a rule failure
|
|
29
|
+
# rule(:email) { key.failure("is unavailable") unless result.schema_error?(:email) }
|
|
30
|
+
attr_reader :result
|
|
31
|
+
# @api private
|
|
32
|
+
#
|
|
33
|
+
# Returns the paths declared for the rule.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<Array<Symbol, Integer>>]
|
|
36
|
+
# @example Use the current rule path
|
|
37
|
+
# rule(:email) { paths # => [[:email]] }
|
|
38
|
+
attr_reader :paths
|
|
39
|
+
# Returns all validated output available to the rule.
|
|
40
|
+
#
|
|
41
|
+
# @return [Contract::Values]
|
|
42
|
+
# @example Compare two validated values
|
|
43
|
+
# rule(:password_confirmation) { key.failure("does not match") if value != values[:password] }
|
|
44
|
+
attr_reader :values
|
|
45
|
+
# @api private
|
|
46
|
+
#
|
|
47
|
+
# Returns the mutable context for the current contract call.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash]
|
|
50
|
+
# @example Read context supplied to {Contract#call}
|
|
51
|
+
# rule(:role) { key.failure("is not allowed") unless context[:roles].include?(value) }
|
|
52
|
+
attr_reader :context
|
|
53
|
+
# Returns the collection index for a `rule.each` evaluation.
|
|
54
|
+
#
|
|
55
|
+
# @return [Integer, nil]
|
|
56
|
+
# @example Add an error for the second collection member
|
|
57
|
+
# rule(:tags).each { key.failure("is reserved") if index == 1 && value == "admin" }
|
|
58
|
+
attr_reader :index
|
|
59
|
+
# @api private
|
|
60
|
+
#
|
|
61
|
+
# Returns failures collected while executing the rule.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Message>]
|
|
64
|
+
# @example Read failures after contract execution
|
|
65
|
+
# evaluator.failures # => [#<Dry::Validation::Rust::Message ...>]
|
|
66
|
+
attr_reader :failures
|
|
67
|
+
|
|
68
|
+
# Creates a rule evaluation context. This is primarily used by Contract.
|
|
69
|
+
#
|
|
70
|
+
# @api private
|
|
71
|
+
#
|
|
72
|
+
# @param contract [Contract] contract executing the rule
|
|
73
|
+
# @param result [Contract::Result] result receiving rule failures
|
|
74
|
+
# @param paths [Array<Array<Symbol, Integer>>] paths declared for the rule
|
|
75
|
+
# @param context [Hash] mutable context for the current contract call
|
|
76
|
+
# @param default_path [Array<Symbol, Integer>] path used by {#key} when omitted
|
|
77
|
+
# @param index [Integer, nil] current `rule.each` collection index
|
|
78
|
+
# @return [Evaluator]
|
|
79
|
+
# @example Contract creates an evaluator while executing a rule
|
|
80
|
+
# contract.call(age: 17)
|
|
81
|
+
def initialize(contract:, result:, paths:, context:, default_path: paths.first || [], index: nil)
|
|
82
|
+
@contract = contract
|
|
83
|
+
@result = result
|
|
84
|
+
@paths = paths
|
|
85
|
+
@default_path = default_path
|
|
86
|
+
@values = result.values
|
|
87
|
+
@context = context
|
|
88
|
+
@index = index
|
|
89
|
+
@failures = []
|
|
90
|
+
@key_failures = {}
|
|
91
|
+
@base_failures = Failures.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Executes a rule block and macro calls, then collects their failures.
|
|
95
|
+
#
|
|
96
|
+
# @api private
|
|
97
|
+
#
|
|
98
|
+
# @param block [Proc, nil] rule block to execute
|
|
99
|
+
# @param macro_calls [Array<Array>] macro calls declared for the rule
|
|
100
|
+
# @param keyword_params [Array<Symbol>] evaluator values injected into the block
|
|
101
|
+
# @return [Evaluator] this evaluator
|
|
102
|
+
# @example Execute a rule with its declared macro calls
|
|
103
|
+
# evaluator.execute(rule.block, rule.macro_calls)
|
|
104
|
+
def execute(block, macro_calls, keyword_params: [])
|
|
105
|
+
execute_block(block, keyword_params) if block
|
|
106
|
+
macro_calls.each { |call| execute_macro(call) }
|
|
107
|
+
collect_failures
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns the failure collector for a path, defaulting to the rule path.
|
|
112
|
+
#
|
|
113
|
+
# @param path [Symbol, String, Array, Hash] key or supported path specification
|
|
114
|
+
# @return [Failures] collector that adds rule failures at the path
|
|
115
|
+
# @example Add an error at the rule's default path
|
|
116
|
+
# rule(:email) { key.failure("is already taken") }
|
|
117
|
+
# @example Add an error at a different path
|
|
118
|
+
# rule(:password) { key(:password_confirmation).failure("does not match") }
|
|
119
|
+
def key(path = default_path)
|
|
120
|
+
normalized = Path.parse(path)
|
|
121
|
+
@key_failures[normalized] ||= Failures.new(normalized)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns the failure collector for base-level messages.
|
|
125
|
+
#
|
|
126
|
+
# @return [Failures] collector that adds base-level rule failures
|
|
127
|
+
# @example Add a contract-wide error
|
|
128
|
+
# rule { base.failure("cannot be approved") }
|
|
129
|
+
def base
|
|
130
|
+
@base_failures
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @api private
|
|
134
|
+
#
|
|
135
|
+
# Returns the default rule path as a key or nested path.
|
|
136
|
+
#
|
|
137
|
+
# @return [Symbol, Array<Symbol, Integer>]
|
|
138
|
+
# @example Read a single-key rule name
|
|
139
|
+
# rule(:email) { key_name # => :email }
|
|
140
|
+
def key_name
|
|
141
|
+
default_path.length == 1 ? default_path.first : default_path
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns the value at the rule's primary path, or nil when absent.
|
|
145
|
+
#
|
|
146
|
+
# @return [Object, nil]
|
|
147
|
+
# @example Reject a value in a rule
|
|
148
|
+
# rule(:age) { key.failure("must be at least 18") if value < 18 }
|
|
149
|
+
def value
|
|
150
|
+
raw = Path.fetch(values.data, value_path)
|
|
151
|
+
raw.equal?(Path::Undefined) ? nil : raw
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns whether validated output contains a key or path.
|
|
155
|
+
#
|
|
156
|
+
# @param name [Symbol, String, Array, Hash] key or supported path specification
|
|
157
|
+
# @return [Boolean]
|
|
158
|
+
# @example Require a related value
|
|
159
|
+
# rule(:password) { key.failure("needs confirmation") unless key?(:password_confirmation) }
|
|
160
|
+
def key?(name = value_path)
|
|
161
|
+
Path.key?(values.data, name)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Returns whether the schema has an error at a path.
|
|
165
|
+
#
|
|
166
|
+
# @param path [Symbol, String, Array, Hash] key or supported path specification
|
|
167
|
+
# @return [Boolean]
|
|
168
|
+
# @example Skip a dependent check after a schema failure
|
|
169
|
+
# rule(:age) { key.failure("is too young") unless schema_error?(:age) || value >= 18 }
|
|
170
|
+
def schema_error?(path)
|
|
171
|
+
result.schema_error?(path)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @api private
|
|
175
|
+
#
|
|
176
|
+
# Alias for #schema_error?.
|
|
177
|
+
#
|
|
178
|
+
# @example Use the short schema-error predicate
|
|
179
|
+
# rule(:age) { key.failure("is too young") unless error?(:age) || value >= 18 }
|
|
180
|
+
alias error? schema_error?
|
|
181
|
+
|
|
182
|
+
# Returns whether a rule error exists at a path or the default path.
|
|
183
|
+
#
|
|
184
|
+
# @param path [Symbol, String, Array, Hash, nil] path to check, or the default rule path
|
|
185
|
+
# @return [Boolean]
|
|
186
|
+
# @example Avoid adding a duplicate rule error
|
|
187
|
+
# rule(:email) { key.failure("is blocked") unless rule_error? }
|
|
188
|
+
def rule_error?(path = nil)
|
|
189
|
+
if path
|
|
190
|
+
result.rule_error?(path)
|
|
191
|
+
else
|
|
192
|
+
!key(default_path).empty?
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Returns whether a base-level rule error exists.
|
|
197
|
+
#
|
|
198
|
+
# @return [Boolean]
|
|
199
|
+
# @example Check whether this rule added a base failure
|
|
200
|
+
# rule { base.failure("cannot continue") unless base_rule_error? }
|
|
201
|
+
def base_rule_error?
|
|
202
|
+
!base.empty? || result.base_rule_error?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @api private
|
|
206
|
+
#
|
|
207
|
+
# Returns the mutable context supplied to the contract call.
|
|
208
|
+
#
|
|
209
|
+
# @return [Hash]
|
|
210
|
+
# @example Access call context through the compatibility helper
|
|
211
|
+
# rule(:role) { _context[:actor] # => current actor }
|
|
212
|
+
def _context
|
|
213
|
+
context
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @api private
|
|
217
|
+
#
|
|
218
|
+
# Reports contract methods delegated through this evaluator.
|
|
219
|
+
#
|
|
220
|
+
# @param name [Symbol] method name to check
|
|
221
|
+
# @param include_private [Boolean] whether private methods are included
|
|
222
|
+
# @return [Boolean]
|
|
223
|
+
# @example Check a contract helper exposed in a rule
|
|
224
|
+
# evaluator.respond_to?(:reserved?) # => true
|
|
225
|
+
def respond_to_missing?(name, include_private = false)
|
|
226
|
+
contract.respond_to?(name, true) || super
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
private :initialize, :contract, :result, :paths, :context, :failures, :execute,
|
|
232
|
+
:key_name, :error?, :_context, :respond_to_missing?
|
|
233
|
+
|
|
234
|
+
# @api private
|
|
235
|
+
attr_reader :default_path
|
|
236
|
+
|
|
237
|
+
# @api private
|
|
238
|
+
def value_path
|
|
239
|
+
paths.first || []
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# @api private
|
|
243
|
+
def execute_block(block, keyword_params, macro: nil)
|
|
244
|
+
keyword_values = { context: context, index: index, macro: macro }
|
|
245
|
+
kwargs = keyword_values.slice(*keyword_params)
|
|
246
|
+
kwargs.empty? ? instance_exec(&block) : instance_exec(**kwargs, &block)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @api private
|
|
250
|
+
def execute_macro(call)
|
|
251
|
+
name, *args = call
|
|
252
|
+
if contract.macro_registered?(name)
|
|
253
|
+
macro = contract.resolve_macro(name).with(args)
|
|
254
|
+
execute_block(macro.block, macro.keyword_params, macro: macro)
|
|
255
|
+
else
|
|
256
|
+
execute_predicate_macro(name, args)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# @api private
|
|
261
|
+
def execute_predicate_macro(name, args)
|
|
262
|
+
normalized = name.to_s.delete_suffix('?').to_sym
|
|
263
|
+
expected = args.length == 1 ? args.first : args
|
|
264
|
+
valid = case normalized
|
|
265
|
+
when :gt then value > expected
|
|
266
|
+
when :gteq then value >= expected
|
|
267
|
+
when :lt then value < expected
|
|
268
|
+
when :lteq then value <= expected
|
|
269
|
+
when :min_size then value.size >= expected
|
|
270
|
+
when :max_size then value.size <= expected
|
|
271
|
+
when :size then value.size == expected
|
|
272
|
+
when :format then expected.match?(value)
|
|
273
|
+
when :included_in then expected.include?(value)
|
|
274
|
+
else
|
|
275
|
+
raise UnsupportedFeatureError, "unknown rule macro #{name.inspect}"
|
|
276
|
+
end
|
|
277
|
+
key.failure("must satisfy #{name}") unless valid
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @api private
|
|
281
|
+
def collect_failures
|
|
282
|
+
failures.concat(base.messages)
|
|
283
|
+
@key_failures.each_value { |set| failures.concat(set.messages) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# @api private
|
|
287
|
+
def method_missing(name, ...)
|
|
288
|
+
return contract.__send__(name, ...) if contract.respond_to?(name, true)
|
|
289
|
+
|
|
290
|
+
super
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Failures
|
|
7
|
+
IDENTIFIER_MESSAGES = {
|
|
8
|
+
acceptance: 'must be accepted',
|
|
9
|
+
invalid: 'is invalid',
|
|
10
|
+
taken: 'is already taken'
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :path, :messages
|
|
14
|
+
|
|
15
|
+
def initialize(path = [])
|
|
16
|
+
@path = Path.parse(path)
|
|
17
|
+
@messages = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def failure(message, tokens = {})
|
|
21
|
+
text, code, meta = normalize(message, tokens)
|
|
22
|
+
messages << Message.new(text: text, path: path, code: code, meta: meta, source: :rule)
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def empty?
|
|
27
|
+
messages.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize(message, tokens)
|
|
33
|
+
case message
|
|
34
|
+
when String
|
|
35
|
+
[interpolate(message, tokens), nil, {}]
|
|
36
|
+
when Symbol
|
|
37
|
+
[interpolate(IDENTIFIER_MESSAGES.fetch(message, message.to_s.tr('_', ' ')), tokens), message, {}]
|
|
38
|
+
when Hash
|
|
39
|
+
raw_text = message.fetch(:text)
|
|
40
|
+
text, code, = normalize(raw_text, tokens)
|
|
41
|
+
[text, code, message.except(:text)]
|
|
42
|
+
else
|
|
43
|
+
[message.to_s, nil, {}]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def interpolate(text, tokens)
|
|
48
|
+
return text if tokens.empty?
|
|
49
|
+
|
|
50
|
+
text % tokens
|
|
51
|
+
rescue KeyError
|
|
52
|
+
text
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file is generated by `bundle exec rake generate:predicates`.
|
|
4
|
+
# Do not edit it directly; update predicates.yml instead.
|
|
5
|
+
module Dry
|
|
6
|
+
module Validation
|
|
7
|
+
module Rust
|
|
8
|
+
class Schema
|
|
9
|
+
NATIVE_PREDICATES = %i[gt gteq lt lteq min_size max_size size odd even].freeze
|
|
10
|
+
RUBY_PREDICATES = %i[format included_in excluded_from eql not_eql].freeze
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'block_keyword_parameters'
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
module Validation
|
|
7
|
+
module Rust
|
|
8
|
+
# @api private
|
|
9
|
+
Macro = Struct.new(:name, :args, :block, :keyword_params, keyword_init: true) do
|
|
10
|
+
def with(call_args)
|
|
11
|
+
self.class.new(name: name, args: args + call_args, block: block, keyword_params: keyword_params)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @api private
|
|
16
|
+
class MacroRegistry
|
|
17
|
+
def initialize(parent = nil)
|
|
18
|
+
@parent = parent
|
|
19
|
+
@entries = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def register(name, *args, &block)
|
|
23
|
+
raise ArgumentError, 'a macro block is required' unless block
|
|
24
|
+
|
|
25
|
+
keyword_params = BlockKeywordParameters.extract(block)
|
|
26
|
+
@entries[name.to_sym] = Macro.new(
|
|
27
|
+
name: name.to_sym,
|
|
28
|
+
args: args,
|
|
29
|
+
block: block,
|
|
30
|
+
keyword_params: keyword_params
|
|
31
|
+
)
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fetch(name)
|
|
36
|
+
@entries.fetch(name.to_sym) { @parent&.fetch(name) || raise(KeyError, "unknown macro: #{name}") }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def key?(name)
|
|
40
|
+
@entries.key?(name.to_sym) || @parent&.key?(name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
Message = Data.define(:text, :path, :meta, :code, :source, :predicate, :args) do
|
|
7
|
+
def initialize(text:, path:, meta: {}, code: nil, source: :rule, predicate: nil, args: [])
|
|
8
|
+
super(
|
|
9
|
+
text: text.to_s.freeze,
|
|
10
|
+
path: Array(path).freeze,
|
|
11
|
+
meta: meta.freeze,
|
|
12
|
+
code: code&.to_sym,
|
|
13
|
+
source: source,
|
|
14
|
+
predicate: predicate&.to_sym,
|
|
15
|
+
args: args.freeze
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def base?
|
|
20
|
+
path.compact.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def schema?
|
|
24
|
+
source == :schema
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rule?
|
|
28
|
+
source == :rule
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def payload
|
|
32
|
+
meta.empty? ? text : { text: text, **meta }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
text
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
module Validation
|
|
7
|
+
module Rust
|
|
8
|
+
# Adapter interface for resolving localized schema error messages.
|
|
9
|
+
#
|
|
10
|
+
# Custom backends must inherit from this class and implement {#message}.
|
|
11
|
+
class MessageBackend
|
|
12
|
+
# Creates a backend using the schema message configuration.
|
|
13
|
+
#
|
|
14
|
+
# @param config [MessageConfig] the configured message settings.
|
|
15
|
+
def initialize(config); end
|
|
16
|
+
|
|
17
|
+
# Resolves text for a schema error.
|
|
18
|
+
#
|
|
19
|
+
# @param code [Symbol] the error code.
|
|
20
|
+
# @param predicate [Symbol, nil] the predicate name, without or with a trailing +?+.
|
|
21
|
+
# @param args [Array] predicate arguments.
|
|
22
|
+
# @param type [Symbol, nil] the normalized field type.
|
|
23
|
+
# @param fallback [String] message used when no translation is available.
|
|
24
|
+
# @return [String] the error message text.
|
|
25
|
+
def message(code:, predicate:, args:, type:, fallback:)
|
|
26
|
+
raise NotImplementedError, "#{self.class} must implement #message"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def tokens_for(args, type)
|
|
32
|
+
argument = args.first
|
|
33
|
+
return range_tokens(argument, type) if argument.is_a?(Range)
|
|
34
|
+
|
|
35
|
+
{ num: argument, size: argument, left: argument, list: Array(argument).join(', '), type: type }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def range_tokens(argument, type)
|
|
39
|
+
{
|
|
40
|
+
num: argument.begin, size: argument, left: argument.begin, right: argument.end,
|
|
41
|
+
list: "#{argument.begin} to #{argument.end}", type: type
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolves messages from YAML translation files listed in {MessageConfig#load_paths}.
|
|
47
|
+
class YamlBackend < MessageBackend
|
|
48
|
+
def initialize(config)
|
|
49
|
+
super
|
|
50
|
+
@default_locale = config.default_locale.to_sym
|
|
51
|
+
@top_namespace = config.top_namespace.to_s
|
|
52
|
+
@translations = load_yaml(config.load_paths)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def message(code:, predicate:, args:, type:, fallback:)
|
|
56
|
+
key = predicate ? "#{predicate.to_s.delete_suffix('?')}?" : code.to_s
|
|
57
|
+
template = @translations[[@default_locale, *@top_namespace.split('.'), 'errors', key].join('.')]
|
|
58
|
+
template ? interpolate(template, tokens_for(args, type)) : fallback
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def load_yaml(paths)
|
|
64
|
+
paths.each_with_object({}) do |path, translations|
|
|
65
|
+
flatten(YAML.safe_load_file(path, aliases: false), [], translations)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def flatten(value, path, translations)
|
|
70
|
+
if value.is_a?(Hash)
|
|
71
|
+
value.each { |key, child| flatten(child, [*path, key.to_s], translations) }
|
|
72
|
+
elsif value.is_a?(String)
|
|
73
|
+
translations[path.join('.')] = value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def interpolate(template, tokens)
|
|
78
|
+
template % tokens
|
|
79
|
+
rescue KeyError => e
|
|
80
|
+
raise ArgumentError, "message template is missing an interpolation token: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Resolves messages through the optional i18n gem.
|
|
85
|
+
class I18nBackend < MessageBackend
|
|
86
|
+
def initialize(config)
|
|
87
|
+
super
|
|
88
|
+
@default_locale = config.default_locale.to_sym
|
|
89
|
+
@top_namespace = config.top_namespace.to_s
|
|
90
|
+
load_i18n(config.load_paths)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def message(code:, predicate:, args:, type:, fallback:)
|
|
94
|
+
key = predicate ? "#{predicate.to_s.delete_suffix('?')}?" : code.to_s
|
|
95
|
+
translation_key = [*@top_namespace.split('.'), 'errors', key].join('.')
|
|
96
|
+
return fallback unless ::I18n.exists?(translation_key, @default_locale)
|
|
97
|
+
|
|
98
|
+
::I18n.t(translation_key, locale: @default_locale, **tokens_for(args, type))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def load_i18n(paths)
|
|
104
|
+
require 'i18n'
|
|
105
|
+
paths.each do |path|
|
|
106
|
+
data = YAML.safe_load_file(path, aliases: false)
|
|
107
|
+
data.each { |locale, translations| ::I18n.backend.store_translations(locale, translations) }
|
|
108
|
+
end
|
|
109
|
+
rescue LoadError
|
|
110
|
+
raise LoadError, 'the i18n gem is required for config.messages.backend = :i18n'
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|