interactor-validation 0.3.9 → 0.4.0

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.
@@ -1,898 +1,190 @@
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
- # rubocop:disable Metrics/ModuleLength
6
14
  module Validates
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
24
-
25
15
  def self.included(base)
26
- super
27
- # Include ActiveModel::Validations first, then prepend our overrides
28
- base.include ActiveModel::Validations unless base.included_modules.include?(ActiveModel::Validations)
29
- base.singleton_class.prepend(ClassMethodsOverride)
30
- # Prepend our instance method overrides to take precedence over ActiveModel
31
- base.prepend(InstanceMethodsOverride)
16
+ base.extend(ClassMethods)
17
+ base.class_attribute :_validations
18
+ base._validations = {}
19
+ base.class_attribute :_validation_config
20
+ base._validation_config = {}
21
+ base.prepend(InstanceMethods)
32
22
  end
33
23
 
34
- module ClassMethodsOverride
35
- # Override ActiveModel's validates to handle our simple validation rules
36
- # Falls back to ActiveModel's validates for complex cases
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]
24
+ module ClassMethods
41
25
  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
26
+ _validations[param_name] ||= {}
27
+ _validations[param_name].merge!(rules)
28
+ _validations[param_name][:_nested] = build_nested_rules(&) if block_given?
29
+ end
55
30
 
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
31
+ def validation_halt(value)
32
+ _validation_config[:halt] = value
33
+ end
65
34
 
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
35
+ def validation_mode(value)
36
+ _validation_config[:mode] = value
71
37
  end
72
38
 
73
39
  private
74
40
 
75
- # Build nested validation rules from a block
76
41
  def build_nested_rules(&)
77
- builder = NestedValidationBuilder.new
42
+ builder = NestedBuilder.new
78
43
  builder.instance_eval(&)
79
44
  builder.rules
80
45
  end
81
46
  end
82
47
 
83
- # Builder class for nested validation DSL
84
- class NestedValidationBuilder
48
+ class NestedBuilder
85
49
  attr_reader :rules
86
50
 
87
51
  def initialize
88
52
  @rules = {}
89
53
  end
90
54
 
91
- # Define validation for a nested attribute
92
- def attribute(attr_name, **validations)
93
- @rules[attr_name] = validations
55
+ def attribute(name, **validations)
56
+ @rules[name] = validations
94
57
  end
95
58
  end
96
59
 
97
- # Wrapper for ActiveModel::Errors that intercepts halt: option
98
- class ErrorsWrapper < SimpleDelegator
99
- def initialize(errors, interactor)
100
- super(errors)
101
- @interactor = interactor
60
+ # Base module with default validate! that does nothing
61
+ module BaseValidation
62
+ def validate!
63
+ # Default implementation - does nothing
64
+ # Subclasses can override and call super
102
65
  end
66
+ end
103
67
 
104
- # Override add to intercept halt: option
105
- def add(attribute, message = :invalid, options = {})
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
68
+ module InstanceMethods
69
+ def self.prepended(base)
70
+ # Include BaseValidation so super works in user's validate!
71
+ base.include(BaseValidation) unless base.ancestors.include?(BaseValidation)
114
72
 
115
- @interactor.instance_variable_set(:@halt_validation, true)
116
- raise HaltValidation
73
+ # Include all validator modules
74
+ base.include(Validators::Presence)
75
+ base.include(Validators::Numeric)
76
+ base.include(Validators::Boolean)
77
+ base.include(Validators::Format)
78
+ base.include(Validators::Length)
79
+ base.include(Validators::Inclusion)
80
+ base.include(Validators::Hash)
81
+ base.include(Validators::Array)
117
82
  end
118
- end
119
83
 
120
- # Module prepended to override ActiveModel instance methods
121
- module InstanceMethodsOverride
122
- # Override errors to return a wrapper that intercepts halt: option
123
84
  def errors
124
- @errors ||= ErrorsWrapper.new(super, self)
85
+ @errors ||= Errors.new
125
86
  end
126
87
 
127
- # Override ActiveModel's validate! to prevent exception-raising behavior
128
- # This hook is called automatically after validate_params!
129
- # Users can override this to add custom validation logic
130
- # @return [void]
131
- # @example
132
- # def validate!
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
88
  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
89
+ errors.clear
90
+ param_errors = false
91
+
92
+ # Run parameter validations
93
+ if self.class._validations
94
+ self.class._validations.each do |param, rules|
95
+ value = context.respond_to?(param) ? context.public_send(param) : nil
96
+ validate_param(param, value, rules)
97
+
98
+ # Halt on first error if configured
99
+ if validation_config(:halt) && errors.any?
100
+ context.fail!(errors: format_errors)
101
+ break
102
+ end
168
103
  end
104
+ param_errors = errors.any?
169
105
  end
170
106
 
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")
107
+ # Call super to allow user-defined validate! to run
108
+ # Skip if param validations failed and skip_validate is true
109
+ super unless param_errors && validation_config(:skip_validate)
181
110
 
