interactor-validation 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +1179 -44
- data/lib/interactor/validation/configuration.rb +6 -2
- data/lib/interactor/validation/validates.rb +154 -16
- data/lib/interactor/validation/version.rb +1 -1
- data/lib/interactor/validation.rb +8 -2
- metadata +5 -6
|
@@ -4,16 +4,20 @@ module Interactor
|
|
|
4
4
|
module Validation
|
|
5
5
|
# Configuration class for interactor validation behavior
|
|
6
6
|
class Configuration
|
|
7
|
-
attr_accessor :
|
|
7
|
+
attr_accessor :halt, :regex_timeout, :max_array_size,
|
|
8
8
|
:enable_instrumentation, :cache_regex_patterns
|
|
9
9
|
attr_reader :error_mode
|
|
10
10
|
|
|
11
|
+
# Backward compatibility alias for halt_on_first_error
|
|
12
|
+
alias halt_on_first_error halt
|
|
13
|
+
alias halt_on_first_error= halt=
|
|
14
|
+
|
|
11
15
|
# Available error modes:
|
|
12
16
|
# - :default - Uses ActiveModel-style human-readable messages [DEFAULT]
|
|
13
17
|
# - :code - Returns structured error codes (e.g., USERNAME_IS_REQUIRED)
|
|
14
18
|
def initialize
|
|
15
19
|
@error_mode = :default
|
|
16
|
-
@
|
|
20
|
+
@halt = false
|
|
17
21
|
@regex_timeout = 0.1 # 100ms timeout for regex matching (ReDoS protection)
|
|
18
22
|
@max_array_size = 1000 # Maximum array size for nested validation (memory protection)
|
|
19
23
|
@enable_instrumentation = false # ActiveSupport::Notifications instrumentation
|
|
@@ -21,9 +21,11 @@ module Interactor
|
|
|
21
21
|
|
|
22
22
|
def self.included(base)
|
|
23
23
|
super
|
|
24
|
-
# Include ActiveModel::Validations first, then prepend our
|
|
24
|
+
# Include ActiveModel::Validations first, then prepend our overrides
|
|
25
25
|
base.include ActiveModel::Validations unless base.included_modules.include?(ActiveModel::Validations)
|
|
26
26
|
base.singleton_class.prepend(ClassMethodsOverride)
|
|
27
|
+
# Prepend our instance method overrides to take precedence over ActiveModel
|
|
28
|
+
base.prepend(InstanceMethodsOverride)
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
module ClassMethodsOverride
|
|
@@ -88,22 +90,126 @@ module Interactor
|
|
|
88
90
|
end
|
|
89
91
|
end
|
|
90
92
|
|
|
93
|
+
# Wrapper for ActiveModel::Errors that intercepts halt: option
|
|
94
|
+
class ErrorsWrapper < SimpleDelegator
|
|
95
|
+
def initialize(errors, interactor)
|
|
96
|
+
super(errors)
|
|
97
|
+
@interactor = interactor
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Override add to intercept halt: option
|
|
101
|
+
def add(attribute, message = :invalid, options = {})
|
|
102
|
+
# Extract halt option before passing to ActiveModel
|
|
103
|
+
halt = options.delete(:halt)
|
|
104
|
+
|
|
105
|
+
# Call the original add method
|
|
106
|
+
__getobj__.add(attribute, message, **options)
|
|
107
|
+
|
|
108
|
+
# Set halt flag if requested
|
|
109
|
+
@interactor.instance_variable_set(:@halt_validation, true) if halt
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Module prepended to override ActiveModel instance methods
|
|
114
|
+
module InstanceMethodsOverride
|
|
115
|
+
# Override errors to return a wrapper that intercepts halt: option
|
|
116
|
+
def errors
|
|
117
|
+
@errors ||= ErrorsWrapper.new(super, self)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Override ActiveModel's validate! to prevent exception-raising behavior
|
|
121
|
+
# This hook is called automatically after validate_params!
|
|
122
|
+
# Users can override this to add custom validation logic
|
|
123
|
+
# @return [void]
|
|
124
|
+
# @example
|
|
125
|
+
# def validate!
|
|
126
|
+
# super # Optional: call parent implementation if needed
|
|
127
|
+
# errors.add(:base, "Custom error") if some_condition?
|
|
128
|
+
# end
|
|
129
|
+
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
130
|
+
def validate!
|
|
131
|
+
# Preserve errors from validate_params! before calling super
|
|
132
|
+
# because super might trigger ActiveModel's valid? which clears errors
|
|
133
|
+
existing_error_details = errors.map do |error|
|
|
134
|
+
{ attribute: error.attribute, type: error.type, options: error.options }
|
|
135
|
+
rescue ArgumentError => e
|
|
136
|
+
# For anonymous classes, accessing error properties may fail
|
|
137
|
+
raise unless e.message.include?("Class name cannot be blank")
|
|
138
|
+
|
|
139
|
+
{ attribute: error.attribute, type: :invalid, options: {} }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Skip custom validations if halt was requested
|
|
143
|
+
unless @halt_validation
|
|
144
|
+
# Call super to allow class's validate! to run and add custom errors
|
|
145
|
+
# Rescue exceptions that might be raised
|
|
146
|
+
begin
|
|
147
|
+
super
|
|
148
|
+
rescue NoMethodError
|
|
149
|
+
# No parent validate! method, which is fine
|
|
150
|
+
rescue ArgumentError => e
|
|
151
|
+
# For anonymous classes, super may fail when generating messages
|
|
152
|
+
raise unless e.message.include?("Class name cannot be blank")
|
|
153
|
+
rescue ActiveModel::ValidationError
|
|
154
|
+
# ActiveModel's validate! raises this when there are errors
|
|
155
|
+
# We handle errors differently, so just ignore this exception
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Restore errors from validate_params! and add any new ones from custom validate!
|
|
160
|
+
existing_error_details.each do |error_detail|
|
|
161
|
+
# Only add if not already present (avoid duplicates)
|
|
162
|
+
next if errors.where(error_detail[:attribute], error_detail[:type]).any?
|
|
163
|
+
|
|
164
|
+
begin
|
|
165
|
+
errors.add(error_detail[:attribute], error_detail[:type], **error_detail[:options])
|
|
166
|
+
rescue ArgumentError => e
|
|
167
|
+
# For anonymous classes, fall back to adding with message directly
|
|
168
|
+
raise unless e.message.include?("Class name cannot be blank")
|
|
169
|
+
|
|
170
|
+
message = error_detail[:options][:message] || error_detail[:type].to_s
|
|
171
|
+
errors.add(error_detail[:attribute], message)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Reset halt flag after validation completes
|
|
176
|
+
@halt_validation = false
|
|
177
|
+
|
|
178
|
+
# Check all accumulated errors from validate_params! and this hook
|
|
179
|
+
# Fail the context if any errors exist
|
|
180
|
+
return if errors.empty?
|
|
181
|
+
|
|
182
|
+
# Use the existing formatted_errors method which handles all the edge cases
|
|
183
|
+
context.fail!(errors: formatted_errors)
|
|
184
|
+
end
|
|
185
|
+
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
186
|
+
end
|
|
187
|
+
|
|
91
188
|
private
|
|
92
189
|
|
|
93
190
|
# Validates all declared parameters before execution
|
|
191
|
+
# Accumulates errors but does not fail the context
|
|
192
|
+
# The validate! hook will fail the context if there are any errors
|
|
94
193
|
# @return [void]
|
|
95
|
-
# @raise [Interactor::Failure] if validation fails
|
|
96
194
|
def validate_params!
|
|
97
195
|
# Memoize config for performance
|
|
98
196
|
@current_config = current_config
|
|
197
|
+
# Initialize halt flag (but don't reset if already set)
|
|
198
|
+
@halt_validation ||= false
|
|
99
199
|
|
|
100
200
|
# Instrument validation if enabled
|
|
101
201
|
instrument("validate_params.interactor_validation") do
|
|
102
202
|
# Trigger ActiveModel validations first (validate callbacks)
|
|
103
203
|
# This runs any custom validations defined with validate :method_name
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
204
|
+
begin
|
|
205
|
+
valid?
|
|
206
|
+
rescue ArgumentError => e
|
|
207
|
+
# For anonymous classes, valid? may fail when generating messages
|
|
208
|
+
raise unless e.message.include?("Class name cannot be blank")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Check if halt was requested during ActiveModel validations
|
|
212
|
+
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
107
213
|
|
|
108
214
|
# Run our custom param validations after ActiveModel validations
|
|
109
215
|
self.class._param_validations.each do |param_name, rules|
|
|
@@ -111,16 +217,16 @@ module Interactor
|
|
|
111
217
|
value = context.respond_to?(param_name) ? context.public_send(param_name) : nil
|
|
112
218
|
validate_param(param_name, value, rules)
|
|
113
219
|
|
|
114
|
-
# Halt on first error if configured
|
|
115
|
-
break if @current_config.
|
|
220
|
+
# Halt on first error if configured or if halt was explicitly requested
|
|
221
|
+
break if @halt_validation || (@current_config.halt && errors.any?)
|
|
116
222
|
end
|
|
117
223
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
context.fail!(errors: formatted_errors)
|
|
224
|
+
# Don't fail here - let validate! hook handle failure
|
|
225
|
+
# This allows validate! to run and add additional custom errors
|
|
121
226
|
end
|
|
122
227
|
ensure
|
|
123
228
|
@current_config = nil # Clear memoization
|
|
229
|
+
# Don't reset @halt_validation here - let validate! handle it
|
|
124
230
|
end
|
|
125
231
|
|
|
126
232
|
# Get the current configuration (instance config overrides global config)
|
|
@@ -142,6 +248,7 @@ module Interactor
|
|
|
142
248
|
end
|
|
143
249
|
|
|
144
250
|
# Validates a single parameter with the given rules
|
|
251
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
145
252
|
def validate_param(param_name, value, rules)
|
|
146
253
|
# Skip validation if explicitly marked
|
|
147
254
|
return if rules[:_skip]
|
|
@@ -152,14 +259,25 @@ module Interactor
|
|
|
152
259
|
return
|
|
153
260
|
end
|
|
154
261
|
|
|
155
|
-
# Standard validations
|
|
262
|
+
# Standard validations - halt if requested or configured after each validation
|
|
156
263
|
validate_presence(param_name, value, rules)
|
|
264
|
+
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
265
|
+
|
|
157
266
|
validate_boolean(param_name, value, rules)
|
|
267
|
+
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
268
|
+
|
|
158
269
|
validate_format(param_name, value, rules)
|
|
270
|
+
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
271
|
+
|
|
159
272
|
validate_length(param_name, value, rules)
|
|
273
|
+
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
274
|
+
|
|
160
275
|
validate_inclusion(param_name, value, rules)
|
|
276
|
+
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
277
|
+
|
|
161
278
|
validate_numericality(param_name, value, rules)
|
|
162
279
|
end
|
|
280
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
163
281
|
|
|
164
282
|
# Validates nested attributes in a hash or array
|
|
165
283
|
def validate_nested(param_name, value, nested_rules)
|
|
@@ -350,7 +468,7 @@ module Interactor
|
|
|
350
468
|
|
|
351
469
|
# Add error for nested validation
|
|
352
470
|
# rubocop:disable Metrics/ParameterLists
|
|
353
|
-
def add_nested_error(param_name, attr_name, custom_message, error_type, index: nil, **interpolations)
|
|
471
|
+
def add_nested_error(param_name, attr_name, custom_message, error_type, index: nil, halt: false, **interpolations)
|
|
354
472
|
# Build the attribute path for the error
|
|
355
473
|
attribute_path = if index.nil?
|
|
356
474
|
# Hash validation: param_name.attr_name
|
|
@@ -370,6 +488,9 @@ module Interactor
|
|
|
370
488
|
else
|
|
371
489
|
errors.add(attribute_path, error_type, **interpolations)
|
|
372
490
|
end
|
|
491
|
+
|
|
492
|
+
# Set halt flag if requested
|
|
493
|
+
@halt_validation = true if halt
|
|
373
494
|
end
|
|
374
495
|
# rubocop:enable Metrics/ParameterLists
|
|
375
496
|
|
|
@@ -562,8 +683,9 @@ module Interactor
|
|
|
562
683
|
# @param param_name [Symbol] the parameter name
|
|
563
684
|
# @param custom_message [String, nil] custom error message if provided
|
|
564
685
|
# @param error_type [Symbol] the type of validation error
|
|
686
|
+
# @param halt [Boolean] if true, halts validation immediately after adding this error
|
|
565
687
|
# @param interpolations [Hash] values to interpolate into the message
|
|
566
|
-
def add_error(param_name, custom_message, error_type, **interpolations)
|
|
688
|
+
def add_error(param_name, custom_message, error_type, halt: false, **interpolations)
|
|
567
689
|
if current_config.error_mode == :code
|
|
568
690
|
# Code mode: use custom message or generate code
|
|
569
691
|
code_message = custom_message || error_code_for(error_type, **interpolations)
|
|
@@ -574,6 +696,9 @@ module Interactor
|
|
|
574
696
|
else
|
|
575
697
|
errors.add(param_name, error_type, **interpolations)
|
|
576
698
|
end
|
|
699
|
+
|
|
700
|
+
# Set halt flag if requested
|
|
701
|
+
@halt_validation = true if halt
|
|
577
702
|
end
|
|
578
703
|
|
|
579
704
|
# Generate error code for :code mode using constants
|
|
@@ -628,9 +753,14 @@ module Interactor
|
|
|
628
753
|
errors.map do |error|
|
|
629
754
|
# Convert attribute path to uppercase, handling nested paths
|
|
630
755
|
# Example: "attributes.username" -> "ATTRIBUTES.USERNAME"
|
|
631
|
-
# Example: "attributes[0].username" -> "ATTRIBUTES[0]
|
|
756
|
+
# Example: "attributes[0].username" -> "ATTRIBUTES[0]_USERNAME"
|
|
632
757
|
param_name = format_attribute_for_code(error.attribute)
|
|
633
|
-
|
|
758
|
+
begin
|
|
759
|
+
message = error.message
|
|
760
|
+
rescue ArgumentError, NoMethodError
|
|
761
|
+
# For anonymous classes or other edge cases, fall back to type
|
|
762
|
+
message = error.type.to_s.upcase
|
|
763
|
+
end
|
|
634
764
|
|
|
635
765
|
{ code: "#{param_name}_#{message}" }
|
|
636
766
|
end
|
|
@@ -672,7 +802,15 @@ module Interactor
|
|
|
672
802
|
"#{attribute_name} #{error_message}"
|
|
673
803
|
elsif error.respond_to?(:message)
|
|
674
804
|
# Try to use ActiveModel's message for simple attributes
|
|
675
|
-
error.message
|
|
805
|
+
message = error.message
|
|
806
|
+
# If translation is missing, fall back to our default messages
|
|
807
|
+
if message.include?("Translation missing")
|
|
808
|
+
attribute_name = error.attribute.to_s.humanize
|
|
809
|
+
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
810
|
+
"#{attribute_name} #{error_message}"
|
|
811
|
+
else
|
|
812
|
+
message
|
|
813
|
+
end
|
|
676
814
|
end
|
|
677
815
|
rescue ArgumentError, NoMethodError
|
|
678
816
|
# Fallback for anonymous classes or other issues
|
|
@@ -38,15 +38,21 @@ module Interactor
|
|
|
38
38
|
|
|
39
39
|
def self.included(base)
|
|
40
40
|
super
|
|
41
|
-
# Set up the validation
|
|
41
|
+
# Set up the validation hooks after all modules are included
|
|
42
42
|
# Use class_eval to ensure we're in the right context
|
|
43
43
|
base.class_eval do
|
|
44
|
+
# Register both validate_params! and validate! hooks
|
|
45
|
+
# Parameter validations run first, then custom validate! hook
|
|
44
46
|
before :validate_params! if respond_to?(:before)
|
|
47
|
+
before :validate! if respond_to?(:before)
|
|
45
48
|
|
|
46
|
-
# Set up inherited hook to ensure child classes also get the before
|
|
49
|
+
# Set up inherited hook to ensure child classes also get the before hooks
|
|
47
50
|
def self.inherited(subclass)
|
|
48
51
|
super
|
|
49
52
|
subclass.before :validate_params! if subclass.respond_to?(:before)
|
|
53
|
+
subclass.before :validate! if subclass.respond_to?(:before)
|
|
54
|
+
# Also prepend InstanceMethodsOverride to child classes
|
|
55
|
+
subclass.prepend(Interactor::Validation::Validates::InstanceMethodsOverride)
|
|
50
56
|
end
|
|
51
57
|
end
|
|
52
58
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: interactor-validation
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Wilson Anciro
|
|
@@ -13,28 +13,28 @@ dependencies:
|
|
|
13
13
|
name: activemodel
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '6.0'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
|
-
- - "
|
|
23
|
+
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '6.0'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: activesupport
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
|
-
- - "
|
|
30
|
+
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
32
|
version: '6.0'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
|
-
- - "
|
|
37
|
+
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '6.0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
@@ -133,7 +133,6 @@ licenses:
|
|
|
133
133
|
- MIT
|
|
134
134
|
metadata:
|
|
135
135
|
homepage_uri: https://github.com/zyxzen/interactor-validation
|
|
136
|
-
source_code_uri: https://github.com/zyxzen/interactor-validation
|
|
137
136
|
changelog_uri: https://github.com/zyxzen/interactor-validation/blob/main/CHANGELOG.md
|
|
138
137
|
rdoc_options: []
|
|
139
138
|
require_paths:
|