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