interactor-validation 0.3.2 → 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 +91 -34
- data/lib/interactor/validation/version.rb +1 -1
- metadata +5 -6
data/README.md
CHANGED
|
@@ -2,99 +2,222 @@
|
|
|
2
2
|
|
|
3
3
|
Add declarative parameter validation to your [Interactor](https://github.com/collectiveidea/interactor) service objects with Rails-style syntax.
|
|
4
4
|
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Declarative validations with Rails-style syntax
|
|
8
|
+
- Nested validation for hash and array attributes
|
|
9
|
+
- Fine-grained halt control for fail-fast validation
|
|
10
|
+
- Multiple error formats (human-readable or structured codes)
|
|
11
|
+
- Custom validation hooks for complex business logic
|
|
12
|
+
- ActiveModel integration and custom validators
|
|
13
|
+
- Built-in security (ReDoS protection, memory safeguards, thread-safe)
|
|
14
|
+
- Performance monitoring via ActiveSupport::Notifications
|
|
15
|
+
- Full inheritance support
|
|
16
|
+
|
|
5
17
|
## Installation
|
|
6
18
|
|
|
19
|
+
Add the gem to your Gemfile:
|
|
20
|
+
|
|
7
21
|
```ruby
|
|
8
22
|
gem "interactor-validation"
|
|
9
23
|
```
|
|
10
24
|
|
|
11
|
-
|
|
25
|
+
Install the gem:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bundle install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### Quick Example
|
|
12
34
|
|
|
13
35
|
```ruby
|
|
14
36
|
class CreateUser
|
|
15
37
|
include Interactor
|
|
16
38
|
include Interactor::Validation
|
|
17
39
|
|
|
18
|
-
|
|
40
|
+
# Declare parameters
|
|
41
|
+
params :email, :username, :age, :terms_accepted
|
|
19
42
|
|
|
20
|
-
|
|
21
|
-
validates :
|
|
22
|
-
validates :
|
|
43
|
+
# Add validations
|
|
44
|
+
validates :email, presence: true, format: { with: /@/ }
|
|
45
|
+
validates :username, presence: true
|
|
46
|
+
validates :age, numericality: { greater_than: 0 }
|
|
47
|
+
validates :terms_accepted, boolean: true
|
|
23
48
|
|
|
24
49
|
def call
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
# Validations run automatically before this
|
|
51
|
+
User.create!(email: email, username: username, age: age)
|
|
27
52
|
end
|
|
28
53
|
end
|
|
29
54
|
|
|
30
|
-
#
|
|
31
|
-
result = CreateUser.call(
|
|
55
|
+
# Use it
|
|
56
|
+
result = CreateUser.call(
|
|
57
|
+
email: "user@example.com",
|
|
58
|
+
username: "john",
|
|
59
|
+
age: 25,
|
|
60
|
+
terms_accepted: true
|
|
61
|
+
)
|
|
62
|
+
result.success? # => true
|
|
63
|
+
|
|
64
|
+
# Invalid data fails automatically
|
|
65
|
+
result = CreateUser.call(email: "", username: "", age: -5, terms_accepted: "yes")
|
|
32
66
|
result.failure? # => true
|
|
33
67
|
result.errors # => [
|
|
34
68
|
# { attribute: :email, type: :blank, message: "Email can't be blank" },
|
|
35
|
-
# { attribute: :username, type: :
|
|
36
|
-
# { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
|
|
69
|
+
# { attribute: :username, type: :blank, message: "Username can't be blank" },
|
|
70
|
+
# { attribute: :age, type: :greater_than, message: "Age must be greater than 0" },
|
|
71
|
+
# { attribute: :terms_accepted, type: :invalid, message: "Terms accepted must be true or false" }
|
|
37
72
|
# ]
|
|
38
73
|
```
|
|
39
74
|
|
|
40
|
-
|
|
75
|
+
### Examples by Validation Type
|
|
41
76
|
|
|
42
|
-
|
|
77
|
+
#### Presence
|
|
43
78
|
|
|
44
79
|
```ruby
|
|
45
80
|
validates :name, presence: true
|
|
46
81
|
# Error: { attribute: :name, type: :blank, message: "Name can't be blank" }
|
|
47
82
|
```
|
|
48
83
|
|
|
49
|
-
|
|
84
|
+
#### Format (Regex)
|
|
50
85
|
|
|
51
86
|
```ruby
|
|
52
87
|
validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
|
|
53
88
|
# Error: { attribute: :email, type: :invalid, message: "Email is invalid" }
|
|
54
89
|
```
|
|
55
90
|
|
|
91
|
+
#### Numericality
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
validates :price, numericality: { greater_than_or_equal_to: 0 }
|
|
95
|
+
validates :quantity, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
|
|
96
|
+
validates :count, numericality: true # Just check if numeric
|
|
97
|
+
|
|
98
|
+
# Available constraints:
|
|
99
|
+
# - greater_than, greater_than_or_equal_to
|
|
100
|
+
# - less_than, less_than_or_equal_to
|
|
101
|
+
# - equal_to
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
#### Boolean
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
validates :is_active, boolean: true
|
|
108
|
+
# Ensures value is true or false (not truthy/falsy)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Available Validations
|
|
114
|
+
|
|
115
|
+
All standard validations support custom error messages:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
validates :field, presence: { message: "Custom message" }
|
|
119
|
+
validates :field, format: { with: /pattern/, message: "Invalid format" }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Presence
|
|
123
|
+
|
|
124
|
+
Validates that a value is not nil or empty.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
validates :name, presence: true
|
|
128
|
+
validates :email, presence: { message: "Email is required" }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Errors:**
|
|
132
|
+
- Default mode: `{ attribute: :name, type: :blank, message: "Name can't be blank" }`
|
|
133
|
+
- Code mode: `{ code: "NAME_IS_REQUIRED" }`
|
|
134
|
+
|
|
135
|
+
### Format
|
|
136
|
+
|
|
137
|
+
Validates that a value matches a regular expression pattern.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
|
|
141
|
+
validates :username, format: { with: /\A[a-z0-9_]+\z/, message: "Only lowercase letters, numbers, and underscores" }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Errors:**
|
|
145
|
+
- Default mode: `{ attribute: :email, type: :invalid, message: "Email is invalid" }`
|
|
146
|
+
- Code mode: `{ code: "EMAIL_INVALID_FORMAT" }`
|
|
147
|
+
|
|
56
148
|
### Length
|
|
57
149
|
|
|
150
|
+
Validates the length of a string or array.
|
|
151
|
+
|
|
58
152
|
```ruby
|
|
59
153
|
validates :password, length: { minimum: 8, maximum: 128 }
|
|
60
154
|
validates :code, length: { is: 6 }
|
|
61
|
-
|
|
62
|
-
# { attribute: :code, type: :wrong_length, message: "Code is the wrong length (should be 6 characters)" }
|
|
155
|
+
validates :bio, length: { maximum: 500 }
|
|
63
156
|
```
|
|
64
157
|
|
|
158
|
+
**Options:** `minimum`, `maximum`, `is`
|
|
159
|
+
|
|
160
|
+
**Errors:**
|
|
161
|
+
- `too_short`: Value is below minimum
|
|
162
|
+
- `too_long`: Value exceeds maximum
|
|
163
|
+
- `wrong_length`: Value doesn't match exact length
|
|
164
|
+
|
|
65
165
|
### Inclusion
|
|
66
166
|
|
|
167
|
+
Validates that a value is in a specific list.
|
|
168
|
+
|
|
67
169
|
```ruby
|
|
68
170
|
validates :status, inclusion: { in: %w[active pending inactive] }
|
|
69
|
-
|
|
171
|
+
validates :role, inclusion: { in: ["admin", "user", "guest"] }
|
|
70
172
|
```
|
|
71
173
|
|
|
174
|
+
**Errors:**
|
|
175
|
+
- Default mode: `{ attribute: :status, type: :inclusion, message: "Status is not included in the list" }`
|
|
176
|
+
- Code mode: `{ code: "STATUS_NOT_IN_LIST" }`
|
|
177
|
+
|
|
72
178
|
### Numericality
|
|
73
179
|
|
|
180
|
+
Validates that a value is numeric and optionally meets constraints.
|
|
181
|
+
|
|
74
182
|
```ruby
|
|
183
|
+
validates :age, numericality: { greater_than: 0 }
|
|
75
184
|
validates :price, numericality: { greater_than_or_equal_to: 0 }
|
|
76
185
|
validates :quantity, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
|
|
186
|
+
validates :rating, numericality: { equal_to: 5 }
|
|
77
187
|
validates :count, numericality: true # Just check if numeric
|
|
78
|
-
|
|
79
|
-
# Available constraints:
|
|
80
|
-
# - greater_than, greater_than_or_equal_to
|
|
81
|
-
# - less_than, less_than_or_equal_to
|
|
82
|
-
# - equal_to
|
|
83
188
|
```
|
|
84
189
|
|
|
190
|
+
**Options:**
|
|
191
|
+
- `greater_than`
|
|
192
|
+
- `greater_than_or_equal_to`
|
|
193
|
+
- `less_than`
|
|
194
|
+
- `less_than_or_equal_to`
|
|
195
|
+
- `equal_to`
|
|
196
|
+
|
|
197
|
+
**Errors:**
|
|
198
|
+
- Default mode: `{ attribute: :age, type: :greater_than, message: "Age must be greater than 0" }`
|
|
199
|
+
- Code mode: `{ code: "AGE_BELOW_MIN_VALUE_0" }`
|
|
200
|
+
|
|
85
201
|
### Boolean
|
|
86
202
|
|
|
203
|
+
Validates that a value is exactly `true` or `false` (not truthy/falsy).
|
|
204
|
+
|
|
87
205
|
```ruby
|
|
88
206
|
validates :is_active, boolean: true
|
|
89
|
-
|
|
207
|
+
validates :terms_accepted, boolean: true
|
|
90
208
|
```
|
|
91
209
|
|
|
210
|
+
**Errors:**
|
|
211
|
+
- Default mode: `{ attribute: :is_active, type: :invalid, message: "Is active must be true or false" }`
|
|
212
|
+
- Code mode: `{ code: "IS_ACTIVE_INVALID_BOOLEAN" }`
|
|
213
|
+
|
|
92
214
|
### Nested Validation
|
|
93
215
|
|
|
94
|
-
Validate nested hashes and arrays
|
|
216
|
+
Validate nested hashes and arrays.
|
|
217
|
+
|
|
218
|
+
**Hash Validation:**
|
|
95
219
|
|
|
96
220
|
```ruby
|
|
97
|
-
# Hash validation
|
|
98
221
|
params :user
|
|
99
222
|
validates :user do
|
|
100
223
|
attribute :name, presence: true
|
|
@@ -102,14 +225,39 @@ validates :user do
|
|
|
102
225
|
attribute :age, numericality: { greater_than: 0 }
|
|
103
226
|
end
|
|
104
227
|
|
|
105
|
-
#
|
|
228
|
+
# Usage
|
|
229
|
+
result = CreateUser.call(user: { name: "", email: "bad", age: -1 })
|
|
230
|
+
result.errors # => [
|
|
231
|
+
# { attribute: "user.name", type: :blank, message: "User.name can't be blank" },
|
|
232
|
+
# { attribute: "user.email", type: :invalid, message: "User.email is invalid" },
|
|
233
|
+
# { attribute: "user.age", type: :greater_than, message: "User.age must be greater than 0" }
|
|
234
|
+
# ]
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Array Validation:**
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
106
240
|
params :items
|
|
107
241
|
validates :items do
|
|
108
242
|
attribute :name, presence: true
|
|
109
243
|
attribute :price, numericality: { greater_than: 0 }
|
|
110
244
|
end
|
|
245
|
+
|
|
246
|
+
# Usage
|
|
247
|
+
result = ProcessItems.call(items: [
|
|
248
|
+
{ name: "Widget", price: 10 },
|
|
249
|
+
{ name: "", price: -5 }
|
|
250
|
+
])
|
|
251
|
+
result.errors # => [
|
|
252
|
+
# { attribute: "items[1].name", type: :blank, message: "Items[1].name can't be blank" },
|
|
253
|
+
# { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" }
|
|
254
|
+
# ]
|
|
111
255
|
```
|
|
112
256
|
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
# Detailed Documentation
|
|
260
|
+
|
|
113
261
|
## Error Formats
|
|
114
262
|
|
|
115
263
|
Choose between two error format modes:
|
|
@@ -173,7 +321,7 @@ Interactor::Validation.configure do |config|
|
|
|
173
321
|
config.error_mode = :default
|
|
174
322
|
|
|
175
323
|
# Stop at first error for better performance
|
|
176
|
-
config.
|
|
324
|
+
config.halt = false # Set to true to stop on first validation error
|
|
177
325
|
|
|
178
326
|
# Security settings
|
|
179
327
|
config.regex_timeout = 0.1 # Regex timeout in seconds (ReDoS protection)
|
|
@@ -198,7 +346,7 @@ class CreateUser
|
|
|
198
346
|
|
|
199
347
|
configure_validation do |config|
|
|
200
348
|
config.error_mode = :code
|
|
201
|
-
config.
|
|
349
|
+
config.halt = true
|
|
202
350
|
end
|
|
203
351
|
|
|
204
352
|
validates :username, presence: true
|
|
@@ -224,11 +372,13 @@ end
|
|
|
224
372
|
|
|
225
373
|
### Halt on First Error
|
|
226
374
|
|
|
227
|
-
|
|
375
|
+
Stop validation early for better performance and user experience:
|
|
376
|
+
|
|
377
|
+
#### Global Configuration
|
|
228
378
|
|
|
229
379
|
```ruby
|
|
230
380
|
configure_validation do |config|
|
|
231
|
-
config.
|
|
381
|
+
config.halt = true # Stop after first error (recommended)
|
|
232
382
|
end
|
|
233
383
|
|
|
234
384
|
validates :field1, presence: true
|
|
@@ -236,6 +386,54 @@ validates :field2, presence: true # Won't run if field1 fails
|
|
|
236
386
|
validates :field3, presence: true # Won't run if earlier fields fail
|
|
237
387
|
```
|
|
238
388
|
|
|
389
|
+
#### Per-Error Halt
|
|
390
|
+
|
|
391
|
+
Use `halt: true` with `errors.add` for fine-grained control:
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
class ProcessOrder
|
|
395
|
+
include Interactor
|
|
396
|
+
include Interactor::Validation
|
|
397
|
+
|
|
398
|
+
params :order_id, :payment_method
|
|
399
|
+
|
|
400
|
+
validates :payment_method, inclusion: { in: %w[credit_card paypal] }
|
|
401
|
+
|
|
402
|
+
validate :check_order_exists
|
|
403
|
+
|
|
404
|
+
def check_order_exists
|
|
405
|
+
order = Order.find_by(id: context.order_id)
|
|
406
|
+
|
|
407
|
+
if order.nil?
|
|
408
|
+
# Halt immediately - no point validating payment if order doesn't exist
|
|
409
|
+
errors.add(:order_id, "not found", halt: true)
|
|
410
|
+
return
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# This won't run if halt was triggered
|
|
414
|
+
if order.cancelled?
|
|
415
|
+
errors.add(:order_id, "order is cancelled")
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def call
|
|
420
|
+
# Process order
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
**How it works:**
|
|
426
|
+
- **Global `halt` config**: Stops validating subsequent parameters after first error
|
|
427
|
+
- **Per-error `halt: true`**: Stops validation immediately when that specific error is added
|
|
428
|
+
- **Within-parameter halt**: When halt is triggered, remaining validation rules for that parameter are skipped
|
|
429
|
+
- **Across-parameter halt**: Subsequent parameters won't be validated
|
|
430
|
+
|
|
431
|
+
**Use cases:**
|
|
432
|
+
- Stop validating dependent fields when a required field is missing
|
|
433
|
+
- Skip expensive validations when basic checks fail
|
|
434
|
+
- Improve API response times by failing fast
|
|
435
|
+
- Provide cleaner error messages (only the most relevant error)
|
|
436
|
+
|
|
239
437
|
### ActiveModel Integration
|
|
240
438
|
|
|
241
439
|
Use ActiveModel's custom validation callbacks:
|
|
@@ -305,27 +503,964 @@ All validation operations are thread-safe for use with Puma, Sidekiq, etc.
|
|
|
305
503
|
- Enable instrumentation to monitor performance
|
|
306
504
|
- Review [SECURITY.md](SECURITY.md) for detailed information
|
|
307
505
|
|
|
308
|
-
##
|
|
506
|
+
## Custom Validation Hook
|
|
309
507
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
508
|
+
Use the `validate!` hook to add custom validation logic that goes beyond standard validations.
|
|
509
|
+
|
|
510
|
+
### Basic Usage
|
|
511
|
+
|
|
512
|
+
The `validate!` method runs automatically after parameter validations:
|
|
513
|
+
|
|
514
|
+
```ruby
|
|
515
|
+
class CreateOrder
|
|
516
|
+
include Interactor
|
|
517
|
+
include Interactor::Validation
|
|
518
|
+
|
|
519
|
+
params :product_id, :quantity, :user_id
|
|
520
|
+
|
|
521
|
+
validates :product_id, presence: true
|
|
522
|
+
validates :quantity, numericality: { greater_than: 0 }
|
|
523
|
+
validates :user_id, presence: true
|
|
524
|
+
|
|
525
|
+
def validate!
|
|
526
|
+
# Custom business logic validation
|
|
527
|
+
product = Product.find_by(id: product_id)
|
|
528
|
+
|
|
529
|
+
if product.nil?
|
|
530
|
+
errors.add(:product_id, "PRODUCT_NOT_FOUND")
|
|
531
|
+
elsif product.stock < quantity
|
|
532
|
+
errors.add(:quantity, "INSUFFICIENT_STOCK")
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
user = User.find_by(id: user_id)
|
|
536
|
+
if user && !user.active?
|
|
537
|
+
errors.add(:user_id, "USER_NOT_ACTIVE")
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def call
|
|
542
|
+
# This only runs if all validations pass
|
|
543
|
+
Order.create!(product_id: product_id, quantity: quantity, user_id: user_id)
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Usage
|
|
548
|
+
result = CreateOrder.call(product_id: 999, quantity: 100, user_id: 1)
|
|
549
|
+
result.failure? # => true
|
|
550
|
+
result.errors # => [{ code: "PRODUCT_ID_PRODUCT_NOT_FOUND" }]
|
|
315
551
|
```
|
|
316
552
|
|
|
317
|
-
###
|
|
553
|
+
### Execution Order
|
|
318
554
|
|
|
319
|
-
|
|
320
|
-
|
|
555
|
+
Validations run in this order:
|
|
556
|
+
|
|
557
|
+
1. **Parameter validations** (`validates :field, ...`)
|
|
558
|
+
2. **Custom validate! hook** (your custom logic)
|
|
559
|
+
3. **call method** (only if no errors)
|
|
560
|
+
|
|
561
|
+
```ruby
|
|
562
|
+
class ProcessPayment
|
|
563
|
+
include Interactor
|
|
564
|
+
include Interactor::Validation
|
|
565
|
+
|
|
566
|
+
params :amount, :card_token
|
|
567
|
+
|
|
568
|
+
validates :amount, numericality: { greater_than: 0 }
|
|
569
|
+
validates :card_token, presence: true
|
|
570
|
+
|
|
571
|
+
def validate!
|
|
572
|
+
# This runs AFTER parameter validations pass
|
|
573
|
+
# Check payment gateway availability
|
|
574
|
+
errors.add(:base, "PAYMENT_GATEWAY_UNAVAILABLE") unless PaymentGateway.available?
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def call
|
|
578
|
+
# This only runs if both parameter validations AND validate! pass
|
|
579
|
+
PaymentGateway.charge(amount: amount, token: card_token)
|
|
580
|
+
end
|
|
581
|
+
end
|
|
321
582
|
```
|
|
322
583
|
|
|
323
|
-
|
|
584
|
+
### Combining with Error Modes
|
|
324
585
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
586
|
+
Works with both `:default` and `:code` error modes:
|
|
587
|
+
|
|
588
|
+
```ruby
|
|
589
|
+
# With :default mode (ActiveModel-style messages)
|
|
590
|
+
class UpdateProfile
|
|
591
|
+
include Interactor
|
|
592
|
+
include Interactor::Validation
|
|
593
|
+
|
|
594
|
+
params :username, :bio
|
|
595
|
+
|
|
596
|
+
validates :username, presence: true
|
|
597
|
+
|
|
598
|
+
def validate!
|
|
599
|
+
if username && username.include?("admin")
|
|
600
|
+
errors.add(:username, "cannot contain 'admin'")
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
result = UpdateProfile.call(username: "admin123")
|
|
606
|
+
result.errors # => [{ attribute: :username, type: :invalid, message: "Username cannot contain 'admin'" }]
|
|
607
|
+
|
|
608
|
+
# With :code mode (structured error codes)
|
|
609
|
+
class UpdateProfile
|
|
610
|
+
include Interactor
|
|
611
|
+
include Interactor::Validation
|
|
612
|
+
|
|
613
|
+
configure_validation do |config|
|
|
614
|
+
config.error_mode = :code
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
params :username, :bio
|
|
618
|
+
|
|
619
|
+
validates :username, presence: true
|
|
620
|
+
|
|
621
|
+
def validate!
|
|
622
|
+
if username && username.include?("admin")
|
|
623
|
+
errors.add(:username, "RESERVED_WORD")
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
result = UpdateProfile.call(username: "admin123")
|
|
629
|
+
result.errors # => [{ code: "USERNAME_RESERVED_WORD" }]
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
## Inheritance
|
|
633
|
+
|
|
634
|
+
Create base interactors with shared validation logic that child classes automatically inherit.
|
|
635
|
+
|
|
636
|
+
### Basic Inheritance
|
|
637
|
+
|
|
638
|
+
```ruby
|
|
639
|
+
# Base interactor with common functionality
|
|
640
|
+
class ApplicationInteractor
|
|
641
|
+
include Interactor
|
|
642
|
+
include Interactor::Validation
|
|
643
|
+
|
|
644
|
+
# All child classes will inherit validation functionality
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Child interactor automatically gets validation
|
|
648
|
+
class CreateUser < ApplicationInteractor
|
|
649
|
+
params :email, :username
|
|
650
|
+
|
|
651
|
+
validates :email, presence: true, format: { with: /@/ }
|
|
652
|
+
validates :username, presence: true
|
|
653
|
+
|
|
654
|
+
def call
|
|
655
|
+
User.create!(email: email, username: username)
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# Another child interactor
|
|
660
|
+
class UpdateUser < ApplicationInteractor
|
|
661
|
+
params :user_id, :email
|
|
662
|
+
|
|
663
|
+
validates :user_id, presence: true
|
|
664
|
+
validates :email, format: { with: /@/ }
|
|
665
|
+
|
|
666
|
+
def call
|
|
667
|
+
User.find(user_id).update!(email: email)
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# Both work automatically
|
|
672
|
+
CreateUser.call(email: "user@example.com", username: "john") # => success
|
|
673
|
+
UpdateUser.call(user_id: 1, email: "invalid") # => failure with validation errors
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### Shared Validation Configuration
|
|
677
|
+
|
|
678
|
+
Configure validation behavior in the base class:
|
|
679
|
+
|
|
680
|
+
```ruby
|
|
681
|
+
class ApiInteractor
|
|
682
|
+
include Interactor
|
|
683
|
+
include Interactor::Validation
|
|
684
|
+
|
|
685
|
+
configure_validation do |config|
|
|
686
|
+
config.error_mode = :code # All child classes use code mode
|
|
687
|
+
config.halt = true
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
class CreatePost < ApiInteractor
|
|
692
|
+
params :title, :body
|
|
693
|
+
|
|
694
|
+
validates :title, presence: true
|
|
695
|
+
validates :body, presence: true
|
|
696
|
+
|
|
697
|
+
def call
|
|
698
|
+
Post.create!(title: title, body: body)
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
result = CreatePost.call(title: "", body: "")
|
|
703
|
+
result.errors # => [{ code: "TITLE_IS_REQUIRED" }] # Halted on first error
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
### Shared Custom Validations
|
|
707
|
+
|
|
708
|
+
Define common validation logic in the base class:
|
|
709
|
+
|
|
710
|
+
```ruby
|
|
711
|
+
class AuthenticatedInteractor
|
|
712
|
+
include Interactor
|
|
713
|
+
include Interactor::Validation
|
|
714
|
+
|
|
715
|
+
params :user_id
|
|
716
|
+
|
|
717
|
+
validates :user_id, presence: true
|
|
718
|
+
|
|
719
|
+
def validate!
|
|
720
|
+
# This validation runs for ALL child classes
|
|
721
|
+
user = User.find_by(id: user_id)
|
|
722
|
+
|
|
723
|
+
if user.nil?
|
|
724
|
+
errors.add(:user_id, "USER_NOT_FOUND")
|
|
725
|
+
elsif !user.active?
|
|
726
|
+
errors.add(:user_id, "USER_INACTIVE")
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
class UpdateSettings < AuthenticatedInteractor
|
|
732
|
+
params :user_id, :theme
|
|
733
|
+
|
|
734
|
+
validates :theme, inclusion: { in: %w[light dark] }
|
|
735
|
+
|
|
736
|
+
def call
|
|
737
|
+
# user_id is already validated by parent
|
|
738
|
+
User.find(user_id).update!(theme: theme)
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
class DeleteAccount < AuthenticatedInteractor
|
|
743
|
+
params :user_id, :confirmation
|
|
744
|
+
|
|
745
|
+
validates :confirmation, presence: true
|
|
746
|
+
|
|
747
|
+
def validate!
|
|
748
|
+
super # Call parent's validate! first
|
|
749
|
+
|
|
750
|
+
# Add additional validation
|
|
751
|
+
if confirmation != "DELETE"
|
|
752
|
+
errors.add(:confirmation, "INVALID_CONFIRMATION")
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def call
|
|
757
|
+
User.find(user_id).destroy!
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
### Multilevel Inheritance
|
|
763
|
+
|
|
764
|
+
Validation works across multiple inheritance levels:
|
|
765
|
+
|
|
766
|
+
```ruby
|
|
767
|
+
# Level 1: Base
|
|
768
|
+
class ApplicationInteractor
|
|
769
|
+
include Interactor
|
|
770
|
+
include Interactor::Validation
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# Level 2: Feature-specific base
|
|
774
|
+
class AdminInteractor < ApplicationInteractor
|
|
775
|
+
params :admin_id
|
|
776
|
+
|
|
777
|
+
validates :admin_id, presence: true
|
|
778
|
+
|
|
779
|
+
def validate!
|
|
780
|
+
admin = Admin.find_by(id: admin_id)
|
|
781
|
+
errors.add(:admin_id, "NOT_AN_ADMIN") if admin.nil?
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Level 3: Specific action
|
|
786
|
+
class BanUser < AdminInteractor
|
|
787
|
+
params :admin_id, :target_user_id, :reason
|
|
788
|
+
|
|
789
|
+
validates :target_user_id, presence: true
|
|
790
|
+
validates :reason, presence: true, length: { minimum: 10 }
|
|
791
|
+
|
|
792
|
+
def validate!
|
|
793
|
+
super # Validates admin_id
|
|
794
|
+
|
|
795
|
+
# Additional validation
|
|
796
|
+
target = User.find_by(id: target_user_id)
|
|
797
|
+
errors.add(:target_user_id, "USER_NOT_FOUND") if target.nil?
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def call
|
|
801
|
+
User.find(target_user_id).ban!(reason: reason, banned_by: admin_id)
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# All three levels of validation run automatically
|
|
806
|
+
result = BanUser.call(admin_id: 1, target_user_id: 999, reason: "Spam")
|
|
807
|
+
# Validates: admin_id presence, admin exists, target_user_id presence, target exists, reason presence/length
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
### Override Parent Configuration
|
|
811
|
+
|
|
812
|
+
Child classes can override parent configuration:
|
|
813
|
+
|
|
814
|
+
```ruby
|
|
815
|
+
class BaseInteractor
|
|
816
|
+
include Interactor
|
|
817
|
+
include Interactor::Validation
|
|
818
|
+
|
|
819
|
+
configure_validation do |config|
|
|
820
|
+
config.error_mode = :default
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
class ApiCreateUser < BaseInteractor
|
|
825
|
+
# Override to use code mode for API
|
|
826
|
+
configure_validation do |config|
|
|
827
|
+
config.error_mode = :code
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
params :email
|
|
831
|
+
|
|
832
|
+
validates :email, presence: true
|
|
833
|
+
|
|
834
|
+
def call
|
|
835
|
+
User.create!(email: email)
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
result = ApiCreateUser.call(email: "")
|
|
840
|
+
result.errors # => [{ code: "EMAIL_IS_REQUIRED" }] # Uses :code mode, not :default
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
## Complete Usage Examples
|
|
844
|
+
|
|
845
|
+
### All Validation Types
|
|
846
|
+
|
|
847
|
+
```ruby
|
|
848
|
+
class CompleteExample
|
|
849
|
+
include Interactor
|
|
850
|
+
include Interactor::Validation
|
|
851
|
+
|
|
852
|
+
params :name, :email, :password, :age, :status, :terms, :profile, :tags
|
|
853
|
+
|
|
854
|
+
# Presence validation
|
|
855
|
+
validates :name, presence: true
|
|
856
|
+
# Error: { attribute: :name, type: :blank, message: "Name can't be blank" }
|
|
857
|
+
|
|
858
|
+
# Format validation (regex)
|
|
859
|
+
validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
|
|
860
|
+
# Error: { attribute: :email, type: :invalid, message: "Email is invalid" }
|
|
861
|
+
|
|
862
|
+
# Length validations
|
|
863
|
+
validates :password, length: { minimum: 8, maximum: 128 }
|
|
864
|
+
# Errors: { attribute: :password, type: :too_short, message: "Password is too short (minimum is 8 characters)" }
|
|
865
|
+
# { attribute: :password, type: :too_long, message: "Password is too long (maximum is 128 characters)" }
|
|
866
|
+
|
|
867
|
+
validates :name, length: { is: 10 }
|
|
868
|
+
# Error: { attribute: :name, type: :wrong_length, message: "Name is the wrong length (should be 10 characters)" }
|
|
869
|
+
|
|
870
|
+
# Numericality validations
|
|
871
|
+
validates :age,
|
|
872
|
+
numericality: {
|
|
873
|
+
greater_than: 0,
|
|
874
|
+
less_than: 150,
|
|
875
|
+
greater_than_or_equal_to: 18,
|
|
876
|
+
less_than_or_equal_to: 100,
|
|
877
|
+
equal_to: 25 # Exact value
|
|
878
|
+
}
|
|
879
|
+
# Errors: { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
|
|
880
|
+
# { attribute: :age, type: :less_than, message: "Age must be less than 150" }
|
|
881
|
+
# { attribute: :age, type: :greater_than_or_equal_to, message: "Age must be greater than or equal to 18" }
|
|
882
|
+
# { attribute: :age, type: :less_than_or_equal_to, message: "Age must be less than or equal to 100" }
|
|
883
|
+
# { attribute: :age, type: :equal_to, message: "Age must be equal to 25" }
|
|
884
|
+
|
|
885
|
+
# Inclusion validation
|
|
886
|
+
validates :status, inclusion: { in: %w[active pending inactive suspended] }
|
|
887
|
+
# Error: { attribute: :status, type: :inclusion, message: "Status is not included in the list" }
|
|
888
|
+
|
|
889
|
+
# Boolean validation
|
|
890
|
+
validates :terms, boolean: true
|
|
891
|
+
# Ensures value is exactly true or false (not truthy/falsy)
|
|
892
|
+
|
|
893
|
+
# Nested hash validation
|
|
894
|
+
validates :profile do
|
|
895
|
+
attribute :username, presence: true, length: { minimum: 3 }
|
|
896
|
+
attribute :bio, length: { maximum: 500 }
|
|
897
|
+
attribute :age, numericality: { greater_than: 0 }
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
# Nested array validation
|
|
901
|
+
validates :tags do
|
|
902
|
+
attribute :name, presence: true
|
|
903
|
+
attribute :priority, numericality: { greater_than_or_equal_to: 0 }
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
def call
|
|
907
|
+
# Your logic here
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
### Custom Error Messages
|
|
913
|
+
|
|
914
|
+
```ruby
|
|
915
|
+
class CustomMessages
|
|
916
|
+
include Interactor
|
|
917
|
+
include Interactor::Validation
|
|
918
|
+
|
|
919
|
+
params :username, :email, :age
|
|
920
|
+
|
|
921
|
+
# Custom message for presence
|
|
922
|
+
validates :username, presence: { message: "Please provide a username" }
|
|
923
|
+
|
|
924
|
+
# Custom message for format
|
|
925
|
+
validates :email, format: { with: /@/, message: "Must be a valid email address" }
|
|
926
|
+
|
|
927
|
+
# Custom message for numericality
|
|
928
|
+
validates :age, numericality: { greater_than: 0, message: "Age must be positive" }
|
|
929
|
+
|
|
930
|
+
def call
|
|
931
|
+
# Your logic
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
# With :default mode
|
|
936
|
+
result = CustomMessages.call(username: "", email: "invalid", age: -5)
|
|
937
|
+
result.errors # => [
|
|
938
|
+
# { attribute: :username, type: :blank, message: "Username please provide a username" },
|
|
939
|
+
# { attribute: :email, type: :invalid, message: "Email must be a valid email address" },
|
|
940
|
+
# { attribute: :age, type: :greater_than, message: "Age age must be positive" }
|
|
941
|
+
# ]
|
|
942
|
+
|
|
943
|
+
# With :code mode
|
|
944
|
+
class CustomMessagesCode
|
|
945
|
+
include Interactor
|
|
946
|
+
include Interactor::Validation
|
|
947
|
+
|
|
948
|
+
configure_validation { |c| c.error_mode = :code }
|
|
949
|
+
|
|
950
|
+
params :username, :age
|
|
951
|
+
|
|
952
|
+
validates :username, presence: { message: "REQUIRED" }
|
|
953
|
+
validates :age, numericality: { greater_than: 0, message: "INVALID" }
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
result = CustomMessagesCode.call(username: "", age: -5)
|
|
957
|
+
result.errors # => [
|
|
958
|
+
# { code: "USERNAME_REQUIRED" },
|
|
959
|
+
# { code: "AGE_INVALID" }
|
|
960
|
+
# ]
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
### Error Modes Comparison
|
|
964
|
+
|
|
965
|
+
```ruby
|
|
966
|
+
class UserRegistration
|
|
967
|
+
include Interactor
|
|
968
|
+
include Interactor::Validation
|
|
969
|
+
|
|
970
|
+
params :email, :password, :age
|
|
971
|
+
|
|
972
|
+
validates :email, presence: true, format: { with: /@/ }
|
|
973
|
+
validates :password, length: { minimum: 8 }
|
|
974
|
+
validates :age, numericality: { greater_than_or_equal_to: 18 }
|
|
975
|
+
|
|
976
|
+
def call
|
|
977
|
+
User.create!(email: email, password: password, age: age)
|
|
978
|
+
end
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
# Default mode (ActiveModel-style) - human-readable, detailed
|
|
982
|
+
result = UserRegistration.call(email: "bad", password: "short", age: 15)
|
|
983
|
+
result.errors # => [
|
|
984
|
+
# { attribute: :email, type: :invalid, message: "Email is invalid" },
|
|
985
|
+
# { attribute: :password, type: :too_short, message: "Password is too short (minimum is 8 characters)" },
|
|
986
|
+
# { attribute: :age, type: :greater_than_or_equal_to, message: "Age must be greater than or equal to 18" }
|
|
987
|
+
# ]
|
|
988
|
+
|
|
989
|
+
# Code mode - structured, API-friendly, i18n-ready
|
|
990
|
+
class ApiUserRegistration
|
|
991
|
+
include Interactor
|
|
992
|
+
include Interactor::Validation
|
|
993
|
+
|
|
994
|
+
configure_validation { |c| c.error_mode = :code }
|
|
995
|
+
|
|
996
|
+
params :email, :password, :age
|
|
997
|
+
|
|
998
|
+
validates :email, presence: true, format: { with: /@/ }
|
|
999
|
+
validates :password, length: { minimum: 8 }
|
|
1000
|
+
validates :age, numericality: { greater_than_or_equal_to: 18 }
|
|
1001
|
+
|
|
1002
|
+
def call
|
|
1003
|
+
User.create!(email: email, password: password, age: age)
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
result = ApiUserRegistration.call(email: "bad", password: "short", age: 15)
|
|
1008
|
+
result.errors # => [
|
|
1009
|
+
# { code: "EMAIL_INVALID_FORMAT" },
|
|
1010
|
+
# { code: "PASSWORD_BELOW_MIN_LENGTH_8" },
|
|
1011
|
+
# { code: "AGE_BELOW_MIN_VALUE_18" }
|
|
1012
|
+
# ]
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
### Configuration Examples
|
|
1016
|
+
|
|
1017
|
+
```ruby
|
|
1018
|
+
# Global configuration (config/initializers/interactor_validation.rb)
|
|
1019
|
+
Interactor::Validation.configure do |config|
|
|
1020
|
+
# Error format
|
|
1021
|
+
config.error_mode = :code # or :default
|
|
1022
|
+
|
|
1023
|
+
# Performance
|
|
1024
|
+
config.halt = true # Stop at first validation error
|
|
1025
|
+
|
|
1026
|
+
# Security
|
|
1027
|
+
config.regex_timeout = 0.1 # 100ms timeout for regex (ReDoS protection)
|
|
1028
|
+
config.max_array_size = 1000 # Max array size for nested validation
|
|
1029
|
+
|
|
1030
|
+
# Optimization
|
|
1031
|
+
config.cache_regex_patterns = true # Cache compiled regex patterns
|
|
1032
|
+
|
|
1033
|
+
# Monitoring
|
|
1034
|
+
config.enable_instrumentation = true
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
# Per-interactor configuration (overrides global)
|
|
1038
|
+
class FastValidator
|
|
1039
|
+
include Interactor
|
|
1040
|
+
include Interactor::Validation
|
|
1041
|
+
|
|
1042
|
+
configure_validation do |config|
|
|
1043
|
+
config.halt = true # Override global setting
|
|
1044
|
+
config.error_mode = :code
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
params :field1, :field2, :field3
|
|
1048
|
+
|
|
1049
|
+
validates :field1, presence: true
|
|
1050
|
+
validates :field2, presence: true # Won't run if field1 fails
|
|
1051
|
+
validates :field3, presence: true # Won't run if earlier fails
|
|
1052
|
+
|
|
1053
|
+
def call
|
|
1054
|
+
# Your logic
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
### Nested Validation Examples
|
|
1060
|
+
|
|
1061
|
+
```ruby
|
|
1062
|
+
# Hash validation
|
|
1063
|
+
class CreateUserWithProfile
|
|
1064
|
+
include Interactor
|
|
1065
|
+
include Interactor::Validation
|
|
1066
|
+
|
|
1067
|
+
params :user
|
|
1068
|
+
|
|
1069
|
+
validates :user do
|
|
1070
|
+
attribute :name, presence: true
|
|
1071
|
+
attribute :email, format: { with: /@/ }
|
|
1072
|
+
attribute :age, numericality: { greater_than: 0 }
|
|
1073
|
+
attribute :bio, length: { maximum: 500 }
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
def call
|
|
1077
|
+
User.create!(user)
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
# Usage
|
|
1082
|
+
result = CreateUserWithProfile.call(
|
|
1083
|
+
user: {
|
|
1084
|
+
name: "",
|
|
1085
|
+
email: "invalid",
|
|
1086
|
+
age: -5,
|
|
1087
|
+
bio: "a" * 600
|
|
1088
|
+
}
|
|
1089
|
+
)
|
|
1090
|
+
result.errors # => [
|
|
1091
|
+
# { attribute: "user.name", type: :blank, message: "User.name can't be blank" },
|
|
1092
|
+
# { attribute: "user.email", type: :invalid, message: "User.email is invalid" },
|
|
1093
|
+
# { attribute: "user.age", type: :greater_than, message: "User.age must be greater than 0" },
|
|
1094
|
+
# { attribute: "user.bio", type: :too_long, message: "User.bio is too long (maximum is 500 characters)" }
|
|
1095
|
+
# ]
|
|
1096
|
+
|
|
1097
|
+
# Array validation
|
|
1098
|
+
class BulkCreateItems
|
|
1099
|
+
include Interactor
|
|
1100
|
+
include Interactor::Validation
|
|
1101
|
+
|
|
1102
|
+
params :items
|
|
1103
|
+
|
|
1104
|
+
validates :items do
|
|
1105
|
+
attribute :name, presence: true
|
|
1106
|
+
attribute :price, numericality: { greater_than: 0 }
|
|
1107
|
+
attribute :quantity, numericality: { greater_than_or_equal_to: 1 }
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
def call
|
|
1111
|
+
items.each { |item| Item.create!(item) }
|
|
1112
|
+
end
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
# Usage
|
|
1116
|
+
result = BulkCreateItems.call(
|
|
1117
|
+
items: [
|
|
1118
|
+
{ name: "Widget", price: 10, quantity: 5 },
|
|
1119
|
+
{ name: "", price: -5, quantity: 0 }
|
|
1120
|
+
]
|
|
1121
|
+
)
|
|
1122
|
+
result.errors # => [
|
|
1123
|
+
# { attribute: "items[1].name", type: :blank, message: "Items[1].name can't be blank" },
|
|
1124
|
+
# { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" },
|
|
1125
|
+
# { attribute: "items[1].quantity", type: :greater_than_or_equal_to, message: "Items[1].quantity must be greater than or equal to 1" }
|
|
1126
|
+
# ]
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
### ActiveModel Integration
|
|
1130
|
+
|
|
1131
|
+
```ruby
|
|
1132
|
+
class CustomValidations
|
|
1133
|
+
include Interactor
|
|
1134
|
+
include Interactor::Validation
|
|
1135
|
+
|
|
1136
|
+
params :username, :password, :password_confirmation
|
|
1137
|
+
|
|
1138
|
+
validates :username, presence: true
|
|
1139
|
+
validates :password, presence: true
|
|
1140
|
+
|
|
1141
|
+
# Use ActiveModel's validate callback for complex logic
|
|
1142
|
+
validate :passwords_match
|
|
1143
|
+
validate :username_not_reserved
|
|
1144
|
+
|
|
1145
|
+
private
|
|
1146
|
+
|
|
1147
|
+
def passwords_match
|
|
1148
|
+
if password != password_confirmation
|
|
1149
|
+
errors.add(:password_confirmation, "doesn't match password")
|
|
1150
|
+
end
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
def username_not_reserved
|
|
1154
|
+
reserved = %w[admin root system]
|
|
1155
|
+
if reserved.include?(username&.downcase)
|
|
1156
|
+
errors.add(:username, "is reserved")
|
|
1157
|
+
end
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
result = CustomValidations.call(
|
|
1162
|
+
username: "admin",
|
|
1163
|
+
password: "secret123",
|
|
1164
|
+
password_confirmation: "different"
|
|
1165
|
+
)
|
|
1166
|
+
result.errors # => [
|
|
1167
|
+
# { attribute: :username, type: :invalid, message: "Username is reserved" },
|
|
1168
|
+
# { attribute: :password_confirmation, type: :invalid, message: "Password confirmation doesn't match password" }
|
|
1169
|
+
# ]
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
### Performance Monitoring
|
|
1173
|
+
|
|
1174
|
+
```ruby
|
|
1175
|
+
# Enable instrumentation in configuration
|
|
1176
|
+
Interactor::Validation.configure do |config|
|
|
1177
|
+
config.enable_instrumentation = true
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
# Subscribe to validation events
|
|
1181
|
+
ActiveSupport::Notifications.subscribe('validate_params.interactor_validation') do |*args|
|
|
1182
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
1183
|
+
|
|
1184
|
+
Rails.logger.info({
|
|
1185
|
+
event: 'validation',
|
|
1186
|
+
interactor: event.payload[:interactor],
|
|
1187
|
+
duration_ms: event.duration,
|
|
1188
|
+
validation_count: event.payload[:validation_count],
|
|
1189
|
+
error_count: event.payload[:error_count],
|
|
1190
|
+
halted: event.payload[:halted]
|
|
1191
|
+
}.to_json)
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1194
|
+
# Now all validations are instrumented
|
|
1195
|
+
class SlowValidation
|
|
1196
|
+
include Interactor
|
|
1197
|
+
include Interactor::Validation
|
|
1198
|
+
|
|
1199
|
+
params :field1, :field2
|
|
1200
|
+
|
|
1201
|
+
validates :field1, presence: true
|
|
1202
|
+
validates :field2, format: { with: /complex.*regex/ }
|
|
1203
|
+
|
|
1204
|
+
def call
|
|
1205
|
+
# Your logic
|
|
1206
|
+
end
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
# Logs: { "event": "validation", "interactor": "SlowValidation", "duration_ms": 2.5, ... }
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
### Real-World Example: API Endpoint
|
|
1213
|
+
|
|
1214
|
+
```ruby
|
|
1215
|
+
# Base API interactor
|
|
1216
|
+
class ApiInteractor
|
|
1217
|
+
include Interactor
|
|
1218
|
+
include Interactor::Validation
|
|
1219
|
+
|
|
1220
|
+
configure_validation do |config|
|
|
1221
|
+
config.error_mode = :code
|
|
1222
|
+
config.halt = false # Return all errors
|
|
1223
|
+
end
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
# User registration endpoint
|
|
1227
|
+
class Api::V1::RegisterUser < ApiInteractor
|
|
1228
|
+
params :email, :password, :password_confirmation, :first_name, :last_name, :terms_accepted
|
|
1229
|
+
|
|
1230
|
+
validates :email,
|
|
1231
|
+
presence: { message: "REQUIRED" },
|
|
1232
|
+
format: { with: URI::MailTo::EMAIL_REGEXP, message: "INVALID_FORMAT" }
|
|
1233
|
+
|
|
1234
|
+
validates :password,
|
|
1235
|
+
presence: { message: "REQUIRED" },
|
|
1236
|
+
length: { minimum: 12, message: "TOO_SHORT" }
|
|
1237
|
+
|
|
1238
|
+
validates :first_name, presence: { message: "REQUIRED" }
|
|
1239
|
+
validates :last_name, presence: { message: "REQUIRED" }
|
|
1240
|
+
validates :terms_accepted, boolean: true
|
|
1241
|
+
|
|
1242
|
+
def validate!
|
|
1243
|
+
# Custom validations
|
|
1244
|
+
if password != password_confirmation
|
|
1245
|
+
errors.add(:password_confirmation, "MISMATCH")
|
|
1246
|
+
end
|
|
1247
|
+
|
|
1248
|
+
if User.exists?(email: email)
|
|
1249
|
+
errors.add(:email, "ALREADY_TAKEN")
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
unless terms_accepted == true
|
|
1253
|
+
errors.add(:terms_accepted, "MUST_ACCEPT")
|
|
1254
|
+
end
|
|
1255
|
+
end
|
|
1256
|
+
|
|
1257
|
+
def call
|
|
1258
|
+
user = User.create!(
|
|
1259
|
+
email: email,
|
|
1260
|
+
password: password,
|
|
1261
|
+
first_name: first_name,
|
|
1262
|
+
last_name: last_name
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
context.user = user
|
|
1266
|
+
context.token = generate_token(user)
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
private
|
|
1270
|
+
|
|
1271
|
+
def generate_token(user)
|
|
1272
|
+
JWT.encode({ user_id: user.id }, Rails.application.secret_key_base)
|
|
1273
|
+
end
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
# Controller
|
|
1277
|
+
class Api::V1::UsersController < ApplicationController
|
|
1278
|
+
def create
|
|
1279
|
+
result = Api::V1::RegisterUser.call(user_params)
|
|
1280
|
+
|
|
1281
|
+
if result.success?
|
|
1282
|
+
render json: {
|
|
1283
|
+
user: result.user,
|
|
1284
|
+
token: result.token
|
|
1285
|
+
}, status: :created
|
|
1286
|
+
else
|
|
1287
|
+
render json: {
|
|
1288
|
+
errors: result.errors
|
|
1289
|
+
}, status: :unprocessable_entity
|
|
1290
|
+
end
|
|
1291
|
+
end
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1294
|
+
# Example error response:
|
|
1295
|
+
# {
|
|
1296
|
+
# "errors": [
|
|
1297
|
+
# { "code": "EMAIL_INVALID_FORMAT" },
|
|
1298
|
+
# { "code": "PASSWORD_TOO_SHORT" },
|
|
1299
|
+
# { "code": "TERMS_ACCEPTED_MUST_ACCEPT" }
|
|
1300
|
+
# ]
|
|
1301
|
+
# }
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
### Real-World Example: Background Job
|
|
1305
|
+
|
|
1306
|
+
```ruby
|
|
1307
|
+
# Background job with validation
|
|
1308
|
+
class ProcessOrderJob
|
|
1309
|
+
include Interactor
|
|
1310
|
+
include Interactor::Validation
|
|
1311
|
+
|
|
1312
|
+
configure_validation do |config|
|
|
1313
|
+
config.error_mode = :code
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
params :order_id, :payment_method, :shipping_address
|
|
1317
|
+
|
|
1318
|
+
validates :order_id, presence: true
|
|
1319
|
+
validates :payment_method, inclusion: { in: %w[credit_card paypal stripe] }
|
|
1320
|
+
|
|
1321
|
+
validates :shipping_address do
|
|
1322
|
+
attribute :street, presence: true
|
|
1323
|
+
attribute :city, presence: true
|
|
1324
|
+
attribute :postal_code, presence: true, format: { with: /\A\d{5}\z/ }
|
|
1325
|
+
attribute :country, inclusion: { in: %w[US CA UK] }
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
def validate!
|
|
1329
|
+
order = Order.find_by(id: order_id)
|
|
1330
|
+
|
|
1331
|
+
if order.nil?
|
|
1332
|
+
errors.add(:order_id, "NOT_FOUND")
|
|
1333
|
+
return
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
if order.processed?
|
|
1337
|
+
errors.add(:order_id, "ALREADY_PROCESSED")
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
if order.total_amount <= 0
|
|
1341
|
+
errors.add(:base, "INVALID_ORDER_AMOUNT")
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
def call
|
|
1346
|
+
order = Order.find(order_id)
|
|
1347
|
+
|
|
1348
|
+
payment = process_payment(order, payment_method)
|
|
1349
|
+
shipment = create_shipment(order, shipping_address)
|
|
1350
|
+
|
|
1351
|
+
order.update!(
|
|
1352
|
+
status: 'processed',
|
|
1353
|
+
payment_id: payment.id,
|
|
1354
|
+
shipment_id: shipment.id
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
context.order = order
|
|
1358
|
+
end
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1361
|
+
# Sidekiq job wrapper
|
|
1362
|
+
class ProcessOrderWorker
|
|
1363
|
+
include Sidekiq::Worker
|
|
1364
|
+
|
|
1365
|
+
def perform(order_id, payment_method, shipping_address)
|
|
1366
|
+
result = ProcessOrderJob.call(
|
|
1367
|
+
order_id: order_id,
|
|
1368
|
+
payment_method: payment_method,
|
|
1369
|
+
shipping_address: shipping_address
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
unless result.success?
|
|
1373
|
+
# Log errors and retry or alert
|
|
1374
|
+
Rails.logger.error("Order processing failed: #{result.errors}")
|
|
1375
|
+
raise StandardError, "Validation failed: #{result.errors}"
|
|
1376
|
+
end
|
|
1377
|
+
end
|
|
1378
|
+
end
|
|
1379
|
+
```
|
|
1380
|
+
|
|
1381
|
+
### Security Best Practices
|
|
1382
|
+
|
|
1383
|
+
```ruby
|
|
1384
|
+
# ReDoS protection
|
|
1385
|
+
class SecureValidation
|
|
1386
|
+
include Interactor
|
|
1387
|
+
include Interactor::Validation
|
|
1388
|
+
|
|
1389
|
+
configure_validation do |config|
|
|
1390
|
+
config.regex_timeout = 0.05 # 50ms timeout
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
params :input
|
|
1394
|
+
|
|
1395
|
+
# Potentially dangerous regex (nested quantifiers)
|
|
1396
|
+
validates :input, format: { with: /^(a+)+$/ }
|
|
1397
|
+
|
|
1398
|
+
def call
|
|
1399
|
+
# If regex takes > 50ms, validation fails safely
|
|
1400
|
+
end
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
# Array size protection
|
|
1404
|
+
class BulkOperation
|
|
1405
|
+
include Interactor
|
|
1406
|
+
include Interactor::Validation
|
|
1407
|
+
|
|
1408
|
+
configure_validation do |config|
|
|
1409
|
+
config.max_array_size = 100 # Limit to 100 items
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
params :items
|
|
1413
|
+
|
|
1414
|
+
validates :items do
|
|
1415
|
+
attribute :name, presence: true
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
def call
|
|
1419
|
+
# If items.length > 100, validation fails
|
|
1420
|
+
items.each { |item| process(item) }
|
|
1421
|
+
end
|
|
1422
|
+
end
|
|
1423
|
+
|
|
1424
|
+
# Sanitize error messages before displaying
|
|
1425
|
+
class UserInput
|
|
1426
|
+
include Interactor
|
|
1427
|
+
include Interactor::Validation
|
|
1428
|
+
|
|
1429
|
+
params :content
|
|
1430
|
+
|
|
1431
|
+
validates :content, presence: true
|
|
1432
|
+
|
|
1433
|
+
def call
|
|
1434
|
+
# Always sanitize user input
|
|
1435
|
+
sanitized = ActionController::Base.helpers.sanitize(content)
|
|
1436
|
+
Content.create!(body: sanitized)
|
|
1437
|
+
end
|
|
1438
|
+
end
|
|
1439
|
+
```
|
|
1440
|
+
|
|
1441
|
+
---
|
|
1442
|
+
|
|
1443
|
+
## Requirements
|
|
1444
|
+
|
|
1445
|
+
- Ruby >= 3.2.0
|
|
1446
|
+
- Interactor ~> 3.0
|
|
1447
|
+
- ActiveModel >= 6.0
|
|
1448
|
+
- ActiveSupport >= 6.0
|
|
1449
|
+
|
|
1450
|
+
## Development
|
|
1451
|
+
|
|
1452
|
+
```bash
|
|
1453
|
+
bin/setup # Install dependencies
|
|
1454
|
+
bundle exec rspec # Run tests (231 examples)
|
|
1455
|
+
bundle exec rubocop # Lint code
|
|
1456
|
+
bin/console # Interactive console
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
### Benchmarking
|
|
1460
|
+
|
|
1461
|
+
```bash
|
|
1462
|
+
bundle exec ruby benchmark/validation_benchmark.rb
|
|
1463
|
+
```
|
|
329
1464
|
|
|
330
1465
|
## License
|
|
331
1466
|
|