interactor-validation 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +70 -0
- data/benchmark/validation_benchmark.rb +227 -0
- data/lib/interactor/validation/configuration.rb +6 -1
- data/lib/interactor/validation/error_codes.rb +51 -0
- data/lib/interactor/validation/validates.rb +460 -44
- data/lib/interactor/validation/version.rb +1 -1
- data/lib/interactor/validation.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc137c0e8cdc24b828f1e2e1369f4527908b48d328df61e994e64ad736dbf59e
|
|
4
|
+
data.tar.gz: b5c1bddd3dd946662b1394bbd5126aa8361798b59743f9f0da661bcf37166f37
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be6bb4b7fe4580208395dd82989f00de651b08bb90a5de35197307d2c9e4f2caff0b41b508a2b3ed973919fdf7bc03aa0c8a953cba9ea86dacc17ea5885cbb4e
|
|
7
|
+
data.tar.gz: 14128318e687a03b13dc1bd0c3144f2db560ec2f2ed039289ee3c87d4726f5784720a72184d7693cbf55180b43782e405a7282f52e1b4a8f4311048991993bcf
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2024-11-16
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
|
|
7
|
+
- **CRITICAL:** Added ReDoS (Regular Expression Denial of Service) protection with configurable timeout
|
|
8
|
+
- **HIGH:** Fixed thread safety issues in validation rule registration using mutex locks
|
|
9
|
+
- Added memory protection with configurable maximum array size for nested validations
|
|
10
|
+
- Added regex pattern caching to prevent recompilation attacks
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- Fixed numeric precision loss bug - now uses `to_i` for integers and `to_f` for floats
|
|
15
|
+
- Fixed ambiguous handling of `nil` vs missing hash keys in nested validation
|
|
16
|
+
- Improved boolean validation to properly distinguish between `nil`, `false`, and missing values
|
|
17
|
+
|
|
18
|
+
### Performance Improvements
|
|
19
|
+
|
|
20
|
+
- Implemented regex pattern caching for up to 10x performance improvement on repeated validations
|
|
21
|
+
- Added configuration memoization during validation to reduce overhead
|
|
22
|
+
- Optimized string-to-numeric coercion to preserve integer precision
|
|
23
|
+
|
|
24
|
+
### New Features
|
|
25
|
+
|
|
26
|
+
- Added `config.regex_timeout` - Configurable timeout for regex validation (default: 100ms)
|
|
27
|
+
- Added `config.max_array_size` - Maximum array size for nested validation (default: 1000)
|
|
28
|
+
- Added `config.enable_instrumentation` - ActiveSupport::Notifications integration for monitoring
|
|
29
|
+
- Added `config.cache_regex_patterns` - Enable/disable regex pattern caching (default: true)
|
|
30
|
+
- Created `ErrorCodes` module with constants for all error types
|
|
31
|
+
- Added comprehensive YARD documentation for public API methods
|
|
32
|
+
|
|
33
|
+
### Documentation
|
|
34
|
+
|
|
35
|
+
- Added SECURITY.md with vulnerability reporting process and security best practices
|
|
36
|
+
- Added benchmark suite in `benchmark/validation_benchmark.rb`
|
|
37
|
+
- Enhanced inline documentation with YARD tags for all public methods
|
|
38
|
+
- Improved code organization by extracting error codes into separate module
|
|
39
|
+
|
|
40
|
+
### Breaking Changes
|
|
41
|
+
|
|
42
|
+
None - this release is fully backward compatible with 0.1.x
|
|
43
|
+
|
|
44
|
+
### Upgrade Notes
|
|
45
|
+
|
|
46
|
+
To take advantage of the new security features, no changes are required. However, you may want to configure:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
Interactor::Validation.configure do |config|
|
|
50
|
+
config.regex_timeout = 0.05 # Stricter timeout for high-security contexts
|
|
51
|
+
config.max_array_size = 100 # Lower limit for your use case
|
|
52
|
+
config.enable_instrumentation = true # Monitor validation performance
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## [0.1.1] - 2025-11-16
|
|
57
|
+
|
|
58
|
+
- Minor version bump for release preparation
|
|
59
|
+
|
|
3
60
|
## [0.1.0] - 2025-11-16
|
|
4
61
|
|
|
5
62
|
- Initial release
|
data/README.md
CHANGED
|
@@ -139,6 +139,18 @@ Interactor::Validation.configure do |config|
|
|
|
139
139
|
|
|
140
140
|
# Stop validation at first error (default: false)
|
|
141
141
|
config.halt_on_first_error = false
|
|
142
|
+
|
|
143
|
+
# Security: Regex timeout in seconds (default: 0.1)
|
|
144
|
+
config.regex_timeout = 0.1
|
|
145
|
+
|
|
146
|
+
# Security: Maximum array size for nested validation (default: 1000)
|
|
147
|
+
config.max_array_size = 1000
|
|
148
|
+
|
|
149
|
+
# Performance: Cache compiled regex patterns (default: true)
|
|
150
|
+
config.cache_regex_patterns = true
|
|
151
|
+
|
|
152
|
+
# Monitoring: Enable ActiveSupport::Notifications (default: false)
|
|
153
|
+
config.enable_instrumentation = false
|
|
142
154
|
end
|
|
143
155
|
```
|
|
144
156
|
|
|
@@ -221,6 +233,64 @@ class CreateUser
|
|
|
221
233
|
end
|
|
222
234
|
```
|
|
223
235
|
|
|
236
|
+
## Security
|
|
237
|
+
|
|
238
|
+
This gem includes built-in protection against common security vulnerabilities:
|
|
239
|
+
|
|
240
|
+
### ReDoS Protection (v0.2.0+)
|
|
241
|
+
|
|
242
|
+
Regular Expression Denial of Service attacks are prevented with automatic timeouts:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
config.regex_timeout = 0.1 # 100ms default timeout
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
If a regex takes longer than the configured timeout, validation will fail safely instead of hanging.
|
|
249
|
+
|
|
250
|
+
### Memory Protection (v0.2.0+)
|
|
251
|
+
|
|
252
|
+
Array validation includes automatic size limits to prevent memory exhaustion:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
config.max_array_size = 1000 # Default limit
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Thread Safety (v0.2.0+)
|
|
259
|
+
|
|
260
|
+
Validation rule registration is thread-safe and can be used safely in multi-threaded environments (Puma, Sidekiq).
|
|
261
|
+
|
|
262
|
+
### Best Practices
|
|
263
|
+
|
|
264
|
+
1. **Use simple regex patterns** - Avoid nested quantifiers that can cause backtracking
|
|
265
|
+
2. **Sanitize outputs** - Always escape error messages when rendering in HTML
|
|
266
|
+
3. **Set appropriate limits** - Configure `max_array_size` based on your application needs
|
|
267
|
+
4. **Monitor performance** - Enable instrumentation in production to detect slow validations
|
|
268
|
+
|
|
269
|
+
For detailed security information, see [SECURITY.md](SECURITY.md).
|
|
270
|
+
|
|
271
|
+
## Performance
|
|
272
|
+
|
|
273
|
+
### Benchmarking
|
|
274
|
+
|
|
275
|
+
Run the included benchmark suite to measure performance:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
bundle exec ruby benchmark/validation_benchmark.rb
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Monitoring
|
|
282
|
+
|
|
283
|
+
Enable instrumentation to track validation performance in production:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
config.enable_instrumentation = true
|
|
287
|
+
|
|
288
|
+
ActiveSupport::Notifications.subscribe('validate_params.interactor_validation') do |*args|
|
|
289
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
290
|
+
Rails.logger.info "Validation took #{event.duration}ms for #{event.payload[:interactor]}"
|
|
291
|
+
end
|
|
292
|
+
```
|
|
293
|
+
|
|
224
294
|
## Development
|
|
225
295
|
|
|
226
296
|
```bash
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "benchmark/ips"
|
|
5
|
+
require "interactor"
|
|
6
|
+
require "interactor/validation"
|
|
7
|
+
|
|
8
|
+
# Benchmark different validation scenarios
|
|
9
|
+
puts "Interactor::Validation Performance Benchmarks"
|
|
10
|
+
puts "=" * 60
|
|
11
|
+
|
|
12
|
+
# Setup test interactors
|
|
13
|
+
class SimplePresenceInteractor
|
|
14
|
+
include Interactor
|
|
15
|
+
include Interactor::Validation
|
|
16
|
+
|
|
17
|
+
params :username, :email
|
|
18
|
+
|
|
19
|
+
validates :username, presence: true
|
|
20
|
+
validates :email, presence: true
|
|
21
|
+
|
|
22
|
+
def call; end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class ComplexValidationInteractor
|
|
26
|
+
include Interactor
|
|
27
|
+
include Interactor::Validation
|
|
28
|
+
|
|
29
|
+
params :username, :email, :age, :bio
|
|
30
|
+
|
|
31
|
+
validates :username, presence: true, length: { minimum: 3, maximum: 20 }
|
|
32
|
+
validates :email, presence: true, format: { with: /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i }
|
|
33
|
+
validates :age, numericality: { greater_than: 0, less_than: 150 }
|
|
34
|
+
validates :bio, length: { maximum: 500 }
|
|
35
|
+
|
|
36
|
+
def call; end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class NestedValidationInteractor
|
|
40
|
+
include Interactor
|
|
41
|
+
include Interactor::Validation
|
|
42
|
+
|
|
43
|
+
params :user_data
|
|
44
|
+
|
|
45
|
+
validates :user_data do |v|
|
|
46
|
+
v.attribute :name, presence: true, length: { minimum: 2 }
|
|
47
|
+
v.attribute :email, presence: true, format: { with: /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i }
|
|
48
|
+
v.attribute :age, numericality: { greater_than: 0 }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call; end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class ArrayValidationInteractor
|
|
55
|
+
include Interactor
|
|
56
|
+
include Interactor::Validation
|
|
57
|
+
|
|
58
|
+
params :items
|
|
59
|
+
|
|
60
|
+
validates :items do |v|
|
|
61
|
+
v.attribute :name, presence: true
|
|
62
|
+
v.attribute :price, numericality: { greater_than: 0 }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call; end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Valid test data
|
|
69
|
+
valid_simple_data = { username: "john_doe", email: "john@example.com" }
|
|
70
|
+
valid_complex_data = {
|
|
71
|
+
username: "john_doe",
|
|
72
|
+
email: "john@example.com",
|
|
73
|
+
age: 25,
|
|
74
|
+
bio: "Software developer"
|
|
75
|
+
}
|
|
76
|
+
valid_nested_data = {
|
|
77
|
+
user_data: {
|
|
78
|
+
name: "John Doe",
|
|
79
|
+
email: "john@example.com",
|
|
80
|
+
age: 25
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
valid_array_data = {
|
|
84
|
+
items: [
|
|
85
|
+
{ name: "Item 1", price: 10.99 },
|
|
86
|
+
{ name: "Item 2", price: 20.50 },
|
|
87
|
+
{ name: "Item 3", price: 15.75 }
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Invalid test data
|
|
92
|
+
invalid_simple_data = { username: "", email: "" }
|
|
93
|
+
|
|
94
|
+
# Benchmark: Simple presence validation
|
|
95
|
+
puts "\n1. Simple Presence Validation (2 fields)"
|
|
96
|
+
Benchmark.ips do |x|
|
|
97
|
+
x.config(time: 5, warmup: 2)
|
|
98
|
+
|
|
99
|
+
x.report("valid data") do
|
|
100
|
+
SimplePresenceInteractor.call(valid_simple_data)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
x.report("invalid data") do
|
|
104
|
+
result = SimplePresenceInteractor.call(invalid_simple_data)
|
|
105
|
+
result.failure?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
x.compare!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Benchmark: Complex multi-rule validation
|
|
112
|
+
puts "\n2. Complex Validation (4 fields, multiple rules)"
|
|
113
|
+
Benchmark.ips do |x|
|
|
114
|
+
x.config(time: 5, warmup: 2)
|
|
115
|
+
|
|
116
|
+
x.report("valid data") do
|
|
117
|
+
ComplexValidationInteractor.call(valid_complex_data)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
x.compare!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Benchmark: Nested validation
|
|
124
|
+
puts "\n3. Nested Hash Validation"
|
|
125
|
+
Benchmark.ips do |x|
|
|
126
|
+
x.config(time: 5, warmup: 2)
|
|
127
|
+
|
|
128
|
+
x.report("valid nested data") do
|
|
129
|
+
NestedValidationInteractor.call(valid_nested_data)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
x.compare!
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Benchmark: Array validation
|
|
136
|
+
puts "\n4. Array Validation (3 items)"
|
|
137
|
+
Benchmark.ips do |x|
|
|
138
|
+
x.config(time: 5, warmup: 2)
|
|
139
|
+
|
|
140
|
+
x.report("valid array") do
|
|
141
|
+
ArrayValidationInteractor.call(valid_array_data)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
x.compare!
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Benchmark: Regex caching impact
|
|
148
|
+
puts "\n5. Regex Pattern Caching (100 iterations)"
|
|
149
|
+
Benchmark.ips do |x|
|
|
150
|
+
x.config(time: 5, warmup: 2)
|
|
151
|
+
|
|
152
|
+
x.report("with caching") do
|
|
153
|
+
Interactor::Validation.configure { |c| c.cache_regex_patterns = true }
|
|
154
|
+
100.times { ComplexValidationInteractor.call(valid_complex_data) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
x.report("without caching") do
|
|
158
|
+
Interactor::Validation.configure { |c| c.cache_regex_patterns = false }
|
|
159
|
+
100.times { ComplexValidationInteractor.call(valid_complex_data) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
x.compare!
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Benchmark: Halt on first error
|
|
166
|
+
puts "\n6. Halt on First Error (invalid data, 4 fields)"
|
|
167
|
+
invalid_all_data = { username: "", email: "invalid", age: -1, bio: "a" * 600 }
|
|
168
|
+
|
|
169
|
+
Benchmark.ips do |x|
|
|
170
|
+
x.config(time: 5, warmup: 2)
|
|
171
|
+
|
|
172
|
+
x.report("halt enabled") do
|
|
173
|
+
Interactor::Validation.configure { |c| c.halt_on_first_error = true }
|
|
174
|
+
ComplexValidationInteractor.call(invalid_all_data)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
x.report("halt disabled") do
|
|
178
|
+
Interactor::Validation.configure { |c| c.halt_on_first_error = false }
|
|
179
|
+
ComplexValidationInteractor.call(invalid_all_data)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
x.compare!
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Benchmark: Error mode comparison
|
|
186
|
+
puts "\n7. Error Formatting Mode"
|
|
187
|
+
Benchmark.ips do |x|
|
|
188
|
+
x.config(time: 5, warmup: 2)
|
|
189
|
+
|
|
190
|
+
x.report("code mode") do
|
|
191
|
+
Interactor::Validation.configure { |c| c.error_mode = :code }
|
|
192
|
+
SimplePresenceInteractor.call(invalid_simple_data)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
x.report("default mode") do
|
|
196
|
+
Interactor::Validation.configure { |c| c.error_mode = :default }
|
|
197
|
+
SimplePresenceInteractor.call(invalid_simple_data)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
x.compare!
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Benchmark: Large array validation
|
|
204
|
+
puts "\n8. Large Array Validation (100 items)"
|
|
205
|
+
large_array_data = {
|
|
206
|
+
items: Array.new(100) { |i| { name: "Item #{i}", price: rand(1.0..100.0).round(2) } }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
Benchmark.ips do |x|
|
|
210
|
+
x.config(time: 5, warmup: 2)
|
|
211
|
+
|
|
212
|
+
x.report("100 items") do
|
|
213
|
+
ArrayValidationInteractor.call(large_array_data)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
x.compare!
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Reset configuration
|
|
220
|
+
Interactor::Validation.reset_configuration!
|
|
221
|
+
|
|
222
|
+
puts "\n#{"=" * 60}"
|
|
223
|
+
puts "Benchmarks complete!"
|
|
224
|
+
puts "\nTo run these benchmarks:"
|
|
225
|
+
puts " bundle exec ruby benchmark/validation_benchmark.rb"
|
|
226
|
+
puts "\nTo add benchmark-ips to your Gemfile:"
|
|
227
|
+
puts " gem 'benchmark-ips', '~> 2.0', group: :development"
|
|
@@ -4,7 +4,8 @@ module Interactor
|
|
|
4
4
|
module Validation
|
|
5
5
|
# Configuration class for interactor validation behavior
|
|
6
6
|
class Configuration
|
|
7
|
-
attr_accessor :halt_on_first_error
|
|
7
|
+
attr_accessor :halt_on_first_error, :regex_timeout, :max_array_size,
|
|
8
|
+
:enable_instrumentation, :cache_regex_patterns
|
|
8
9
|
attr_reader :error_mode
|
|
9
10
|
|
|
10
11
|
# Available error modes:
|
|
@@ -13,6 +14,10 @@ module Interactor
|
|
|
13
14
|
def initialize
|
|
14
15
|
@error_mode = :code
|
|
15
16
|
@halt_on_first_error = false
|
|
17
|
+
@regex_timeout = 0.1 # 100ms timeout for regex matching (ReDoS protection)
|
|
18
|
+
@max_array_size = 1000 # Maximum array size for nested validation (memory protection)
|
|
19
|
+
@enable_instrumentation = false # ActiveSupport::Notifications instrumentation
|
|
20
|
+
@cache_regex_patterns = true # Cache compiled regex patterns for performance
|
|
16
21
|
end
|
|
17
22
|
|
|
18
23
|
def error_mode=(mode)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Interactor
|
|
4
|
+
module Validation
|
|
5
|
+
# Error code constants for structured error messages
|
|
6
|
+
module ErrorCodes
|
|
7
|
+
REQUIRED = "IS_REQUIRED"
|
|
8
|
+
MUST_BE_BOOLEAN = "MUST_BE_BOOLEAN"
|
|
9
|
+
INVALID_FORMAT = "INVALID_FORMAT"
|
|
10
|
+
NOT_IN_ALLOWED_VALUES = "NOT_IN_ALLOWED_VALUES"
|
|
11
|
+
MUST_BE_A_NUMBER = "MUST_BE_A_NUMBER"
|
|
12
|
+
INVALID_TYPE = "INVALID_TYPE"
|
|
13
|
+
REGEX_TIMEOUT = "REGEX_TIMEOUT"
|
|
14
|
+
ARRAY_TOO_LARGE = "ARRAY_TOO_LARGE"
|
|
15
|
+
|
|
16
|
+
# Generate length error codes
|
|
17
|
+
def self.exceeds_max_length(count)
|
|
18
|
+
"EXCEEDS_MAX_LENGTH_#{count}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.below_min_length(count)
|
|
22
|
+
"BELOW_MIN_LENGTH_#{count}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.must_be_length(count)
|
|
26
|
+
"MUST_BE_LENGTH_#{count}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generate numeric comparison error codes
|
|
30
|
+
def self.must_be_greater_than(count)
|
|
31
|
+
"MUST_BE_GREATER_THAN_#{count}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.must_be_at_least(count)
|
|
35
|
+
"MUST_BE_AT_LEAST_#{count}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.must_be_less_than(count)
|
|
39
|
+
"MUST_BE_LESS_THAN_#{count}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.must_be_at_most(count)
|
|
43
|
+
"MUST_BE_AT_MOST_#{count}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.must_be_equal_to(count)
|
|
47
|
+
"MUST_BE_EQUAL_TO_#{count}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Interactor
|
|
4
4
|
module Validation
|
|
5
|
+
# rubocop:disable Metrics/ModuleLength
|
|
5
6
|
module Validates
|
|
6
7
|
extend ActiveSupport::Concern
|
|
7
8
|
|
|
8
9
|
included do
|
|
9
10
|
class_attribute :_param_validations, instance_writer: false, default: {}
|
|
11
|
+
# Regex pattern cache for performance
|
|
12
|
+
class_attribute :_regex_cache, instance_writer: false, default: {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Class-level mutex for thread-safe validation updates
|
|
16
|
+
@validations_mutex = Mutex.new
|
|
17
|
+
|
|
18
|
+
def self.validations_mutex
|
|
19
|
+
@validations_mutex ||= Mutex.new
|
|
10
20
|
end
|
|
11
21
|
|
|
12
22
|
def self.included(base)
|
|
@@ -19,75 +29,431 @@ module Interactor
|
|
|
19
29
|
module ClassMethodsOverride
|
|
20
30
|
# Override ActiveModel's validates to handle our simple validation rules
|
|
21
31
|
# Falls back to ActiveModel's validates for complex cases
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
# @param param_name [Symbol] the parameter name to validate
|
|
33
|
+
# @param rules [Hash] validation rules (presence, format, length, etc.)
|
|
34
|
+
# @yield [NestedValidationBuilder] optional block for nested validation DSL
|
|
35
|
+
# @return [void]
|
|
36
|
+
def validates(param_name, **rules, &)
|
|
37
|
+
# Thread-safe validation rule updates
|
|
38
|
+
Validates.validations_mutex.synchronize do
|
|
39
|
+
# If block is provided, this is nested validation
|
|
40
|
+
if block_given?
|
|
41
|
+
nested_rules = build_nested_rules(&)
|
|
42
|
+
current_validations = _param_validations.dup
|
|
43
|
+
existing_rules = current_validations[param_name] || {}
|
|
44
|
+
self._param_validations = current_validations.merge(
|
|
45
|
+
param_name => existing_rules.merge(_nested: nested_rules)
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# If no keyword arguments and no block, mark as skip validation
|
|
51
|
+
if rules.empty?
|
|
52
|
+
current_validations = _param_validations.dup
|
|
53
|
+
existing_rules = current_validations[param_name] || {}
|
|
54
|
+
self._param_validations = current_validations.merge(
|
|
55
|
+
param_name => existing_rules.merge(_skip: true)
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Merge validation rules for the same param, ensuring we don't modify parent's hash
|
|
61
|
+
current_validations = _param_validations.dup
|
|
62
|
+
existing_rules = current_validations[param_name] || {}
|
|
63
|
+
self._param_validations = current_validations.merge(param_name => existing_rules.merge(rules))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
25
68
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
69
|
+
# Build nested validation rules from a block
|
|
70
|
+
def build_nested_rules(&)
|
|
71
|
+
builder = NestedValidationBuilder.new
|
|
72
|
+
builder.instance_eval(&)
|
|
73
|
+
builder.rules
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Builder class for nested validation DSL
|
|
78
|
+
class NestedValidationBuilder
|
|
79
|
+
attr_reader :rules
|
|
80
|
+
|
|
81
|
+
def initialize
|
|
82
|
+
@rules = {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Define validation for a nested attribute
|
|
86
|
+
def attribute(attr_name, **validations)
|
|
87
|
+
@rules[attr_name] = validations
|
|
30
88
|
end
|
|
31
89
|
end
|
|
32
90
|
|
|
33
91
|
private
|
|
34
92
|
|
|
35
93
|
# Validates all declared parameters before execution
|
|
94
|
+
# @return [void]
|
|
95
|
+
# @raise [Interactor::Failure] if validation fails
|
|
36
96
|
def validate_params!
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
|
|
97
|
+
# Memoize config for performance
|
|
98
|
+
@current_config = current_config
|
|
99
|
+
|
|
100
|
+
# Instrument validation if enabled
|
|
101
|
+
instrument("validate_params.interactor_validation") do
|
|
102
|
+
# Trigger ActiveModel validations first (validate callbacks)
|
|
103
|
+
# This runs any custom validations defined with validate :method_name
|
|
104
|
+
# NOTE: valid? must be called BEFORE adding our custom errors
|
|
105
|
+
# because it clears the errors object
|
|
106
|
+
valid?
|
|
107
|
+
|
|
108
|
+
# Run our custom param validations after ActiveModel validations
|
|
109
|
+
self.class._param_validations.each do |param_name, rules|
|
|
110
|
+
# Safe param access - returns nil if not present in context
|
|
111
|
+
value = context.respond_to?(param_name) ? context.public_send(param_name) : nil
|
|
112
|
+
validate_param(param_name, value, rules)
|
|
113
|
+
|
|
114
|
+
# Halt on first error if configured
|
|
115
|
+
break if @current_config.halt_on_first_error && errors.any?
|
|
116
|
+
end
|
|
42
117
|
|
|
43
|
-
|
|
44
|
-
self.class._param_validations.each do |param_name, rules|
|
|
45
|
-
# Safe param access - returns nil if not present in context
|
|
46
|
-
value = context.respond_to?(param_name) ? context.public_send(param_name) : nil
|
|
47
|
-
validate_param(param_name, value, rules)
|
|
118
|
+
return if errors.empty?
|
|
48
119
|
|
|
49
|
-
|
|
50
|
-
break if current_config.halt_on_first_error && errors.any?
|
|
120
|
+
context.fail!(errors: formatted_errors)
|
|
51
121
|
end
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
context.fail!(errors: formatted_errors)
|
|
122
|
+
ensure
|
|
123
|
+
@current_config = nil # Clear memoization
|
|
56
124
|
end
|
|
57
125
|
|
|
58
126
|
# Get the current configuration (instance config overrides global config)
|
|
127
|
+
# @return [Configuration] the active configuration
|
|
59
128
|
def current_config
|
|
60
|
-
self.class.validation_config || Interactor::Validation.configuration
|
|
129
|
+
@current_config || self.class.validation_config || Interactor::Validation.configuration
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Instrument a block of code if instrumentation is enabled
|
|
133
|
+
# @param event_name [String] the event name for ActiveSupport::Notifications
|
|
134
|
+
# @yield the block to instrument
|
|
135
|
+
# @return [Object] the return value of the block
|
|
136
|
+
def instrument(event_name, &)
|
|
137
|
+
if current_config.enable_instrumentation
|
|
138
|
+
ActiveSupport::Notifications.instrument(event_name, interactor: self.class.name, &)
|
|
139
|
+
else
|
|
140
|
+
yield
|
|
141
|
+
end
|
|
61
142
|
end
|
|
62
143
|
|
|
63
144
|
# Validates a single parameter with the given rules
|
|
64
145
|
def validate_param(param_name, value, rules)
|
|
146
|
+
# Skip validation if explicitly marked
|
|
147
|
+
return if rules[:_skip]
|
|
148
|
+
|
|
149
|
+
# Handle nested validation (hash or array)
|
|
150
|
+
if rules[:_nested]
|
|
151
|
+
validate_nested(param_name, value, rules[:_nested])
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Standard validations
|
|
65
156
|
validate_presence(param_name, value, rules)
|
|
157
|
+
validate_boolean(param_name, value, rules)
|
|
66
158
|
validate_format(param_name, value, rules)
|
|
67
159
|
validate_length(param_name, value, rules)
|
|
68
160
|
validate_inclusion(param_name, value, rules)
|
|
69
161
|
validate_numericality(param_name, value, rules)
|
|
70
162
|
end
|
|
71
163
|
|
|
164
|
+
# Validates nested attributes in a hash or array
|
|
165
|
+
def validate_nested(param_name, value, nested_rules)
|
|
166
|
+
if value.is_a?(Array)
|
|
167
|
+
validate_array_of_hashes(param_name, value, nested_rules)
|
|
168
|
+
elsif value.is_a?(Hash)
|
|
169
|
+
validate_hash_attributes(param_name, value, nested_rules)
|
|
170
|
+
else
|
|
171
|
+
# If value is not hash or array, add type error
|
|
172
|
+
add_nested_error(param_name, nil, nil, :invalid_type)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Validates each hash in an array
|
|
177
|
+
# @param param_name [Symbol] the parameter name
|
|
178
|
+
# @param array [Array] the array of hashes to validate
|
|
179
|
+
# @param nested_rules [Hash] validation rules for nested attributes
|
|
180
|
+
# @return [void]
|
|
181
|
+
def validate_array_of_hashes(param_name, array, nested_rules)
|
|
182
|
+
# Memory protection: limit array size
|
|
183
|
+
if array.size > current_config.max_array_size
|
|
184
|
+
add_error(param_name, ErrorCodes::ARRAY_TOO_LARGE, :too_large,
|
|
185
|
+
count: current_config.max_array_size)
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
array.each_with_index do |item, index|
|
|
190
|
+
if item.is_a?(Hash)
|
|
191
|
+
validate_hash_attributes(param_name, item, nested_rules, index: index)
|
|
192
|
+
else
|
|
193
|
+
add_nested_error(param_name, nil, nil, :invalid_type, index: index)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Validates attributes within a hash
|
|
199
|
+
# @param param_name [Symbol] the parameter name
|
|
200
|
+
# @param hash [Hash] the hash containing attributes to validate
|
|
201
|
+
# @param nested_rules [Hash] validation rules for each attribute
|
|
202
|
+
# @param index [Integer, nil] optional array index for error messages
|
|
203
|
+
# @return [void]
|
|
204
|
+
def validate_hash_attributes(param_name, hash, nested_rules, index: nil)
|
|
205
|
+
nested_rules.each do |attr_name, attr_rules|
|
|
206
|
+
# Check both symbol and string keys, handling nil/false values and missing keys properly
|
|
207
|
+
attr_value = get_nested_value(hash, attr_name)
|
|
208
|
+
validate_nested_attribute(param_name, attr_name, attr_value, attr_rules, index: index)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Get nested value from hash, distinguishing between nil and missing keys
|
|
213
|
+
# @param hash [Hash] the hash to search
|
|
214
|
+
# @param attr_name [Symbol] the attribute name
|
|
215
|
+
# @return [Object, Symbol] the value or :__missing__ sentinel
|
|
216
|
+
def get_nested_value(hash, attr_name)
|
|
217
|
+
if hash.key?(attr_name)
|
|
218
|
+
hash[attr_name]
|
|
219
|
+
elsif hash.key?(attr_name.to_s)
|
|
220
|
+
hash[attr_name.to_s]
|
|
221
|
+
else
|
|
222
|
+
:__missing__ # Sentinel value to distinguish from nil
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Validates a single nested attribute
|
|
227
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
228
|
+
def validate_nested_attribute(param_name, attr_name, value, rules, index: nil)
|
|
229
|
+
# Handle missing key sentinel
|
|
230
|
+
is_missing = value == :__missing__
|
|
231
|
+
value = nil if is_missing
|
|
232
|
+
|
|
233
|
+
# Validate presence (false is a valid present value for booleans)
|
|
234
|
+
if rules[:presence] && !value.present? && value != false
|
|
235
|
+
message = extract_message(rules[:presence], :blank)
|
|
236
|
+
add_nested_error(param_name, attr_name, message, :blank, index: index)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Validate boolean (works on all values, not just present ones)
|
|
240
|
+
# Don't validate boolean for missing keys (sentinel value)
|
|
241
|
+
if rules[:boolean] && !is_missing && !boolean?(value)
|
|
242
|
+
message = extract_message(rules[:boolean], :not_boolean)
|
|
243
|
+
add_nested_error(param_name, attr_name, message, :not_boolean, index: index)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Only run other validations if value is present (false is considered present for booleans)
|
|
247
|
+
return unless value.present? || value == false
|
|
248
|
+
|
|
249
|
+
# Validate format (with ReDoS protection)
|
|
250
|
+
if rules[:format]
|
|
251
|
+
format_options = rules[:format]
|
|
252
|
+
pattern = format_options.is_a?(Hash) ? format_options[:with] : format_options
|
|
253
|
+
|
|
254
|
+
# Safe regex matching with timeout protection
|
|
255
|
+
unless safe_regex_match?(value.to_s, pattern)
|
|
256
|
+
message = extract_message(format_options, :invalid)
|
|
257
|
+
add_nested_error(param_name, attr_name, message, :invalid, index: index)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Validate length
|
|
262
|
+
validate_nested_length(param_name, attr_name, value, rules[:length], index: index) if rules[:length]
|
|
263
|
+
|
|
264
|
+
# Validate inclusion
|
|
265
|
+
if rules[:inclusion]
|
|
266
|
+
inclusion_options = rules[:inclusion]
|
|
267
|
+
allowed_values = inclusion_options.is_a?(Hash) ? inclusion_options[:in] : inclusion_options
|
|
268
|
+
unless allowed_values.include?(value)
|
|
269
|
+
message = extract_message(inclusion_options, :inclusion)
|
|
270
|
+
add_nested_error(param_name, attr_name, message, :inclusion, index: index)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Validate numericality
|
|
275
|
+
return unless rules[:numericality]
|
|
276
|
+
|
|
277
|
+
validate_nested_numericality(param_name, attr_name, value, rules[:numericality], index: index)
|
|
278
|
+
end
|
|
279
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
280
|
+
|
|
281
|
+
# Validates length for nested attributes
|
|
282
|
+
def validate_nested_length(param_name, attr_name, value, length_rules, index: nil)
|
|
283
|
+
length = value.to_s.length
|
|
284
|
+
|
|
285
|
+
if length_rules[:maximum] && length > length_rules[:maximum]
|
|
286
|
+
message = extract_message(length_rules, :too_long, count: length_rules[:maximum])
|
|
287
|
+
add_nested_error(param_name, attr_name, message, :too_long,
|
|
288
|
+
count: length_rules[:maximum], index: index)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
if length_rules[:minimum] && length < length_rules[:minimum]
|
|
292
|
+
message = extract_message(length_rules, :too_short, count: length_rules[:minimum])
|
|
293
|
+
add_nested_error(param_name, attr_name, message, :too_short,
|
|
294
|
+
count: length_rules[:minimum], index: index)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
return unless length_rules[:is] && length != length_rules[:is]
|
|
298
|
+
|
|
299
|
+
message = extract_message(length_rules, :wrong_length, count: length_rules[:is])
|
|
300
|
+
add_nested_error(param_name, attr_name, message, :wrong_length,
|
|
301
|
+
count: length_rules[:is], index: index)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Validates numericality for nested attributes
|
|
305
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
306
|
+
def validate_nested_numericality(param_name, attr_name, value, numeric_rules, index: nil)
|
|
307
|
+
numeric_rules = {} unless numeric_rules.is_a?(Hash)
|
|
308
|
+
|
|
309
|
+
unless numeric?(value)
|
|
310
|
+
message = extract_message(numeric_rules, :not_a_number)
|
|
311
|
+
add_nested_error(param_name, attr_name, message, :not_a_number, index: index)
|
|
312
|
+
return
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
numeric_value = coerce_to_numeric(value)
|
|
316
|
+
|
|
317
|
+
if numeric_rules[:greater_than] && numeric_value <= numeric_rules[:greater_than]
|
|
318
|
+
message = extract_message(numeric_rules, :greater_than, count: numeric_rules[:greater_than])
|
|
319
|
+
add_nested_error(param_name, attr_name, message, :greater_than,
|
|
320
|
+
count: numeric_rules[:greater_than], index: index)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
if numeric_rules[:greater_than_or_equal_to] && numeric_value < numeric_rules[:greater_than_or_equal_to]
|
|
324
|
+
message = extract_message(numeric_rules, :greater_than_or_equal_to,
|
|
325
|
+
count: numeric_rules[:greater_than_or_equal_to])
|
|
326
|
+
add_nested_error(param_name, attr_name, message, :greater_than_or_equal_to,
|
|
327
|
+
count: numeric_rules[:greater_than_or_equal_to], index: index)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
if numeric_rules[:less_than] && numeric_value >= numeric_rules[:less_than]
|
|
331
|
+
message = extract_message(numeric_rules, :less_than, count: numeric_rules[:less_than])
|
|
332
|
+
add_nested_error(param_name, attr_name, message, :less_than,
|
|
333
|
+
count: numeric_rules[:less_than], index: index)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
if numeric_rules[:less_than_or_equal_to] && numeric_value > numeric_rules[:less_than_or_equal_to]
|
|
337
|
+
message = extract_message(numeric_rules, :less_than_or_equal_to,
|
|
338
|
+
count: numeric_rules[:less_than_or_equal_to])
|
|
339
|
+
add_nested_error(param_name, attr_name, message, :less_than_or_equal_to,
|
|
340
|
+
count: numeric_rules[:less_than_or_equal_to], index: index)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
return unless numeric_rules[:equal_to] && numeric_value != numeric_rules[:equal_to]
|
|
344
|
+
|
|
345
|
+
message = extract_message(numeric_rules, :equal_to, count: numeric_rules[:equal_to])
|
|
346
|
+
add_nested_error(param_name, attr_name, message, :equal_to,
|
|
347
|
+
count: numeric_rules[:equal_to], index: index)
|
|
348
|
+
end
|
|
349
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
350
|
+
|
|
351
|
+
# Add error for nested validation
|
|
352
|
+
# rubocop:disable Metrics/ParameterLists
|
|
353
|
+
def add_nested_error(param_name, attr_name, custom_message, error_type, index: nil, **interpolations)
|
|
354
|
+
# Build the attribute path for the error
|
|
355
|
+
attribute_path = if index.nil?
|
|
356
|
+
# Hash validation: param_name.attr_name
|
|
357
|
+
attr_name ? :"#{param_name}.#{attr_name}" : param_name
|
|
358
|
+
else
|
|
359
|
+
# Array validation: param_name[index].attr_name
|
|
360
|
+
attr_name ? :"#{param_name}[#{index}].#{attr_name}" : :"#{param_name}[#{index}]"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
if current_config.error_mode == :code
|
|
364
|
+
# Code mode: use custom message or generate code
|
|
365
|
+
code_message = custom_message || error_code_for(error_type, **interpolations)
|
|
366
|
+
errors.add(attribute_path, code_message)
|
|
367
|
+
elsif custom_message
|
|
368
|
+
# Default mode: use ActiveModel's error messages with custom message
|
|
369
|
+
errors.add(attribute_path, custom_message)
|
|
370
|
+
else
|
|
371
|
+
errors.add(attribute_path, error_type, **interpolations)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
# rubocop:enable Metrics/ParameterLists
|
|
375
|
+
|
|
72
376
|
def validate_presence(param_name, value, rules)
|
|
73
377
|
return unless rules[:presence]
|
|
74
|
-
|
|
378
|
+
# For booleans, false is a valid present value
|
|
379
|
+
return if value.present? || value == false
|
|
75
380
|
|
|
76
381
|
message = extract_message(rules[:presence], :blank)
|
|
77
382
|
add_error(param_name, message, :blank)
|
|
78
383
|
end
|
|
79
384
|
|
|
385
|
+
def validate_boolean(param_name, value, rules)
|
|
386
|
+
return unless rules[:boolean]
|
|
387
|
+
return if boolean?(value)
|
|
388
|
+
|
|
389
|
+
message = extract_message(rules[:boolean], :not_boolean)
|
|
390
|
+
add_error(param_name, message, :not_boolean)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def boolean?(value)
|
|
394
|
+
[true, false].include?(value)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Validates format using regex patterns with ReDoS protection
|
|
398
|
+
# @param param_name [Symbol] the parameter name
|
|
399
|
+
# @param value [Object] the value to validate
|
|
400
|
+
# @param rules [Hash] validation rules containing :format
|
|
401
|
+
# @return [void]
|
|
80
402
|
def validate_format(param_name, value, rules)
|
|
81
403
|
return unless rules[:format] && value.present?
|
|
82
404
|
|
|
83
405
|
format_options = rules[:format]
|
|
84
406
|
pattern = format_options.is_a?(Hash) ? format_options[:with] : format_options
|
|
85
|
-
|
|
407
|
+
|
|
408
|
+
# Safe regex matching with timeout and caching
|
|
409
|
+
return if safe_regex_match?(value.to_s, pattern)
|
|
86
410
|
|
|
87
411
|
message = extract_message(format_options, :invalid)
|
|
88
412
|
add_error(param_name, message, :invalid)
|
|
89
413
|
end
|
|
90
414
|
|
|
415
|
+
# Safely match a value against a regex pattern with timeout protection
|
|
416
|
+
# @param value [String] the string to match
|
|
417
|
+
# @param pattern [Regexp] the regex pattern
|
|
418
|
+
# @return [Boolean] true if matches, false if no match or timeout
|
|
419
|
+
def safe_regex_match?(value, pattern)
|
|
420
|
+
# Get cached pattern if caching is enabled
|
|
421
|
+
cached_pattern = current_config.cache_regex_patterns ? get_cached_regex(pattern) : pattern
|
|
422
|
+
|
|
423
|
+
# Use Regexp.timeout if available (Ruby 3.2+)
|
|
424
|
+
if Regexp.respond_to?(:timeout)
|
|
425
|
+
begin
|
|
426
|
+
Regexp.timeout = current_config.regex_timeout
|
|
427
|
+
value.match?(cached_pattern)
|
|
428
|
+
rescue Regexp::TimeoutError
|
|
429
|
+
# Log timeout and treat as validation failure
|
|
430
|
+
add_error(:regex, ErrorCodes::REGEX_TIMEOUT, :timeout) if errors.respond_to?(:add)
|
|
431
|
+
false
|
|
432
|
+
ensure
|
|
433
|
+
Regexp.timeout = nil
|
|
434
|
+
end
|
|
435
|
+
else
|
|
436
|
+
# Fallback for older Ruby versions - use Timeout module
|
|
437
|
+
require "timeout"
|
|
438
|
+
begin
|
|
439
|
+
Timeout.timeout(current_config.regex_timeout) do
|
|
440
|
+
value.match?(cached_pattern)
|
|
441
|
+
end
|
|
442
|
+
rescue Timeout::Error
|
|
443
|
+
add_error(:regex, ErrorCodes::REGEX_TIMEOUT, :timeout) if errors.respond_to?(:add)
|
|
444
|
+
false
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Get or cache a compiled regex pattern
|
|
450
|
+
# @param pattern [Regexp] the pattern to cache
|
|
451
|
+
# @return [Regexp] the cached pattern
|
|
452
|
+
def get_cached_regex(pattern)
|
|
453
|
+
cache_key = pattern.source
|
|
454
|
+
self.class._regex_cache[cache_key] ||= pattern
|
|
455
|
+
end
|
|
456
|
+
|
|
91
457
|
def validate_length(param_name, value, rules)
|
|
92
458
|
return unless rules[:length] && value.present?
|
|
93
459
|
|
|
@@ -136,12 +502,22 @@ module Interactor
|
|
|
136
502
|
validate_numeric_constraints(param_name, numeric_value, numeric_rules)
|
|
137
503
|
end
|
|
138
504
|
|
|
505
|
+
# Check if a value is numeric or can be coerced to numeric
|
|
506
|
+
# @param value [Object] the value to check
|
|
507
|
+
# @return [Boolean] true if numeric or numeric string
|
|
139
508
|
def numeric?(value)
|
|
140
509
|
value.is_a?(Numeric) || value.to_s.match?(/\A-?\d+(\.\d+)?\z/)
|
|
141
510
|
end
|
|
142
511
|
|
|
512
|
+
# Coerce a value to numeric, preserving integer precision
|
|
513
|
+
# @param value [Object] the value to coerce
|
|
514
|
+
# @return [Numeric] integer or float depending on input
|
|
143
515
|
def coerce_to_numeric(value)
|
|
144
|
-
value.is_a?(Numeric)
|
|
516
|
+
return value if value.is_a?(Numeric)
|
|
517
|
+
|
|
518
|
+
str = value.to_s
|
|
519
|
+
# Use to_i for integers to preserve precision, to_f for floats
|
|
520
|
+
str.include?(".") ? str.to_f : str.to_i
|
|
145
521
|
end
|
|
146
522
|
|
|
147
523
|
def validate_numeric_constraints(param_name, value, rules)
|
|
@@ -200,47 +576,60 @@ module Interactor
|
|
|
200
576
|
end
|
|
201
577
|
end
|
|
202
578
|
|
|
203
|
-
# Generate error code for :code mode
|
|
579
|
+
# Generate error code for :code mode using constants
|
|
204
580
|
# @param error_type [Symbol] the type of validation error
|
|
205
581
|
# @param interpolations [Hash] values to interpolate into the code
|
|
206
582
|
# @return [String] the error code
|
|
583
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
207
584
|
def error_code_for(error_type, **interpolations)
|
|
208
585
|
case error_type
|
|
209
586
|
when :blank
|
|
210
|
-
|
|
587
|
+
ErrorCodes::REQUIRED
|
|
588
|
+
when :not_boolean
|
|
589
|
+
ErrorCodes::MUST_BE_BOOLEAN
|
|
211
590
|
when :invalid
|
|
212
|
-
|
|
591
|
+
ErrorCodes::INVALID_FORMAT
|
|
213
592
|
when :too_long
|
|
214
|
-
|
|
593
|
+
ErrorCodes.exceeds_max_length(interpolations[:count])
|
|
215
594
|
when :too_short
|
|
216
|
-
|
|
595
|
+
ErrorCodes.below_min_length(interpolations[:count])
|
|
217
596
|
when :wrong_length
|
|
218
|
-
|
|
597
|
+
ErrorCodes.must_be_length(interpolations[:count])
|
|
219
598
|
when :inclusion
|
|
220
|
-
|
|
599
|
+
ErrorCodes::NOT_IN_ALLOWED_VALUES
|
|
221
600
|
when :not_a_number
|
|
222
|
-
|
|
601
|
+
ErrorCodes::MUST_BE_A_NUMBER
|
|
223
602
|
when :greater_than
|
|
224
|
-
|
|
603
|
+
ErrorCodes.must_be_greater_than(interpolations[:count])
|
|
225
604
|
when :greater_than_or_equal_to
|
|
226
|
-
|
|
605
|
+
ErrorCodes.must_be_at_least(interpolations[:count])
|
|
227
606
|
when :less_than
|
|
228
|
-
|
|
607
|
+
ErrorCodes.must_be_less_than(interpolations[:count])
|
|
229
608
|
when :less_than_or_equal_to
|
|
230
|
-
|
|
609
|
+
ErrorCodes.must_be_at_most(interpolations[:count])
|
|
231
610
|
when :equal_to
|
|
232
|
-
|
|
611
|
+
ErrorCodes.must_be_equal_to(interpolations[:count])
|
|
612
|
+
when :invalid_type
|
|
613
|
+
ErrorCodes::INVALID_TYPE
|
|
614
|
+
when :too_large
|
|
615
|
+
ErrorCodes::ARRAY_TOO_LARGE
|
|
616
|
+
when :timeout
|
|
617
|
+
ErrorCodes::REGEX_TIMEOUT
|
|
233
618
|
else
|
|
234
619
|
error_type.to_s.upcase
|
|
235
620
|
end
|
|
236
621
|
end
|
|
622
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
237
623
|
|
|
238
624
|
# Formats errors into the expected structure
|
|
239
625
|
def formatted_errors
|
|
240
626
|
if current_config.error_mode == :code
|
|
241
627
|
# Code mode: return structured error codes
|
|
242
628
|
errors.map do |error|
|
|
243
|
-
|
|
629
|
+
# Convert attribute path to uppercase, handling nested paths
|
|
630
|
+
# Example: "attributes.username" -> "ATTRIBUTES.USERNAME"
|
|
631
|
+
# Example: "attributes[0].username" -> "ATTRIBUTES[0].USERNAME"
|
|
632
|
+
param_name = format_attribute_for_code(error.attribute)
|
|
244
633
|
message = error.message
|
|
245
634
|
|
|
246
635
|
{ code: "#{param_name}_#{message}" }
|
|
@@ -259,13 +648,33 @@ module Interactor
|
|
|
259
648
|
end
|
|
260
649
|
end
|
|
261
650
|
|
|
651
|
+
# Format attribute path for error code
|
|
652
|
+
# @param attribute [Symbol] the attribute path (e.g., :"attributes.username" or :"attributes[0].username")
|
|
653
|
+
# @return [String] formatted attribute path (e.g., "ATTRIBUTES_USERNAME" or "ATTRIBUTES[0]_USERNAME")
|
|
654
|
+
def format_attribute_for_code(attribute)
|
|
655
|
+
# Convert to string and uppercase
|
|
656
|
+
attr_str = attribute.to_s.upcase
|
|
657
|
+
# Replace dots with underscores, but preserve array indices
|
|
658
|
+
# Example: "attributes[0].username" -> "ATTRIBUTES[0]_USERNAME"
|
|
659
|
+
attr_str.gsub(/\.(?![^\[]*\])/, "_")
|
|
660
|
+
end
|
|
661
|
+
|
|
262
662
|
# Build a human-readable error message
|
|
263
663
|
# @param error [ActiveModel::Error] the error object
|
|
264
664
|
# @return [String] the formatted message
|
|
265
665
|
def build_error_message(error)
|
|
266
|
-
#
|
|
267
|
-
|
|
268
|
-
|
|
666
|
+
# For nested attributes (with dots or brackets), we can't use ActiveModel's message method
|
|
667
|
+
# because it tries to call a method on the class which doesn't exist
|
|
668
|
+
if error.attribute.to_s.include?(".") || error.attribute.to_s.include?("[")
|
|
669
|
+
# Manually build message for nested attributes
|
|
670
|
+
attribute_name = error.attribute.to_s.humanize
|
|
671
|
+
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
672
|
+
"#{attribute_name} #{error_message}"
|
|
673
|
+
elsif error.respond_to?(:message)
|
|
674
|
+
# Try to use ActiveModel's message for simple attributes
|
|
675
|
+
error.message
|
|
676
|
+
end
|
|
677
|
+
rescue ArgumentError, NoMethodError
|
|
269
678
|
# Fallback for anonymous classes or other issues
|
|
270
679
|
attribute_name = error.attribute.to_s.humanize
|
|
271
680
|
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
@@ -276,10 +685,13 @@ module Interactor
|
|
|
276
685
|
# @param type [Symbol] the error type
|
|
277
686
|
# @param options [Hash] error options with interpolations
|
|
278
687
|
# @return [String] the default message
|
|
688
|
+
# rubocop:disable Metrics/MethodLength
|
|
279
689
|
def default_message_for_type(type, options = {})
|
|
280
690
|
case type
|
|
281
691
|
when :blank
|
|
282
692
|
"can't be blank"
|
|
693
|
+
when :not_boolean
|
|
694
|
+
"must be a boolean value"
|
|
283
695
|
when :invalid
|
|
284
696
|
"is invalid"
|
|
285
697
|
when :too_long
|
|
@@ -302,10 +714,14 @@ module Interactor
|
|
|
302
714
|
"must be less than or equal to #{options[:count]}"
|
|
303
715
|
when :equal_to
|
|
304
716
|
"must be equal to #{options[:count]}"
|
|
717
|
+
when :invalid_type
|
|
718
|
+
"must be a Hash or Array"
|
|
305
719
|
else
|
|
306
720
|
"is invalid"
|
|
307
721
|
end
|
|
308
722
|
end
|
|
723
|
+
# rubocop:enable Metrics/MethodLength
|
|
309
724
|
end
|
|
725
|
+
# rubocop:enable Metrics/ModuleLength
|
|
310
726
|
end
|
|
311
727
|
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Wilson Anciro
|
|
@@ -120,8 +120,10 @@ files:
|
|
|
120
120
|
- LICENSE.txt
|
|
121
121
|
- README.md
|
|
122
122
|
- Rakefile
|
|
123
|
+
- benchmark/validation_benchmark.rb
|
|
123
124
|
- lib/interactor/validation.rb
|
|
124
125
|
- lib/interactor/validation/configuration.rb
|
|
126
|
+
- lib/interactor/validation/error_codes.rb
|
|
125
127
|
- lib/interactor/validation/params.rb
|
|
126
128
|
- lib/interactor/validation/validates.rb
|
|
127
129
|
- lib/interactor/validation/version.rb
|