interactor-validation 0.3.9 → 0.4.1
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 +4 -4
- data/README.md +280 -1417
- data/benchmark/validation_benchmark.rb +0 -3
- data/lib/interactor/validation/configuration.rb +3 -27
- data/lib/interactor/validation/core_ext.rb +120 -0
- data/lib/interactor/validation/errors.rb +49 -0
- data/lib/interactor/validation/params.rb +6 -16
- data/lib/interactor/validation/validates.rb +157 -821
- data/lib/interactor/validation/validators/array.rb +20 -0
- data/lib/interactor/validation/validators/boolean.rb +15 -0
- data/lib/interactor/validation/validators/format.rb +17 -0
- data/lib/interactor/validation/validators/hash.rb +43 -0
- data/lib/interactor/validation/validators/inclusion.rb +17 -0
- data/lib/interactor/validation/validators/length.rb +46 -0
- data/lib/interactor/validation/validators/numeric.rb +46 -0
- data/lib/interactor/validation/validators/presence.rb +16 -0
- data/lib/interactor/validation/version.rb +1 -1
- data/lib/interactor/validation.rb +13 -44
- data/smoke_test.rb +252 -0
- metadata +28 -44
- data/lib/interactor/validation/error_codes.rb +0 -51
|
@@ -1,898 +1,234 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "validators/presence"
|
|
4
|
+
require_relative "validators/numeric"
|
|
5
|
+
require_relative "validators/boolean"
|
|
6
|
+
require_relative "validators/format"
|
|
7
|
+
require_relative "validators/length"
|
|
8
|
+
require_relative "validators/inclusion"
|
|
9
|
+
require_relative "validators/hash"
|
|
10
|
+
require_relative "validators/array"
|
|
11
|
+
|
|
3
12
|
module Interactor
|
|
4
13
|
module Validation
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
extend ActiveSupport::Concern
|
|
8
|
-
|
|
9
|
-
# Exception raised when validation should halt immediately
|
|
10
|
-
class HaltValidation < StandardError; end
|
|
11
|
-
|
|
12
|
-
included do
|
|
13
|
-
class_attribute :_param_validations, instance_writer: false, default: {}
|
|
14
|
-
# Regex pattern cache for performance
|
|
15
|
-
class_attribute :_regex_cache, instance_writer: false, default: {}
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Class-level mutex for thread-safe validation updates
|
|
19
|
-
@validations_mutex = Mutex.new
|
|
20
|
-
|
|
21
|
-
def self.validations_mutex
|
|
22
|
-
@validations_mutex ||= Mutex.new
|
|
23
|
-
end
|
|
14
|
+
# Exception raised when validation should halt on first error
|
|
15
|
+
class HaltValidation < StandardError; end
|
|
24
16
|
|
|
17
|
+
module Validates
|
|
25
18
|
def self.included(base)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
base.
|
|
29
|
-
base.
|
|
30
|
-
|
|
31
|
-
base.prepend(
|
|
19
|
+
base.extend(ClassMethods)
|
|
20
|
+
base.class_attribute :_validations
|
|
21
|
+
base._validations = {}
|
|
22
|
+
base.class_attribute :_validation_config
|
|
23
|
+
base._validation_config = {}
|
|
24
|
+
base.prepend(InstanceMethods)
|
|
32
25
|
end
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# @param param_name [Symbol] the parameter name to validate
|
|
38
|
-
# @param rules [Hash] validation rules (presence, format, length, etc.)
|
|
39
|
-
# @yield [NestedValidationBuilder] optional block for nested validation DSL
|
|
40
|
-
# @return [void]
|
|
41
|
-
def validates(param_name, **rules, &)
|
|
42
|
-
# Thread-safe validation rule updates
|
|
43
|
-
Validates.validations_mutex.synchronize do
|
|
44
|
-
# If block is provided, this is nested validation
|
|
45
|
-
if block_given?
|
|
46
|
-
nested_rules = build_nested_rules(&)
|
|
47
|
-
current_validations = _param_validations.dup
|
|
48
|
-
existing_rules = current_validations[param_name] || {}
|
|
49
|
-
# Merge both the validation rules (like presence: true) AND the nested rules
|
|
50
|
-
self._param_validations = current_validations.merge(
|
|
51
|
-
param_name => existing_rules.merge(rules).merge(_nested: nested_rules)
|
|
52
|
-
)
|
|
53
|
-
return
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# If no keyword arguments and no block, mark as skip validation
|
|
57
|
-
if rules.empty?
|
|
58
|
-
current_validations = _param_validations.dup
|
|
59
|
-
existing_rules = current_validations[param_name] || {}
|
|
60
|
-
self._param_validations = current_validations.merge(
|
|
61
|
-
param_name => existing_rules.merge(_skip: true)
|
|
62
|
-
)
|
|
63
|
-
return
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Merge validation rules for the same param, ensuring we don't modify parent's hash
|
|
67
|
-
current_validations = _param_validations.dup
|
|
68
|
-
existing_rules = current_validations[param_name] || {}
|
|
69
|
-
self._param_validations = current_validations.merge(param_name => existing_rules.merge(rules))
|
|
70
|
-
end
|
|
27
|
+
class ConfigurationProxy
|
|
28
|
+
def initialize(config_hash)
|
|
29
|
+
@config = config_hash
|
|
71
30
|
end
|
|
72
31
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# Build nested validation rules from a block
|
|
76
|
-
def build_nested_rules(&)
|
|
77
|
-
builder = NestedValidationBuilder.new
|
|
78
|
-
builder.instance_eval(&)
|
|
79
|
-
builder.rules
|
|
32
|
+
def mode=(value)
|
|
33
|
+
@config[:mode] = value
|
|
80
34
|
end
|
|
81
|
-
end
|
|
82
35
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
attr_reader :rules
|
|
86
|
-
|
|
87
|
-
def initialize
|
|
88
|
-
@rules = {}
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Define validation for a nested attribute
|
|
92
|
-
def attribute(attr_name, **validations)
|
|
93
|
-
@rules[attr_name] = validations
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Wrapper for ActiveModel::Errors that intercepts halt: option
|
|
98
|
-
class ErrorsWrapper < SimpleDelegator
|
|
99
|
-
def initialize(errors, interactor)
|
|
100
|
-
super(errors)
|
|
101
|
-
@interactor = interactor
|
|
36
|
+
def halt=(value)
|
|
37
|
+
@config[:halt] = value
|
|
102
38
|
end
|
|
103
39
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# Extract halt option before passing to ActiveModel
|
|
107
|
-
halt = options.delete(:halt)
|
|
108
|
-
|
|
109
|
-
# Call the original add method
|
|
110
|
-
__getobj__.add(attribute, message, **options)
|
|
111
|
-
|
|
112
|
-
# Set halt flag and raise exception if requested
|
|
113
|
-
return unless halt
|
|
114
|
-
|
|
115
|
-
@interactor.instance_variable_set(:@halt_validation, true)
|
|
116
|
-
raise HaltValidation
|
|
40
|
+
def skip_validate=(value)
|
|
41
|
+
@config[:skip_validate] = value
|
|
117
42
|
end
|
|
118
43
|
end
|
|
119
44
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
45
|
+
module ClassMethods
|
|
46
|
+
def inherited(subclass)
|
|
47
|
+
super
|
|
48
|
+
# Ensure child class gets its own copy of config, merging with parent's config
|
|
49
|
+
subclass._validation_config = _validation_config.dup
|
|
50
|
+
# Ensure child class gets its own copy of validations
|
|
51
|
+
subclass._validations = _validations.dup
|
|
125
52
|
end
|
|
126
53
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
# super # Optional: call parent implementation if needed
|
|
134
|
-
# errors.add(:base, "Custom error") if some_condition?
|
|
135
|
-
# end
|
|
136
|
-
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
137
|
-
def validate!
|
|
138
|
-
# Preserve errors from validate_params! before calling super
|
|
139
|
-
# because super might trigger ActiveModel's valid? which clears errors
|
|
140
|
-
existing_error_details = errors.map do |error|
|
|
141
|
-
{ attribute: error.attribute, type: error.type, options: error.options }
|
|
142
|
-
rescue ArgumentError => e
|
|
143
|
-
# For anonymous classes, accessing error properties may fail
|
|
144
|
-
raise unless e.message.include?("Class name cannot be blank")
|
|
145
|
-
|
|
146
|
-
{ attribute: error.attribute, type: :invalid, options: {} }
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# Skip custom validations if configured to skip when param errors exist
|
|
150
|
-
skip_custom_validation = current_config.skip_validate && existing_error_details.any?
|
|
151
|
-
|
|
152
|
-
# Skip custom validations if halt was requested or if configured to skip on param errors
|
|
153
|
-
unless @halt_validation || skip_custom_validation
|
|
154
|
-
# Call super to allow class's validate! to run and add custom errors
|
|
155
|
-
# Rescue exceptions that might be raised
|
|
156
|
-
begin
|
|
157
|
-
super
|
|
158
|
-
rescue NoMethodError
|
|
159
|
-
# No parent validate! method, which is fine
|
|
160
|
-
rescue ArgumentError => e
|
|
161
|
-
# For anonymous classes, super may fail when generating messages
|
|
162
|
-
raise unless e.message.include?("Class name cannot be blank")
|
|
163
|
-
rescue ActiveModel::ValidationError
|
|
164
|
-
# ActiveModel's validate! raises this when there are errors
|
|
165
|
-
# We handle errors differently, so just ignore this exception
|
|
166
|
-
rescue HaltValidation
|
|
167
|
-
# Validation halted - error already added, continue to fail context
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Restore errors from validate_params! and add any new ones from custom validate!
|
|
172
|
-
existing_error_details.each do |error_detail|
|
|
173
|
-
# Only add if not already present (avoid duplicates)
|
|
174
|
-
next if errors.where(error_detail[:attribute], error_detail[:type]).any?
|
|
175
|
-
|
|
176
|
-
begin
|
|
177
|
-
errors.add(error_detail[:attribute], error_detail[:type], **error_detail[:options])
|
|
178
|
-
rescue ArgumentError => e
|
|
179
|
-
# For anonymous classes, fall back to adding with message directly
|
|
180
|
-
raise unless e.message.include?("Class name cannot be blank")
|
|
181
|
-
|
|
182
|
-
message = error_detail[:options][:message] || error_detail[:type].to_s
|
|
183
|
-
errors.add(error_detail[:attribute], message)
|
|
184
|
-
end
|
|
54
|
+
def validates(param_name, **rules, &)
|
|
55
|
+
# Ensure we have our own copy of validations when first modifying
|
|
56
|
+
begin
|
|
57
|
+
self._validations = _validations.dup if _validations.equal?(superclass._validations)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
false
|
|
185
60
|
end
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
# Check all accumulated errors from validate_params! and this hook
|
|
191
|
-
# Fail the context if any errors exist
|
|
192
|
-
return if errors.empty?
|
|
193
|
-
|
|
194
|
-
# Use the existing formatted_errors method which handles all the edge cases
|
|
195
|
-
context.fail!(errors: formatted_errors)
|
|
61
|
+
_validations[param_name] ||= {}
|
|
62
|
+
_validations[param_name].merge!(rules)
|
|
63
|
+
_validations[param_name][:_nested] = build_nested_rules(&) if block_given?
|
|
196
64
|
end
|
|
197
|
-
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
198
|
-
end
|
|
199
65
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
# Validates all declared parameters before execution
|
|
203
|
-
# Accumulates errors but does not fail the context
|
|
204
|
-
# The validate! hook will fail the context if there are any errors
|
|
205
|
-
# @return [void]
|
|
206
|
-
# rubocop:disable Metrics/PerceivedComplexity
|
|
207
|
-
def validate_params!
|
|
208
|
-
# Memoize config for performance
|
|
209
|
-
@current_config = current_config
|
|
210
|
-
# Initialize halt flag (but don't reset if already set)
|
|
211
|
-
@halt_validation ||= false
|
|
212
|
-
|
|
213
|
-
# Instrument validation if enabled
|
|
214
|
-
instrument("validate_params.interactor_validation") do
|
|
215
|
-
# Trigger ActiveModel validations first (validate callbacks)
|
|
216
|
-
# This runs any custom validations defined with validate :method_name
|
|
66
|
+
def configure
|
|
67
|
+
# Ensure we have our own copy of config before modifying
|
|
217
68
|
begin
|
|
218
|
-
|
|
219
|
-
rescue
|
|
220
|
-
|
|
221
|
-
raise unless e.message.include?("Class name cannot be blank")
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Check if halt was requested during ActiveModel validations
|
|
225
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
226
|
-
|
|
227
|
-
# Run our custom param validations after ActiveModel validations
|
|
228
|
-
self.class._param_validations.each do |param_name, rules|
|
|
229
|
-
# Safe param access - returns nil if not present in context
|
|
230
|
-
value = context.respond_to?(param_name) ? context.public_send(param_name) : nil
|
|
231
|
-
validate_param(param_name, value, rules)
|
|
232
|
-
|
|
233
|
-
# Halt on first error if configured or if halt was explicitly requested
|
|
234
|
-
break if @halt_validation || (@current_config.halt && errors.any?)
|
|
69
|
+
self._validation_config = _validation_config.dup if _validation_config.equal?(superclass._validation_config)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
false
|
|
235
72
|
end
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
# This allows validate! to run and add additional custom errors
|
|
239
|
-
end
|
|
240
|
-
rescue HaltValidation
|
|
241
|
-
# Validation halted - error already added, stop processing
|
|
242
|
-
ensure
|
|
243
|
-
@current_config = nil # Clear memoization
|
|
244
|
-
# Don't reset @halt_validation here - let validate! handle it
|
|
245
|
-
end
|
|
246
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
247
|
-
|
|
248
|
-
# Get the current configuration (instance config overrides global config)
|
|
249
|
-
# @return [Configuration] the active configuration
|
|
250
|
-
def current_config
|
|
251
|
-
return @current_config if @current_config
|
|
252
|
-
|
|
253
|
-
# Try to get class-level config if available
|
|
254
|
-
if self.class.respond_to?(:validation_config)
|
|
255
|
-
self.class.validation_config || Interactor::Validation.configuration
|
|
256
|
-
else
|
|
257
|
-
Interactor::Validation.configuration
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
# Instrument a block of code if instrumentation is enabled
|
|
262
|
-
# @param event_name [String] the event name for ActiveSupport::Notifications
|
|
263
|
-
# @yield the block to instrument
|
|
264
|
-
# @return [Object] the return value of the block
|
|
265
|
-
def instrument(event_name, &)
|
|
266
|
-
if current_config.enable_instrumentation
|
|
267
|
-
ActiveSupport::Notifications.instrument(event_name, interactor: self.class.name, &)
|
|
268
|
-
else
|
|
269
|
-
yield
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
# Validates a single parameter with the given rules
|
|
274
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
275
|
-
def validate_param(param_name, value, rules)
|
|
276
|
-
# Skip validation if explicitly marked
|
|
277
|
-
return if rules[:_skip]
|
|
278
|
-
|
|
279
|
-
# Handle nested validation (hash or array)
|
|
280
|
-
if rules[:_nested]
|
|
281
|
-
# Run presence validation first if it exists
|
|
282
|
-
# This allows optional vs required nested validation
|
|
283
|
-
validate_presence(param_name, value, rules)
|
|
284
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
285
|
-
|
|
286
|
-
# Only run nested validation if value is present (not nil and not empty)
|
|
287
|
-
# If parent has no presence requirement, treat empty hash/array as "not provided"
|
|
288
|
-
# If parent has presence: true, empty hash/array already failed presence check above
|
|
289
|
-
should_validate_nested = value.present? || value == false
|
|
290
|
-
validate_nested(param_name, value, rules[:_nested]) if should_validate_nested
|
|
291
|
-
return
|
|
73
|
+
config = ConfigurationProxy.new(_validation_config)
|
|
74
|
+
yield(config)
|
|
292
75
|
end
|
|
293
76
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
validate_format(param_name, value, rules)
|
|
302
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
303
|
-
|
|
304
|
-
validate_length(param_name, value, rules)
|
|
305
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
306
|
-
|
|
307
|
-
validate_inclusion(param_name, value, rules)
|
|
308
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
309
|
-
|
|
310
|
-
validate_numericality(param_name, value, rules)
|
|
311
|
-
end
|
|
312
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
313
|
-
|
|
314
|
-
# Validates nested attributes in a hash or array
|
|
315
|
-
def validate_nested(param_name, value, nested_rules)
|
|
316
|
-
# Return early if value is nil - presence validation handles this if required
|
|
317
|
-
return if value.nil?
|
|
318
|
-
|
|
319
|
-
if value.is_a?(Array)
|
|
320
|
-
validate_array_of_hashes(param_name, value, nested_rules)
|
|
321
|
-
elsif value.is_a?(Hash)
|
|
322
|
-
validate_hash_attributes(param_name, value, nested_rules)
|
|
323
|
-
else
|
|
324
|
-
# If value is not hash or array, add type error
|
|
325
|
-
add_nested_error(param_name, nil, nil, :invalid_type)
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
# Validates each hash in an array
|
|
330
|
-
# @param param_name [Symbol] the parameter name
|
|
331
|
-
# @param array [Array] the array of hashes to validate
|
|
332
|
-
# @param nested_rules [Hash] validation rules for nested attributes
|
|
333
|
-
# @return [void]
|
|
334
|
-
def validate_array_of_hashes(param_name, array, nested_rules)
|
|
335
|
-
# Memory protection: limit array size
|
|
336
|
-
if array.size > current_config.max_array_size
|
|
337
|
-
add_error(param_name, ErrorCodes::ARRAY_TOO_LARGE, :too_large,
|
|
338
|
-
count: current_config.max_array_size)
|
|
339
|
-
return
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
array.each_with_index do |item, index|
|
|
343
|
-
if item.is_a?(Hash)
|
|
344
|
-
validate_hash_attributes(param_name, item, nested_rules, index: index)
|
|
345
|
-
else
|
|
346
|
-
add_nested_error(param_name, nil, nil, :invalid_type, index: index)
|
|
77
|
+
def validation_halt(value)
|
|
78
|
+
# Ensure we have our own copy of config before modifying
|
|
79
|
+
begin
|
|
80
|
+
self._validation_config = _validation_config.dup if _validation_config.equal?(superclass._validation_config)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
false
|
|
347
83
|
end
|
|
84
|
+
_validation_config[:halt] = value
|
|
348
85
|
end
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
# Validates attributes within a hash
|
|
352
|
-
# @param param_name [Symbol] the parameter name
|
|
353
|
-
# @param hash [Hash] the hash containing attributes to validate
|
|
354
|
-
# @param nested_rules [Hash] validation rules for each attribute
|
|
355
|
-
# @param index [Integer, nil] optional array index for error messages
|
|
356
|
-
# @return [void]
|
|
357
|
-
def validate_hash_attributes(param_name, hash, nested_rules, index: nil)
|
|
358
|
-
nested_rules.each do |attr_name, attr_rules|
|
|
359
|
-
# Check both symbol and string keys, handling nil/false values and missing keys properly
|
|
360
|
-
attr_value = get_nested_value(hash, attr_name)
|
|
361
|
-
validate_nested_attribute(param_name, attr_name, attr_value, attr_rules, index: index)
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
86
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
hash[attr_name]
|
|
372
|
-
elsif hash.key?(attr_name.to_s)
|
|
373
|
-
hash[attr_name.to_s]
|
|
374
|
-
else
|
|
375
|
-
:__missing__ # Sentinel value to distinguish from nil
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
# Validates a single nested attribute
|
|
380
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
381
|
-
def validate_nested_attribute(param_name, attr_name, value, rules, index: nil)
|
|
382
|
-
# Handle missing key sentinel
|
|
383
|
-
is_missing = value == :__missing__
|
|
384
|
-
value = nil if is_missing
|
|
385
|
-
|
|
386
|
-
# Validate presence (false is a valid present value for booleans)
|
|
387
|
-
if rules[:presence] && !value.present? && value != false
|
|
388
|
-
message = extract_message(rules[:presence], :blank)
|
|
389
|
-
add_nested_error(param_name, attr_name, message, :blank, index: index)
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
# Validate boolean (works on all values, not just present ones)
|
|
393
|
-
# Don't validate boolean for missing keys (sentinel value)
|
|
394
|
-
if rules[:boolean] && !is_missing && !boolean?(value)
|
|
395
|
-
message = extract_message(rules[:boolean], :not_boolean)
|
|
396
|
-
add_nested_error(param_name, attr_name, message, :not_boolean, index: index)
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
# Only run other validations if value is present (false is considered present for booleans)
|
|
400
|
-
return unless value.present? || value == false
|
|
401
|
-
|
|
402
|
-
# Validate format (with ReDoS protection)
|
|
403
|
-
if rules[:format]
|
|
404
|
-
format_options = rules[:format]
|
|
405
|
-
pattern = format_options.is_a?(Hash) ? format_options[:with] : format_options
|
|
406
|
-
|
|
407
|
-
# Safe regex matching with timeout protection
|
|
408
|
-
unless safe_regex_match?(value.to_s, pattern)
|
|
409
|
-
message = extract_message(format_options, :invalid)
|
|
410
|
-
add_nested_error(param_name, attr_name, message, :invalid, index: index)
|
|
87
|
+
def validation_mode(value)
|
|
88
|
+
# Ensure we have our own copy of config before modifying
|
|
89
|
+
begin
|
|
90
|
+
self._validation_config = _validation_config.dup if _validation_config.equal?(superclass._validation_config)
|
|
91
|
+
rescue StandardError
|
|
92
|
+
false
|
|
411
93
|
end
|
|
94
|
+
_validation_config[:mode] = value
|
|
412
95
|
end
|
|
413
96
|
|
|
414
|
-
|
|
415
|
-
validate_nested_length(param_name, attr_name, value, rules[:length], index: index) if rules[:length]
|
|
97
|
+
private
|
|
416
98
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
unless allowed_values.include?(value)
|
|
422
|
-
message = extract_message(inclusion_options, :inclusion)
|
|
423
|
-
add_nested_error(param_name, attr_name, message, :inclusion, index: index)
|
|
424
|
-
end
|
|
99
|
+
def build_nested_rules(&)
|
|
100
|
+
builder = NestedBuilder.new
|
|
101
|
+
builder.instance_eval(&)
|
|
102
|
+
builder.rules
|
|
425
103
|
end
|
|
426
|
-
|
|
427
|
-
# Validate numericality
|
|
428
|
-
return unless rules[:numericality]
|
|
429
|
-
|
|
430
|
-
validate_nested_numericality(param_name, attr_name, value, rules[:numericality], index: index)
|
|
431
104
|
end
|
|
432
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
433
105
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
length = value.to_s.length
|
|
106
|
+
class NestedBuilder
|
|
107
|
+
attr_reader :rules
|
|
437
108
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
add_nested_error(param_name, attr_name, message, :too_long,
|
|
441
|
-
count: length_rules[:maximum], index: index)
|
|
109
|
+
def initialize
|
|
110
|
+
@rules = {}
|
|
442
111
|
end
|
|
443
112
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
add_nested_error(param_name, attr_name, message, :too_short,
|
|
447
|
-
count: length_rules[:minimum], index: index)
|
|
113
|
+
def attribute(name, **validations)
|
|
114
|
+
@rules[name] = validations
|
|
448
115
|
end
|
|
449
|
-
|
|
450
|
-
return unless length_rules[:is] && length != length_rules[:is]
|
|
451
|
-
|
|
452
|
-
message = extract_message(length_rules, :wrong_length, count: length_rules[:is])
|
|
453
|
-
add_nested_error(param_name, attr_name, message, :wrong_length,
|
|
454
|
-
count: length_rules[:is], index: index)
|
|
455
116
|
end
|
|
456
117
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
numeric_value = coerce_to_numeric(value)
|
|
469
|
-
|
|
470
|
-
if numeric_rules[:greater_than] && numeric_value <= numeric_rules[:greater_than]
|
|
471
|
-
message = extract_message(numeric_rules, :greater_than, count: numeric_rules[:greater_than])
|
|
472
|
-
add_nested_error(param_name, attr_name, message, :greater_than,
|
|
473
|
-
count: numeric_rules[:greater_than], index: index)
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
if numeric_rules[:greater_than_or_equal_to] && numeric_value < numeric_rules[:greater_than_or_equal_to]
|
|
477
|
-
message = extract_message(numeric_rules, :greater_than_or_equal_to,
|
|
478
|
-
count: numeric_rules[:greater_than_or_equal_to])
|
|
479
|
-
add_nested_error(param_name, attr_name, message, :greater_than_or_equal_to,
|
|
480
|
-
count: numeric_rules[:greater_than_or_equal_to], index: index)
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
if numeric_rules[:less_than] && numeric_value >= numeric_rules[:less_than]
|
|
484
|
-
message = extract_message(numeric_rules, :less_than, count: numeric_rules[:less_than])
|
|
485
|
-
add_nested_error(param_name, attr_name, message, :less_than,
|
|
486
|
-
count: numeric_rules[:less_than], index: index)
|
|
118
|
+
module InstanceMethods
|
|
119
|
+
def self.prepended(base)
|
|
120
|
+
# Include all validator modules
|
|
121
|
+
base.include(Validators::Presence)
|
|
122
|
+
base.include(Validators::Numeric)
|
|
123
|
+
base.include(Validators::Boolean)
|
|
124
|
+
base.include(Validators::Format)
|
|
125
|
+
base.include(Validators::Length)
|
|
126
|
+
base.include(Validators::Inclusion)
|
|
127
|
+
base.include(Validators::Hash)
|
|
128
|
+
base.include(Validators::Array)
|
|
487
129
|
end
|
|
488
130
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
count: numeric_rules[:less_than_or_equal_to])
|
|
492
|
-
add_nested_error(param_name, attr_name, message, :less_than_or_equal_to,
|
|
493
|
-
count: numeric_rules[:less_than_or_equal_to], index: index)
|
|
494
|
-
end
|
|
495
|
-
|
|
496
|
-
return unless numeric_rules[:equal_to] && numeric_value != numeric_rules[:equal_to]
|
|
497
|
-
|
|
498
|
-
message = extract_message(numeric_rules, :equal_to, count: numeric_rules[:equal_to])
|
|
499
|
-
add_nested_error(param_name, attr_name, message, :equal_to,
|
|
500
|
-
count: numeric_rules[:equal_to], index: index)
|
|
501
|
-
end
|
|
502
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
503
|
-
|
|
504
|
-
# Add error for nested validation
|
|
505
|
-
# rubocop:disable Metrics/ParameterLists
|
|
506
|
-
def add_nested_error(param_name, attr_name, custom_message, error_type, index: nil, halt: false, **interpolations)
|
|
507
|
-
# Build the attribute path for the error
|
|
508
|
-
attribute_path = if index.nil?
|
|
509
|
-
# Hash validation: param_name.attr_name
|
|
510
|
-
attr_name ? :"#{param_name}.#{attr_name}" : param_name
|
|
511
|
-
else
|
|
512
|
-
# Array validation: param_name[index].attr_name
|
|
513
|
-
attr_name ? :"#{param_name}[#{index}].#{attr_name}" : :"#{param_name}[#{index}]"
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
if current_config.error_mode == :code
|
|
517
|
-
# Code mode: use custom message or generate code
|
|
518
|
-
code_message = custom_message || error_code_for(error_type, **interpolations)
|
|
519
|
-
errors.add(attribute_path, code_message, halt: halt)
|
|
520
|
-
elsif custom_message
|
|
521
|
-
# Default mode: use ActiveModel's error messages with custom message
|
|
522
|
-
errors.add(attribute_path, custom_message, halt: halt)
|
|
523
|
-
else
|
|
524
|
-
errors.add(attribute_path, error_type, halt: halt, **interpolations)
|
|
131
|
+
def errors
|
|
132
|
+
@errors ||= Errors.new(halt_checker: -> { validation_config(:halt) })
|
|
525
133
|
end
|
|
526
134
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
# rubocop:enable Metrics/ParameterLists
|
|
135
|
+
def run_validations!
|
|
136
|
+
param_errors = false
|
|
530
137
|
|
|
531
|
-
def validate_presence(param_name, value, rules)
|
|
532
|
-
return unless rules[:presence]
|
|
533
|
-
# For booleans, false is a valid present value
|
|
534
|
-
return if value.present? || value == false
|
|
535
|
-
|
|
536
|
-
message = extract_message(rules[:presence], :blank)
|
|
537
|
-
add_error(param_name, message, :blank)
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
def validate_boolean(param_name, value, rules)
|
|
541
|
-
return unless rules[:boolean]
|
|
542
|
-
return if boolean?(value)
|
|
543
|
-
|
|
544
|
-
message = extract_message(rules[:boolean], :not_boolean)
|
|
545
|
-
add_error(param_name, message, :not_boolean)
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
def boolean?(value)
|
|
549
|
-
[true, false].include?(value)
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
# Validates format using regex patterns with ReDoS protection
|
|
553
|
-
# @param param_name [Symbol] the parameter name
|
|
554
|
-
# @param value [Object] the value to validate
|
|
555
|
-
# @param rules [Hash] validation rules containing :format
|
|
556
|
-
# @return [void]
|
|
557
|
-
def validate_format(param_name, value, rules)
|
|
558
|
-
return unless rules[:format] && value.present?
|
|
559
|
-
|
|
560
|
-
format_options = rules[:format]
|
|
561
|
-
pattern = format_options.is_a?(Hash) ? format_options[:with] : format_options
|
|
562
|
-
|
|
563
|
-
# Safe regex matching with timeout and caching
|
|
564
|
-
return if safe_regex_match?(value.to_s, pattern)
|
|
565
|
-
|
|
566
|
-
message = extract_message(format_options, :invalid)
|
|
567
|
-
add_error(param_name, message, :invalid)
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
# Safely match a value against a regex pattern with timeout protection
|
|
571
|
-
# @param value [String] the string to match
|
|
572
|
-
# @param pattern [Regexp] the regex pattern
|
|
573
|
-
# @return [Boolean] true if matches, false if no match or timeout
|
|
574
|
-
def safe_regex_match?(value, pattern)
|
|
575
|
-
# Get cached pattern if caching is enabled
|
|
576
|
-
cached_pattern = current_config.cache_regex_patterns ? get_cached_regex(pattern) : pattern
|
|
577
|
-
|
|
578
|
-
# Use Regexp.timeout if available (Ruby 3.2+)
|
|
579
|
-
if Regexp.respond_to?(:timeout)
|
|
580
|
-
begin
|
|
581
|
-
Regexp.timeout = current_config.regex_timeout
|
|
582
|
-
value.match?(cached_pattern)
|
|
583
|
-
rescue Regexp::TimeoutError
|
|
584
|
-
# Log timeout and treat as validation failure
|
|
585
|
-
add_error(:regex, ErrorCodes::REGEX_TIMEOUT, :timeout) if errors.respond_to?(:add)
|
|
586
|
-
false
|
|
587
|
-
ensure
|
|
588
|
-
Regexp.timeout = nil
|
|
589
|
-
end
|
|
590
|
-
else
|
|
591
|
-
# Fallback for older Ruby versions - use Timeout module
|
|
592
|
-
require "timeout"
|
|
593
138
|
begin
|
|
594
|
-
|
|
595
|
-
|
|
139
|
+
# Run parameter validations
|
|
140
|
+
if self.class._validations
|
|
141
|
+
self.class._validations.each do |param, rules|
|
|
142
|
+
value = context.respond_to?(param) ? context.public_send(param) : nil
|
|
143
|
+
validate_param(param, value, rules)
|
|
144
|
+
end
|
|
145
|
+
param_errors = errors.any?
|
|
596
146
|
end
|
|
597
|
-
rescue Timeout::Error
|
|
598
|
-
add_error(:regex, ErrorCodes::REGEX_TIMEOUT, :timeout) if errors.respond_to?(:add)
|
|
599
|
-
false
|
|
600
|
-
end
|
|
601
|
-
end
|
|
602
|
-
end
|
|
603
|
-
|
|
604
|
-
# Get or cache a compiled regex pattern
|
|
605
|
-
# @param pattern [Regexp] the pattern to cache
|
|
606
|
-
# @return [Regexp] the cached pattern
|
|
607
|
-
def get_cached_regex(pattern)
|
|
608
|
-
cache_key = pattern.source
|
|
609
|
-
self.class._regex_cache[cache_key] ||= pattern
|
|
610
|
-
end
|
|
611
147
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if length_rules[:maximum] && length > length_rules[:maximum]
|
|
619
|
-
message = extract_message(length_rules, :too_long, count: length_rules[:maximum])
|
|
620
|
-
add_error(param_name, message, :too_long, count: length_rules[:maximum])
|
|
621
|
-
end
|
|
622
|
-
|
|
623
|
-
if length_rules[:minimum] && length < length_rules[:minimum]
|
|
624
|
-
message = extract_message(length_rules, :too_short, count: length_rules[:minimum])
|
|
625
|
-
add_error(param_name, message, :too_short, count: length_rules[:minimum])
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
return unless length_rules[:is] && length != length_rules[:is]
|
|
629
|
-
|
|
630
|
-
message = extract_message(length_rules, :wrong_length, count: length_rules[:is])
|
|
631
|
-
add_error(param_name, message, :wrong_length, count: length_rules[:is])
|
|
632
|
-
end
|
|
633
|
-
|
|
634
|
-
def validate_inclusion(param_name, value, rules)
|
|
635
|
-
return unless rules[:inclusion] && value.present?
|
|
636
|
-
|
|
637
|
-
inclusion_options = rules[:inclusion]
|
|
638
|
-
allowed_values = inclusion_options.is_a?(Hash) ? inclusion_options[:in] : inclusion_options
|
|
639
|
-
return if allowed_values.include?(value)
|
|
640
|
-
|
|
641
|
-
message = extract_message(inclusion_options, :inclusion)
|
|
642
|
-
add_error(param_name, message, :inclusion)
|
|
643
|
-
end
|
|
644
|
-
|
|
645
|
-
def validate_numericality(param_name, value, rules)
|
|
646
|
-
return unless rules[:numericality] && value.present?
|
|
647
|
-
|
|
648
|
-
numeric_rules = rules[:numericality].is_a?(Hash) ? rules[:numericality] : {}
|
|
148
|
+
# Run custom validations if defined
|
|
149
|
+
# Skip if param validations failed and skip_validate is true
|
|
150
|
+
validate! if respond_to?(:validate!, true) && !(param_errors && validation_config(:skip_validate))
|
|
151
|
+
rescue HaltValidation
|
|
152
|
+
# Validation halted on first error - fall through to fail context
|
|
153
|
+
end
|
|
649
154
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
add_error(param_name, message, :not_a_number)
|
|
653
|
-
return
|
|
155
|
+
# Fail context if any errors exist
|
|
156
|
+
context.fail!(errors: format_errors) if errors.any?
|
|
654
157
|
end
|
|
655
158
|
|
|
656
|
-
|
|
657
|
-
validate_numeric_constraints(param_name, numeric_value, numeric_rules)
|
|
658
|
-
end
|
|
659
|
-
|
|
660
|
-
# Check if a value is numeric or can be coerced to numeric
|
|
661
|
-
# @param value [Object] the value to check
|
|
662
|
-
# @return [Boolean] true if numeric or numeric string
|
|
663
|
-
def numeric?(value)
|
|
664
|
-
value.is_a?(Numeric) || value.to_s.match?(/\A-?\d+(\.\d+)?\z/)
|
|
665
|
-
end
|
|
666
|
-
|
|
667
|
-
# Coerce a value to numeric, preserving integer precision
|
|
668
|
-
# @param value [Object] the value to coerce
|
|
669
|
-
# @return [Numeric] integer or float depending on input
|
|
670
|
-
def coerce_to_numeric(value)
|
|
671
|
-
return value if value.is_a?(Numeric)
|
|
672
|
-
|
|
673
|
-
str = value.to_s
|
|
674
|
-
# Use to_i for integers to preserve precision, to_f for floats
|
|
675
|
-
str.include?(".") ? str.to_f : str.to_i
|
|
676
|
-
end
|
|
159
|
+
private
|
|
677
160
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
161
|
+
def validate_param(param, value, rules)
|
|
162
|
+
# Handle nested validation
|
|
163
|
+
if rules[:_nested]
|
|
164
|
+
validate_presence(param, value, rules[:presence]) if rules[:presence]
|
|
165
|
+
validate_nested(param, value, rules[:_nested]) if value.present?
|
|
166
|
+
return
|
|
167
|
+
end
|
|
683
168
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
end
|
|
169
|
+
# Standard validations
|
|
170
|
+
validate_presence(param, value, rules[:presence]) if rules[:presence]
|
|
171
|
+
return unless value.present?
|
|
688
172
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
173
|
+
validate_boolean(param, value) if rules[:boolean]
|
|
174
|
+
validate_format(param, value, rules[:format]) if rules[:format]
|
|
175
|
+
validate_length(param, value, rules[:length]) if rules[:length]
|
|
176
|
+
validate_inclusion(param, value, rules[:inclusion]) if rules[:inclusion]
|
|
177
|
+
validate_numeric(param, value, rules[:numeric] || rules[:numericality]) if rules[:numeric] || rules[:numericality]
|
|
692
178
|
end
|
|
693
179
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
180
|
+
def validate_nested(param, value, nested_rules)
|
|
181
|
+
if value.is_a?(::Array)
|
|
182
|
+
validate_array(param, value, nested_rules)
|
|
183
|
+
elsif value.is_a?(::Hash)
|
|
184
|
+
validate_hash(param, value, nested_rules)
|
|
185
|
+
end
|
|
697
186
|
end
|
|
698
187
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
add_error(param_name, message, :equal_to, count: rules[:equal_to])
|
|
703
|
-
end
|
|
704
|
-
|
|
705
|
-
# Extract custom message from validation options
|
|
706
|
-
# @param options [Hash, Boolean] validation options
|
|
707
|
-
# @param error_type [Symbol] the type of error
|
|
708
|
-
# @param interpolations [Hash] values to interpolate into message
|
|
709
|
-
# @return [String, nil] custom message if provided
|
|
710
|
-
def extract_message(options, _error_type, **_interpolations)
|
|
711
|
-
return nil unless options.is_a?(Hash)
|
|
712
|
-
|
|
713
|
-
options[:message]
|
|
714
|
-
end
|
|
715
|
-
|
|
716
|
-
# Add an error with proper formatting based on error mode
|
|
717
|
-
# @param param_name [Symbol] the parameter name
|
|
718
|
-
# @param custom_message [String, nil] custom error message if provided
|
|
719
|
-
# @param error_type [Symbol] the type of validation error
|
|
720
|
-
# @param halt [Boolean] if true, halts validation immediately after adding this error
|
|
721
|
-
# @param interpolations [Hash] values to interpolate into the message
|
|
722
|
-
def add_error(param_name, custom_message, error_type, halt: false, **interpolations)
|
|
723
|
-
if current_config.error_mode == :code
|
|
724
|
-
# Code mode: use custom message or generate code
|
|
725
|
-
code_message = custom_message || error_code_for(error_type, **interpolations)
|
|
726
|
-
errors.add(param_name, code_message, halt: halt)
|
|
727
|
-
elsif custom_message
|
|
728
|
-
# Default mode: use ActiveModel's error messages
|
|
729
|
-
errors.add(param_name, custom_message, halt: halt)
|
|
730
|
-
else
|
|
731
|
-
errors.add(param_name, error_type, halt: halt, **interpolations)
|
|
188
|
+
def validation_config(key)
|
|
189
|
+
# Check per-interactor config first, then fall back to global config
|
|
190
|
+
self.class._validation_config.key?(key) ? self.class._validation_config[key] : Interactor::Validation.configuration.public_send(key)
|
|
732
191
|
end
|
|
733
192
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
742
|
-
def error_code_for(error_type, **interpolations)
|
|
743
|
-
case error_type
|
|
744
|
-
when :blank
|
|
745
|
-
ErrorCodes::REQUIRED
|
|
746
|
-
when :not_boolean
|
|
747
|
-
ErrorCodes::MUST_BE_BOOLEAN
|
|
748
|
-
when :invalid
|
|
749
|
-
ErrorCodes::INVALID_FORMAT
|
|
750
|
-
when :too_long
|
|
751
|
-
ErrorCodes.exceeds_max_length(interpolations[:count])
|
|
752
|
-
when :too_short
|
|
753
|
-
ErrorCodes.below_min_length(interpolations[:count])
|
|
754
|
-
when :wrong_length
|
|
755
|
-
ErrorCodes.must_be_length(interpolations[:count])
|
|
756
|
-
when :inclusion
|
|
757
|
-
ErrorCodes::NOT_IN_ALLOWED_VALUES
|
|
758
|
-
when :not_a_number
|
|
759
|
-
ErrorCodes::MUST_BE_A_NUMBER
|
|
760
|
-
when :greater_than
|
|
761
|
-
ErrorCodes.must_be_greater_than(interpolations[:count])
|
|
762
|
-
when :greater_than_or_equal_to
|
|
763
|
-
ErrorCodes.must_be_at_least(interpolations[:count])
|
|
764
|
-
when :less_than
|
|
765
|
-
ErrorCodes.must_be_less_than(interpolations[:count])
|
|
766
|
-
when :less_than_or_equal_to
|
|
767
|
-
ErrorCodes.must_be_at_most(interpolations[:count])
|
|
768
|
-
when :equal_to
|
|
769
|
-
ErrorCodes.must_be_equal_to(interpolations[:count])
|
|
770
|
-
when :invalid_type
|
|
771
|
-
ErrorCodes::INVALID_TYPE
|
|
772
|
-
when :too_large
|
|
773
|
-
ErrorCodes::ARRAY_TOO_LARGE
|
|
774
|
-
when :timeout
|
|
775
|
-
ErrorCodes::REGEX_TIMEOUT
|
|
776
|
-
else
|
|
777
|
-
error_type.to_s.upcase
|
|
193
|
+
def format_errors
|
|
194
|
+
case validation_config(:mode)
|
|
195
|
+
when :code
|
|
196
|
+
format_errors_as_code
|
|
197
|
+
else
|
|
198
|
+
format_errors_as_default
|
|
199
|
+
end
|
|
778
200
|
end
|
|
779
|
-
end
|
|
780
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
781
|
-
|
|
782
|
-
# Formats errors into the expected structure
|
|
783
|
-
def formatted_errors
|
|
784
|
-
if current_config.error_mode == :code
|
|
785
|
-
# Code mode: return structured error codes
|
|
786
|
-
errors.map do |error|
|
|
787
|
-
# Convert attribute path to uppercase, handling nested paths
|
|
788
|
-
# Example: "attributes.username" -> "ATTRIBUTES.USERNAME"
|
|
789
|
-
# Example: "attributes[0].username" -> "ATTRIBUTES[0]_USERNAME"
|
|
790
|
-
param_name = format_attribute_for_code(error.attribute)
|
|
791
|
-
begin
|
|
792
|
-
message = error.message
|
|
793
|
-
rescue ArgumentError, NoMethodError
|
|
794
|
-
# For anonymous classes or other edge cases, fall back to type
|
|
795
|
-
message = error.type.to_s.upcase
|
|
796
|
-
end
|
|
797
201
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
else
|
|
801
|
-
# Default mode: return ActiveModel-style errors
|
|
802
|
-
errors.map do |error|
|
|
803
|
-
# Build a human-readable message manually to avoid anonymous class issues
|
|
804
|
-
message = build_error_message(error)
|
|
202
|
+
def format_errors_as_default
|
|
203
|
+
errors.map do |err|
|
|
805
204
|
{
|
|
806
|
-
attribute:
|
|
807
|
-
type:
|
|
808
|
-
message: message
|
|
205
|
+
attribute: err.attribute,
|
|
206
|
+
type: err.type,
|
|
207
|
+
message: "#{err.attribute.to_s.humanize} #{err.message}"
|
|
809
208
|
}
|
|
810
209
|
end
|
|
811
210
|
end
|
|
812
|
-
end
|
|
813
211
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
def format_attribute_for_code(attribute)
|
|
818
|
-
# Convert to string and uppercase
|
|
819
|
-
attr_str = attribute.to_s.upcase
|
|
820
|
-
# Replace dots with underscores, but preserve array indices
|
|
821
|
-
# Example: "attributes[0].username" -> "ATTRIBUTES[0]_USERNAME"
|
|
822
|
-
attr_str.gsub(/\.(?![^\[]*\])/, "_")
|
|
823
|
-
end
|
|
824
|
-
|
|
825
|
-
# Build a human-readable error message
|
|
826
|
-
# @param error [ActiveModel::Error] the error object
|
|
827
|
-
# @return [String] the formatted message
|
|
828
|
-
def build_error_message(error)
|
|
829
|
-
# For nested attributes (with dots or brackets), we can't use ActiveModel's message method
|
|
830
|
-
# because it tries to call a method on the class which doesn't exist
|
|
831
|
-
if error.attribute.to_s.include?(".") || error.attribute.to_s.include?("[")
|
|
832
|
-
# Manually build message for nested attributes
|
|
833
|
-
attribute_name = error.attribute.to_s.humanize
|
|
834
|
-
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
835
|
-
"#{attribute_name} #{error_message}"
|
|
836
|
-
elsif error.respond_to?(:message)
|
|
837
|
-
# Try to use ActiveModel's message for simple attributes
|
|
838
|
-
message = error.message
|
|
839
|
-
# If translation is missing, fall back to our default messages
|
|
840
|
-
if message.include?("Translation missing")
|
|
841
|
-
attribute_name = error.attribute.to_s.humanize
|
|
842
|
-
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
843
|
-
"#{attribute_name} #{error_message}"
|
|
844
|
-
else
|
|
845
|
-
message
|
|
212
|
+
def format_errors_as_code
|
|
213
|
+
errors.map do |err|
|
|
214
|
+
{ code: generate_error_code(err.attribute, err.type) }
|
|
846
215
|
end
|
|
847
216
|
end
|
|
848
|
-
rescue ArgumentError, NoMethodError
|
|
849
|
-
# Fallback for anonymous classes or other issues
|
|
850
|
-
attribute_name = error.attribute.to_s.humanize
|
|
851
|
-
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
852
|
-
"#{attribute_name} #{error_message}"
|
|
853
|
-
end
|
|
854
217
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
"
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
"is invalid"
|
|
868
|
-
when :too_long
|
|
869
|
-
"is too long (maximum is #{options[:count]} characters)"
|
|
870
|
-
when :too_short
|
|
871
|
-
"is too short (minimum is #{options[:count]} characters)"
|
|
872
|
-
when :wrong_length
|
|
873
|
-
"is the wrong length (should be #{options[:count]} characters)"
|
|
874
|
-
when :inclusion
|
|
875
|
-
"is not included in the list"
|
|
876
|
-
when :not_a_number
|
|
877
|
-
"is not a number"
|
|
878
|
-
when :greater_than
|
|
879
|
-
"must be greater than #{options[:count]}"
|
|
880
|
-
when :greater_than_or_equal_to
|
|
881
|
-
"must be greater than or equal to #{options[:count]}"
|
|
882
|
-
when :less_than
|
|
883
|
-
"must be less than #{options[:count]}"
|
|
884
|
-
when :less_than_or_equal_to
|
|
885
|
-
"must be less than or equal to #{options[:count]}"
|
|
886
|
-
when :equal_to
|
|
887
|
-
"must be equal to #{options[:count]}"
|
|
888
|
-
when :invalid_type
|
|
889
|
-
"must be a Hash or Array"
|
|
890
|
-
else
|
|
891
|
-
"is invalid"
|
|
218
|
+
def generate_error_code(attribute, type)
|
|
219
|
+
# Convert attribute to uppercase with underscores
|
|
220
|
+
# Handle nested attributes: user.email → USER_EMAIL, items[0].name → ITEMS[0]_NAME
|
|
221
|
+
code_attribute = attribute.to_s
|
|
222
|
+
.gsub(/\[(\d+)\]\./, '[\\1]_')
|
|
223
|
+
.gsub(".", "_")
|
|
224
|
+
.upcase
|
|
225
|
+
|
|
226
|
+
# Use "IS_REQUIRED" for blank errors, otherwise use type name
|
|
227
|
+
code_type = type == :blank ? "IS_REQUIRED" : type.to_s.upcase
|
|
228
|
+
|
|
229
|
+
"#{code_attribute}_#{code_type}"
|
|
892
230
|
end
|
|
893
231
|
end
|
|
894
|
-
# rubocop:enable Metrics/MethodLength
|
|
895
232
|
end
|
|
896
|
-
# rubocop:enable Metrics/ModuleLength
|
|
897
233
|
end
|
|
898
234
|
end
|