182
- message = error_detail[:options][:message] || error_detail[:type].to_s
183
- errors.add(error_detail[:attribute], message)
184
- end
185
- end
186
-
187
- # Reset halt flag after validation completes
188
- @halt_validation = false
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)
111
+ # Fail context if any errors exist
112
+ context.fail!(errors: format_errors) if errors.any?
196
113
  end
197
- # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
198
- end
199
-
200
- private
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
217
- begin
218
- valid?
219
- rescue ArgumentError => e
220
- # For anonymous classes, valid? may fail when generating messages
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?)
235
- end
236
-
237
- # Don't fail here - let validate! hook handle failure
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
114
 
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
292
- end
293
-
294
- # Standard validations - halt if requested or configured after each validation
295
- validate_presence(param_name, value, rules)
296
- return if @halt_validation || (@current_config.halt && errors.any?)
297
-
298
- validate_boolean(param_name, value, rules)
299
- return if @halt_validation || (@current_config.halt && errors.any?)
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
115
+ private
341
116
 
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)
117
+ def validate_param(param, value, rules)
118
+ # Handle nested validation
119
+ if rules[:_nested]
120
+ validate_presence(param, value, rules[:presence]) if rules[:presence]
121
+ validate_nested(param, value, rules[:_nested]) if value.present?
122
+ return
347
123
  end
348
- 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
124
 
365
- # Get nested value from hash, distinguishing between nil and missing keys
366
- # @param hash [Hash] the hash to search
367
- # @param attr_name [Symbol] the attribute name
368
- # @return [Object, Symbol] the value or :__missing__ sentinel
369
- def get_nested_value(hash, attr_name)
370
- if hash.key?(attr_name)
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
125
+ # Standard validations
126
+ validate_presence(param, value, rules[:presence]) if rules[:presence]
127
+ return unless value.present?
406
128
 
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)
411
- end
129
+ validate_boolean(param, value) if rules[:boolean]
130
+ validate_format(param, value, rules[:format]) if rules[:format]
131
+ validate_length(param, value, rules[:length]) if rules[:length]
132
+ validate_inclusion(param, value, rules[:inclusion]) if rules[:inclusion]
133
+ validate_numeric(param, value, rules[:numeric] || rules[:numericality]) if rules[:numeric] || rules[:numericality]
412
134
  end
413
135
 
414
- # Validate length
415
- validate_nested_length(param_name, attr_name, value, rules[:length], index: index) if rules[:length]
416
-
417
- # Validate inclusion
418
- if rules[:inclusion]
419
- inclusion_options = rules[:inclusion]
420
- allowed_values = inclusion_options.is_a?(Hash) ? inclusion_options[:in] : inclusion_options
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)
136
+ def validate_nested(param, value, nested_rules)
137
+ if value.is_a?(::Array)
138
+ validate_array(param, value, nested_rules)
139
+ elsif value.is_a?(::Hash)
140
+ validate_hash(param, value, nested_rules)
424
141
  end
425
142
  end
426
143
 
427
- # Validate numericality
428
- return unless rules[:numericality]
429
-
430
- validate_nested_numericality(param_name, attr_name, value, rules[:numericality], index: index)
431
- end
432
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
433
-
434
- # Validates length for nested attributes
435
- def validate_nested_length(param_name, attr_name, value, length_rules, index: nil)
436
- length = value.to_s.length
437
-
438
- if length_rules[:maximum] && length > length_rules[:maximum]
439
- message = extract_message(length_rules, :too_long, count: length_rules[:maximum])
440
- add_nested_error(param_name, attr_name, message, :too_long,
441
- count: length_rules[:maximum], index: index)
442
- end
443
-
444
- if length_rules[:minimum] && length < length_rules[:minimum]
445
- message = extract_message(length_rules, :too_short, count: length_rules[:minimum])
446
- add_nested_error(param_name, attr_name, message, :too_short,
447
- count: length_rules[:minimum], index: index)
448
- 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
- end
456
-
457
- # Validates numericality for nested attributes
458
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
459
- def validate_nested_numericality(param_name, attr_name, value, numeric_rules, index: nil)
460
- numeric_rules = {} unless numeric_rules.is_a?(Hash)
461
-
462
- unless numeric?(value)
463
- message = extract_message(numeric_rules, :not_a_number)
464
- add_nested_error(param_name, attr_name, message, :not_a_number, index: index)
465
- return
144
+ def validation_config(key)
145
+ # Check per-interactor config first, then fall back to global config
146
+ self.class._validation_config.key?(key) ? self.class._validation_config[key] : Interactor::Validation.configuration.public_send(key)
466
147
  end
