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.
@@ -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
- # rubocop:disable Metrics/ModuleLength
6
- 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
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
- 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)
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
- 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]
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
- private
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
- # Builder class for nested validation DSL
84
- class NestedValidationBuilder
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
- # 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
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
- # Module prepended to override ActiveModel instance methods
121
- module InstanceMethodsOverride
122
- # Override errors to return a wrapper that intercepts halt: option
123
- def errors
124
- @errors ||= ErrorsWrapper.new(super, self)
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
- # 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
- 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
- # 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)
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
- 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
66
+ def configure
67
+ # Ensure we have our own copy of config before modifying
217
68
  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?)
69
+ self._validation_config = _validation_config.dup if _validation_config.equal?(superclass._validation_config)
70
+ rescue StandardError
71
+ false
235
72
  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
-
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
- # 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
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
- # 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
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
- # Validate length
415
- validate_nested_length(param_name, attr_name, value, rules[:length], index: index) if rules[:length]
97
+ private
416
98
 
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)
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
- # 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
106
+ class NestedBuilder
107
+ attr_reader :rules
437
108
 
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)
109
+ def initialize
110
+ @rules = {}
442
111
  end
443
112
 
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)
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
- # 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
466
- end
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
- 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)
131
+ def errors
132
+ @errors ||= Errors.new(halt_checker: -> { validation_config(:halt) })
525
133
  end
526
134
 
527
- # NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
528
- end
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
- Timeout.timeout(current_config.regex_timeout) do
595
- value.match?(cached_pattern)
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
- 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] : {}
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
- unless numeric?(value)
651
- message = extract_message(numeric_rules, :not_a_number)
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
- 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
159
+ private
677
160
 
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
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
- 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
169
+ # Standard validations
170
+ validate_presence(param, value, rules[:presence]) if rules[:presence]
171
+ return unless value.present?
688
172
 
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])
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
- 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])
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
- 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)
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
- # 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
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
- { 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)
202
+ def format_errors_as_default
203
+ errors.map do |err|
805
204
  {
806
- attribute: error.attribute,
807
- type: error.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
- # 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
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
- # 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"
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