467
148
 
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)
487
- end
488
-
489
- if numeric_rules[:less_than_or_equal_to] && numeric_value > numeric_rules[:less_than_or_equal_to]
490
- message = extract_message(numeric_rules, :less_than_or_equal_to,
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)
525
- end
526
-
527
- # NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
528
- end
529
- # rubocop:enable Metrics/ParameterLists
530
-
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
- begin
594
- Timeout.timeout(current_config.regex_timeout) do
595
- value.match?(cached_pattern)
596
- end
597
- rescue Timeout::Error
598
- add_error(:regex, ErrorCodes::REGEX_TIMEOUT, :timeout) if errors.respond_to?(:add)
599
- false
149
+ def format_errors
150
+ case validation_config(:mode)
151
+ when :code
152
+ format_errors_as_code
153
+ else
154
+ format_errors_as_default
600
155
  end
601
156
  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
-
612
- def validate_length(param_name, value, rules)
613
- return unless rules[:length] && value.present?
614
-
615
- length_rules = rules[:length]
616
- length = value.to_s.length
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] : {}
649
-
650
- unless numeric?(value)
651
- message = extract_message(numeric_rules, :not_a_number)
652
- add_error(param_name, message, :not_a_number)
653
- return
654
- end
655
-
656
- numeric_value = coerce_to_numeric(value)
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
677
-
678
- def validate_numeric_constraints(param_name, value, rules)
679
- if rules[:greater_than] && value <= rules[:greater_than]
680
- message = extract_message(rules, :greater_than, count: rules[:greater_than])
681
- add_error(param_name, message, :greater_than, count: rules[:greater_than])
682
- end
683
-
684
- if rules[:greater_than_or_equal_to] && value < rules[:greater_than_or_equal_to]
685
- message = extract_message(rules, :greater_than_or_equal_to, count: rules[:greater_than_or_equal_to])
686
- add_error(param_name, message, :greater_than_or_equal_to, count: rules[:greater_than_or_equal_to])
687
- end
688
-
689
- if rules[:less_than] && value >= rules[:less_than]
690
- message = extract_message(rules, :less_than, count: rules[:less_than])
691
- add_error(param_name, message, :less_than, count: rules[:less_than])
692
- end
693
-
694
- if rules[:less_than_or_equal_to] && value > rules[:less_than_or_equal_to]
695
- message = extract_message(rules, :less_than_or_equal_to, count: rules[:less_than_or_equal_to])
696
- add_error(param_name, message, :less_than_or_equal_to, count: rules[:less_than_or_equal_to])
697
- end
698
157
 
699
- return unless rules[:equal_to] && value != rules[:equal_to]
700
-
701
- message = extract_message(rules, :equal_to, count: rules[:equal_to])
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)
732
- end
733
-
734
- # NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
735
- end
736
-
737
- # Generate error code for :code mode using constants
738
- # @param error_type [Symbol] the type of validation error
739
- # @param interpolations [Hash] values to interpolate into the code
740
- # @return [String] the error code
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
778
- 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
-
798
- { code: "#{param_name}_#{message}" }
799
- end
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)
158
+ def format_errors_as_default
159
+ errors.map do |err|
805
160
  {
806
- attribute: error.attribute,
807
- type: error.type,
808
- message: message
161
+ attribute: err.attribute,
162
+ type: err.type,
163
+ message: "#{err.attribute.to_s.humanize} #{err.message}"
809
164
  }
810
165
  end
811
166
  end
812
- end
813
167
 
814
- # Format attribute path for error code
815
- # @param attribute [Symbol] the attribute path (e.g., :"attributes.username" or :"attributes[0].username")
816
- # @return [String] formatted attribute path (e.g., "ATTRIBUTES_USERNAME" or "ATTRIBUTES[0]_USERNAME")
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
168
+ def format_errors_as_code
169
+ errors.map do |err|
170
+ { code: generate_error_code(err.attribute, err.type) }
846
171
  end
847
172
  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
173
 
855
- # Get default message for error type
856
- # @param type [Symbol] the error type
857
- # @param options [Hash] error options with interpolations
858
- # @return [String] the default message
859
- # rubocop:disable Metrics/MethodLength
860
- def default_message_for_type(type, options = {})
861
- case type
862
- when :blank
863
- "can't be blank"
864
- when :not_boolean
865
- "must be a boolean value"
866
- when :invalid
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"
174
+ def generate_error_code(attribute, type)
175
+ # Convert attribute to uppercase with underscores
176
+ # Handle nested attributes: user.email → USER_EMAIL, items[0].name ITEMS[0]_NAME
177
+ code_attribute = attribute.to_s
178
+ .gsub(/\[(\d+)\]\./, '[\\1]_')
179
+ .gsub(".", "_")
180
+ .upcase
181
+
182
+ # Use "IS_REQUIRED" for blank errors, otherwise use type name
183
+ code_type = type == :blank ? "IS_REQUIRED" : type.to_s.upcase
184
+
185
+ "#{code_attribute}_#{code_type}"
892
186
  end
893
187
  end
894
- # rubocop:enable Metrics/MethodLength
895
188
  end
896
- # rubocop:enable Metrics/ModuleLength
897
189
  end
898
190
  end