interactor-validation 0.3.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +229 -1438
- data/benchmark/validation_benchmark.rb +0 -3
- data/lib/interactor/validation/configuration.rb +3 -27
- data/lib/interactor/validation/core_ext.rb +120 -0
- data/lib/interactor/validation/errors.rb +49 -0
- data/lib/interactor/validation/params.rb +6 -16
- data/lib/interactor/validation/validates.rb +118 -826
- data/lib/interactor/validation/validators/array.rb +20 -0
- data/lib/interactor/validation/validators/boolean.rb +15 -0
- data/lib/interactor/validation/validators/format.rb +17 -0
- data/lib/interactor/validation/validators/hash.rb +43 -0
- data/lib/interactor/validation/validators/inclusion.rb +17 -0
- data/lib/interactor/validation/validators/length.rb +46 -0
- data/lib/interactor/validation/validators/numeric.rb +46 -0
- data/lib/interactor/validation/validators/presence.rb +16 -0
- data/lib/interactor/validation/version.rb +1 -1
- data/lib/interactor/validation.rb +13 -44
- data/smoke_test.rb +252 -0
- metadata +28 -44
- data/lib/interactor/validation/error_codes.rb +0 -51
data/README.md
CHANGED
|
@@ -1,28 +1,45 @@
|
|
|
1
1
|
# Interactor::Validation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Structured, lightweight parameter validation designed specifically for [Interactor](https://github.com/collectiveidea/interactor) service objects.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
- **Built for Interactor** - Seamless integration with service objects
|
|
8
|
+
- **Comprehensive validators** - Presence, format, length, inclusion, numericality, boolean
|
|
9
|
+
- **Nested validation** - Validate complex hashes and arrays
|
|
10
|
+
- **Custom validations** - Override `validate!` for business logic
|
|
11
|
+
- **Flexible error formats** - Human-readable messages or machine-readable codes
|
|
12
|
+
- **Zero dependencies** - Just Interactor and Ruby stdlib
|
|
13
|
+
- **Configurable** - Control validation behavior and error handling
|
|
14
|
+
|
|
15
|
+
## Table of Contents
|
|
16
|
+
|
|
17
|
+
- [Installation](#installation)
|
|
18
|
+
- [Quick Example](#quick-example)
|
|
19
|
+
- [Validations](#validations)
|
|
20
|
+
- [Presence](#presence)
|
|
21
|
+
- [Format](#format)
|
|
22
|
+
- [Length](#length)
|
|
23
|
+
- [Inclusion](#inclusion)
|
|
24
|
+
- [Numericality](#numericality)
|
|
25
|
+
- [Boolean](#boolean)
|
|
26
|
+
- [Nested Validation](#nested-validation)
|
|
27
|
+
- [Custom Validations](#custom-validations)
|
|
28
|
+
- [Configuration](#configuration)
|
|
29
|
+
- [Error Format](#error-format)
|
|
30
|
+
- [Parameter Delegation](#parameter-delegation)
|
|
31
|
+
- [Requirements](#requirements)
|
|
32
|
+
- [Design Philosophy](#design-philosophy)
|
|
16
33
|
|
|
17
34
|
## Installation
|
|
18
35
|
|
|
19
|
-
Add
|
|
36
|
+
Add to your Gemfile:
|
|
20
37
|
|
|
21
38
|
```ruby
|
|
22
39
|
gem "interactor-validation"
|
|
23
40
|
```
|
|
24
41
|
|
|
25
|
-
|
|
42
|
+
Then run:
|
|
26
43
|
|
|
27
44
|
```bash
|
|
28
45
|
bundle install
|
|
@@ -32,122 +49,86 @@ bundle install
|
|
|
32
49
|
|
|
33
50
|
### Quick Example
|
|
34
51
|
|
|
52
|
+
Define validations directly in your interactor:
|
|
53
|
+
|
|
35
54
|
```ruby
|
|
36
55
|
class CreateUser
|
|
37
56
|
include Interactor
|
|
38
57
|
include Interactor::Validation
|
|
39
58
|
|
|
40
|
-
# Declare parameters
|
|
41
|
-
params :email, :username, :age
|
|
59
|
+
# Declare expected parameters
|
|
60
|
+
params :email, :username, :age
|
|
42
61
|
|
|
43
|
-
#
|
|
62
|
+
# Define validation rules
|
|
44
63
|
validates :email, presence: true, format: { with: /@/ }
|
|
45
|
-
validates :username, presence: true
|
|
64
|
+
validates :username, presence: true, length: { maximum: 100 }
|
|
46
65
|
validates :age, numericality: { greater_than: 0 }
|
|
47
|
-
validates :terms_accepted, boolean: true
|
|
48
66
|
|
|
49
67
|
def call
|
|
50
68
|
# Validations run automatically before this
|
|
51
69
|
User.create!(email: email, username: username, age: age)
|
|
52
70
|
end
|
|
53
71
|
end
|
|
54
|
-
|
|
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")
|
|
66
|
-
result.failure? # => true
|
|
67
|
-
result.errors # => [
|
|
68
|
-
# { attribute: :email, type: :blank, message: "Email can't be blank" },
|
|
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" }
|
|
72
|
-
# ]
|
|
73
72
|
```
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
#### Presence
|
|
74
|
+
When validation fails, the interactor automatically halts with errors:
|
|
78
75
|
|
|
79
76
|
```ruby
|
|
80
|
-
|
|
81
|
-
#
|
|
77
|
+
result = CreateUser.call(email: "", username: "", age: -5)
|
|
78
|
+
result.failure? # => true
|
|
79
|
+
result.errors # => Array of error hashes
|
|
82
80
|
```
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
**Default mode** (human-readable messages):
|
|
86
83
|
```ruby
|
|
87
|
-
|
|
88
|
-
#
|
|
84
|
+
result.errors
|
|
85
|
+
# => [
|
|
86
|
+
# { attribute: :email, type: :blank, message: "Email can't be blank" },
|
|
87
|
+
# { attribute: :username, type: :blank, message: "Username can't be blank" },
|
|
88
|
+
# { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
|
|
89
|
+
# ]
|
|
89
90
|
```
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
**Code mode** (machine-readable codes):
|
|
93
93
|
```ruby
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
94
|
+
# Set mode to :code in configuration
|
|
95
|
+
Interactor::Validation.configure { |config| config.mode = :code }
|
|
105
96
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
#
|
|
97
|
+
result.errors
|
|
98
|
+
# => [
|
|
99
|
+
# { code: 'EMAIL_IS_REQUIRED' },
|
|
100
|
+
# { code: 'USERNAME_IS_REQUIRED' },
|
|
101
|
+
# { code: 'AGE_MUST_BE_GREATER_THAN_0' }
|
|
102
|
+
# ]
|
|
109
103
|
```
|
|
110
104
|
|
|
111
|
-
|
|
105
|
+
## Validations
|
|
112
106
|
|
|
113
|
-
|
|
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
|
-
```
|
|
107
|
+
All validators support custom error messages via the `message` option.
|
|
121
108
|
|
|
122
109
|
### Presence
|
|
123
110
|
|
|
124
|
-
Validates that a value is not nil or
|
|
111
|
+
Validates that a value is not `nil`, empty string, or blank.
|
|
125
112
|
|
|
126
113
|
```ruby
|
|
127
114
|
validates :name, presence: true
|
|
128
115
|
validates :email, presence: { message: "Email is required" }
|
|
129
116
|
```
|
|
130
117
|
|
|
131
|
-
**Errors:**
|
|
132
|
-
- Default mode: `{ attribute: :name, type: :blank, message: "Name can't be blank" }`
|
|
133
|
-
- Code mode: `{ code: "NAME_IS_REQUIRED" }`
|
|
134
|
-
|
|
135
118
|
### Format
|
|
136
119
|
|
|
137
120
|
Validates that a value matches a regular expression pattern.
|
|
138
121
|
|
|
139
122
|
```ruby
|
|
140
123
|
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: "
|
|
124
|
+
validates :username, format: { with: /\A[a-z0-9_]+\z/, message: "Invalid username" }
|
|
142
125
|
```
|
|
143
126
|
|
|
144
|
-
**Errors:**
|
|
145
|
-
- Default mode: `{ attribute: :email, type: :invalid, message: "Email is invalid" }`
|
|
146
|
-
- Code mode: `{ code: "EMAIL_INVALID_FORMAT" }`
|
|
147
|
-
|
|
148
127
|
### Length
|
|
149
128
|
|
|
150
|
-
Validates the length of a string
|
|
129
|
+
Validates the length of a string.
|
|
130
|
+
|
|
131
|
+
**Options:** `minimum`, `maximum`, `is`
|
|
151
132
|
|
|
152
133
|
```ruby
|
|
153
134
|
validates :password, length: { minimum: 8, maximum: 128 }
|
|
@@ -155,48 +136,31 @@ validates :code, length: { is: 6 }
|
|
|
155
136
|
validates :bio, length: { maximum: 500 }
|
|
156
137
|
```
|
|
157
138
|
|
|
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
|
-
|
|
165
139
|
### Inclusion
|
|
166
140
|
|
|
167
|
-
Validates that a value is in a
|
|
141
|
+
Validates that a value is included in a set of allowed values.
|
|
168
142
|
|
|
169
143
|
```ruby
|
|
170
144
|
validates :status, inclusion: { in: %w[active pending inactive] }
|
|
171
|
-
validates :role, inclusion: { in: ["admin", "user", "guest"] }
|
|
145
|
+
validates :role, inclusion: { in: ["admin", "user", "guest"], message: "Invalid role" }
|
|
172
146
|
```
|
|
173
147
|
|
|
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
|
-
|
|
178
148
|
### Numericality
|
|
179
149
|
|
|
180
|
-
Validates
|
|
150
|
+
Validates numeric values and comparisons.
|
|
151
|
+
|
|
152
|
+
**Options:** `greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `equal_to`
|
|
181
153
|
|
|
182
154
|
```ruby
|
|
183
155
|
validates :age, numericality: { greater_than: 0 }
|
|
184
156
|
validates :price, numericality: { greater_than_or_equal_to: 0 }
|
|
185
157
|
validates :quantity, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
|
|
186
158
|
validates :rating, numericality: { equal_to: 5 }
|
|
187
|
-
validates :count, numericality: true # Just
|
|
188
|
-
```
|
|
159
|
+
validates :count, numericality: true # Just verify it's numeric
|
|
189
160
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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" }`
|
|
161
|
+
# Shorthand: 'numeric' alias
|
|
162
|
+
validates :age, numeric: { greater_than: 0 }
|
|
163
|
+
```
|
|
200
164
|
|
|
201
165
|
### Boolean
|
|
202
166
|
|
|
@@ -207,1446 +171,273 @@ validates :is_active, boolean: true
|
|
|
207
171
|
validates :terms_accepted, boolean: true
|
|
208
172
|
```
|
|
209
173
|
|
|
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
|
-
|
|
214
174
|
### Nested Validation
|
|
215
175
|
|
|
216
|
-
Validate nested hashes and arrays
|
|
217
|
-
|
|
218
|
-
**Optional Nested Validation (parameter can be nil):**
|
|
219
|
-
|
|
220
|
-
```ruby
|
|
221
|
-
params :filters
|
|
222
|
-
validates :filters do
|
|
223
|
-
attribute :type, presence: true
|
|
224
|
-
attribute :value
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
# When filters is nil or missing - succeeds
|
|
228
|
-
result = SearchItems.call(filters: nil)
|
|
229
|
-
result.success? # => true
|
|
230
|
-
|
|
231
|
-
# When filters is present - validates nested attributes
|
|
232
|
-
result = SearchItems.call(filters: { type: "category" })
|
|
233
|
-
result.success? # => true
|
|
234
|
-
|
|
235
|
-
# When filters is present but invalid - fails
|
|
236
|
-
result = SearchItems.call(filters: { value: "test" })
|
|
237
|
-
result.errors # => [{ attribute: "filters.type", type: :blank, message: "Filters.type can't be blank" }]
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
**Required Nested Validation (parameter must be present):**
|
|
241
|
-
|
|
242
|
-
```ruby
|
|
243
|
-
params :user
|
|
244
|
-
validates :user, presence: true do
|
|
245
|
-
attribute :name, presence: true
|
|
246
|
-
attribute :email, format: { with: /@/ }
|
|
247
|
-
attribute :age, numericality: { greater_than: 0 }
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# When user is nil - fails with presence error
|
|
251
|
-
result = CreateUser.call(user: nil)
|
|
252
|
-
result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
|
|
253
|
-
|
|
254
|
-
# When user is empty hash - fails with presence error (empty hashes are not .present?)
|
|
255
|
-
result = CreateUser.call(user: {})
|
|
256
|
-
result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
|
|
257
|
-
|
|
258
|
-
# When user is present - validates nested attributes
|
|
259
|
-
result = CreateUser.call(user: { name: "", email: "bad", age: -1 })
|
|
260
|
-
result.errors # => [
|
|
261
|
-
# { attribute: "user.name", type: :blank, message: "User.name can't be blank" },
|
|
262
|
-
# { attribute: "user.email", type: :invalid, message: "User.email is invalid" },
|
|
263
|
-
# { attribute: "user.age", type: :greater_than, message: "User.age must be greater than 0" }
|
|
264
|
-
# ]
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
**Array Validation:**
|
|
268
|
-
|
|
269
|
-
```ruby
|
|
270
|
-
params :items
|
|
271
|
-
validates :items do
|
|
272
|
-
attribute :name, presence: true
|
|
273
|
-
attribute :price, numericality: { greater_than: 0 }
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Usage
|
|
277
|
-
result = ProcessItems.call(items: [
|
|
278
|
-
{ name: "Widget", price: 10 },
|
|
279
|
-
{ name: "", price: -5 }
|
|
280
|
-
])
|
|
281
|
-
result.errors # => [
|
|
282
|
-
# { attribute: "items[1].name", type: :blank, message: "Items[1].name can't be blank" },
|
|
283
|
-
# { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" }
|
|
284
|
-
# ]
|
|
285
|
-
|
|
286
|
-
# Optional array (can be nil)
|
|
287
|
-
result = ProcessItems.call(items: nil)
|
|
288
|
-
result.success? # => true
|
|
289
|
-
|
|
290
|
-
# Required array (must be present)
|
|
291
|
-
validates :items, presence: true do
|
|
292
|
-
attribute :name, presence: true
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
result = ProcessItems.call(items: nil)
|
|
296
|
-
result.errors # => [{ attribute: :items, type: :blank, message: "Items can't be blank" }]
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
---
|
|
300
|
-
|
|
301
|
-
# Detailed Documentation
|
|
302
|
-
|
|
303
|
-
## Error Formats
|
|
304
|
-
|
|
305
|
-
Choose between two error format modes:
|
|
306
|
-
|
|
307
|
-
### Default Mode (ActiveModel-style)
|
|
308
|
-
|
|
309
|
-
Human-readable errors with full context - ideal for forms and user-facing applications:
|
|
176
|
+
Validate complex nested structures like hashes and arrays using block syntax.
|
|
310
177
|
|
|
311
|
-
|
|
312
|
-
# This is the default, no configuration needed
|
|
313
|
-
result.errors # => [
|
|
314
|
-
# { attribute: :email, type: :blank, message: "Email can't be blank" },
|
|
315
|
-
# { attribute: :username, type: :too_short, message: "Username is too short" }
|
|
316
|
-
# ]
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
### Code Mode
|
|
320
|
-
|
|
321
|
-
Structured error codes - ideal for APIs and internationalization:
|
|
322
|
-
|
|
323
|
-
```ruby
|
|
324
|
-
configure_validation do |config|
|
|
325
|
-
config.error_mode = :code
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
result.errors # => [
|
|
329
|
-
# { code: "EMAIL_IS_REQUIRED" },
|
|
330
|
-
# { code: "USERNAME_BELOW_MIN_LENGTH_3" }
|
|
331
|
-
# ]
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
### Custom Messages
|
|
335
|
-
|
|
336
|
-
Provide custom error messages for any validation:
|
|
337
|
-
|
|
338
|
-
```ruby
|
|
339
|
-
# Works with both modes
|
|
340
|
-
validates :username, presence: { message: "Username is required" }
|
|
341
|
-
validates :email, format: { with: /@/, message: "Invalid email format" }
|
|
342
|
-
|
|
343
|
-
# In :code mode, custom messages become part of the code
|
|
344
|
-
configure_validation { |c| c.error_mode = :code }
|
|
345
|
-
validates :age, numericality: { greater_than: 0, message: "INVALID_AGE" }
|
|
346
|
-
# => { code: "AGE_INVALID_AGE" }
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
## Configuration
|
|
178
|
+
#### Hash Validation
|
|
350
179
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
Configure behavior for all interactors:
|
|
354
|
-
|
|
355
|
-
```ruby
|
|
356
|
-
# config/initializers/interactor_validation.rb
|
|
357
|
-
Interactor::Validation.configure do |config|
|
|
358
|
-
# Error format mode - Available options:
|
|
359
|
-
# :default - ActiveModel-style messages (default)
|
|
360
|
-
# { attribute: :email, type: :blank, message: "Email can't be blank" }
|
|
361
|
-
# :code - Structured error codes for APIs
|
|
362
|
-
# { code: "EMAIL_IS_REQUIRED" }
|
|
363
|
-
config.error_mode = :default
|
|
364
|
-
|
|
365
|
-
# Stop at first error for better performance
|
|
366
|
-
config.halt = false # Set to true to stop on first validation error
|
|
367
|
-
|
|
368
|
-
# Skip custom validate! hook when parameter validation fails
|
|
369
|
-
config.skip_validate = true # Set to false to always run validate! hook
|
|
370
|
-
|
|
371
|
-
# Security settings
|
|
372
|
-
config.regex_timeout = 0.1 # Regex timeout in seconds (ReDoS protection)
|
|
373
|
-
config.max_array_size = 1000 # Max array size for nested validation
|
|
374
|
-
|
|
375
|
-
# Performance settings
|
|
376
|
-
config.cache_regex_patterns = true
|
|
377
|
-
|
|
378
|
-
# Monitoring
|
|
379
|
-
config.enable_instrumentation = false
|
|
380
|
-
end
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
### Per-Interactor Configuration
|
|
384
|
-
|
|
385
|
-
Override settings for specific interactors:
|
|
180
|
+
Use a block to define validations for hash attributes:
|
|
386
181
|
|
|
387
182
|
```ruby
|
|
388
183
|
class CreateUser
|
|
389
184
|
include Interactor
|
|
390
185
|
include Interactor::Validation
|
|
391
186
|
|
|
392
|
-
|
|
393
|
-
config.error_mode = :code
|
|
394
|
-
config.halt = true
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
validates :username, presence: true
|
|
398
|
-
validates :email, presence: true
|
|
399
|
-
end
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
## Advanced Features
|
|
403
|
-
|
|
404
|
-
### Parameter Declaration
|
|
405
|
-
|
|
406
|
-
Declare parameters for automatic delegation to context:
|
|
407
|
-
|
|
408
|
-
```ruby
|
|
409
|
-
params :user_id, :action
|
|
410
|
-
|
|
411
|
-
def call
|
|
412
|
-
# Access directly instead of context.user_id
|
|
413
|
-
user = User.find(user_id)
|
|
414
|
-
user.perform(action)
|
|
415
|
-
end
|
|
416
|
-
```
|
|
417
|
-
|
|
418
|
-
### Halt on First Error
|
|
419
|
-
|
|
420
|
-
Stop validation early for better performance and user experience:
|
|
187
|
+
params :user
|
|
421
188
|
|
|
422
|
-
|
|
189
|
+
validates :user, presence: true do
|
|
190
|
+
attribute :name, presence: true
|
|
191
|
+
attribute :email, format: { with: /@/ }
|
|
192
|
+
attribute :age, numericality: { greater_than: 0 }
|
|
193
|
+
end
|
|
423
194
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
195
|
+
def call
|
|
196
|
+
User.create!(user)
|
|
197
|
+
end
|
|
427
198
|
end
|
|
428
199
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
200
|
+
result = CreateUser.call(user: { name: "", email: "bad" })
|
|
201
|
+
result.errors
|
|
202
|
+
# => [
|
|
203
|
+
# { attribute: :"user.name", type: :blank, message: "User name can't be blank" },
|
|
204
|
+
# { attribute: :"user.email", type: :invalid, message: "User email is invalid" }
|
|
205
|
+
# ]
|
|
432
206
|
```
|
|
433
207
|
|
|
434
|
-
####
|
|
208
|
+
#### Array Validation
|
|
435
209
|
|
|
436
|
-
|
|
210
|
+
Validate each element in an array by passing a block without additional options:
|
|
437
211
|
|
|
438
212
|
```ruby
|
|
439
|
-
class
|
|
213
|
+
class BulkCreateItems
|
|
440
214
|
include Interactor
|
|
441
215
|
include Interactor::Validation
|
|
442
216
|
|
|
443
|
-
params :
|
|
444
|
-
|
|
445
|
-
validates :payment_method, inclusion: { in: %w[credit_card paypal] }
|
|
446
|
-
|
|
447
|
-
validate :check_order_exists
|
|
448
|
-
|
|
449
|
-
def check_order_exists
|
|
450
|
-
order = Order.find_by(id: context.order_id)
|
|
451
|
-
|
|
452
|
-
if order.nil?
|
|
453
|
-
# Halt immediately - no point validating payment if order doesn't exist
|
|
454
|
-
errors.add(:order_id, "not found", halt: true)
|
|
455
|
-
return
|
|
456
|
-
end
|
|
217
|
+
params :items
|
|
457
218
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
end
|
|
219
|
+
validates :items do
|
|
220
|
+
attribute :name, presence: true
|
|
221
|
+
attribute :price, numericality: { greater_than: 0 }
|
|
462
222
|
end
|
|
463
223
|
|
|
464
224
|
def call
|
|
465
|
-
|
|
225
|
+
items.each { |item| Item.create!(item) }
|
|
466
226
|
end
|
|
467
227
|
end
|
|
468
|
-
```
|
|
469
228
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
- Provide cleaner error messages (only the most relevant error)
|
|
229
|
+
result = BulkCreateItems.call(items: [
|
|
230
|
+
{ name: "Widget", price: 10 },
|
|
231
|
+
{ name: "", price: -5 }
|
|
232
|
+
])
|
|
233
|
+
result.errors
|
|
234
|
+
# => [
|
|
235
|
+
# { attribute: :"items[1].name", type: :blank, message: "Items[1] name can't be blank" },
|
|
236
|
+
# { attribute: :"items[1].price", type: :greater_than, message: "Items[1] price must be greater than 0" }
|
|
237
|
+
# ]
|
|
238
|
+
```
|
|
481
239
|
|
|
482
|
-
|
|
240
|
+
## Custom Validations
|
|
483
241
|
|
|
484
|
-
|
|
242
|
+
Override `validate!` for custom business logic:
|
|
485
243
|
|
|
486
244
|
```ruby
|
|
487
|
-
|
|
488
|
-
class CreateUser
|
|
245
|
+
class CreateOrder
|
|
489
246
|
include Interactor
|
|
490
247
|
include Interactor::Validation
|
|
491
248
|
|
|
492
|
-
|
|
493
|
-
config.skip_validate = true # Default
|
|
494
|
-
end
|
|
495
|
-
|
|
496
|
-
params :username, :email
|
|
249
|
+
params :product_id, :quantity, :user_id
|
|
497
250
|
|
|
498
|
-
validates :
|
|
499
|
-
validates :
|
|
251
|
+
validates :product_id, presence: true
|
|
252
|
+
validates :quantity, numericality: { greater_than: 0 }
|
|
253
|
+
validates :user_id, presence: true
|
|
500
254
|
|
|
501
255
|
def validate!
|
|
502
|
-
#
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
256
|
+
super # Run parameter validations first
|
|
257
|
+
|
|
258
|
+
product = Product.find_by(id: product_id)
|
|
259
|
+
if product.nil?
|
|
260
|
+
errors.add(:product_id, :not_found, message: "Product not found")
|
|
261
|
+
elsif product.stock < quantity
|
|
262
|
+
errors.add(:quantity, :insufficient, message: "Insufficient stock")
|
|
263
|
+
end
|
|
506
264
|
end
|
|
507
265
|
|
|
508
266
|
def call
|
|
509
|
-
|
|
267
|
+
Order.create!(product_id: product_id, quantity: quantity, user_id: user_id)
|
|
510
268
|
end
|
|
511
269
|
end
|
|
270
|
+
```
|
|
512
271
|
|
|
513
|
-
|
|
514
|
-
class CreateUserAlwaysValidate
|
|
515
|
-
include Interactor
|
|
516
|
-
include Interactor::Validation
|
|
517
|
-
|
|
518
|
-
configure_validation do |config|
|
|
519
|
-
config.skip_validate = false # Run validate! even with param errors
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
params :username, :email
|
|
523
|
-
|
|
524
|
-
validates :username, presence: true
|
|
525
|
-
validates :email, presence: true
|
|
272
|
+
## Configuration
|
|
526
273
|
|
|
527
|
-
|
|
528
|
-
# This WILL run even if username or email is missing
|
|
529
|
-
# Useful when you need to collect all possible errors
|
|
530
|
-
user_exists = User.exists?(username: username) if username.present?
|
|
531
|
-
errors.add(:username, "already taken") if user_exists
|
|
532
|
-
end
|
|
274
|
+
Configure global validation behavior in an initializer or before your interactors are loaded:
|
|
533
275
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
276
|
+
```ruby
|
|
277
|
+
Interactor::Validation.configure do |config|
|
|
278
|
+
config.skip_validate = true # Skip custom validate! if params fail (default: true)
|
|
279
|
+
config.mode = :default # Error format: :default or :code (default: :default)
|
|
280
|
+
config.halt = false # Stop on first error (default: false)
|
|
537
281
|
end
|
|
538
282
|
```
|
|
539
283
|
|
|
540
|
-
|
|
541
|
-
- Skip expensive database lookups when basic params are invalid
|
|
542
|
-
- Avoid unnecessary API calls when required fields are missing
|
|
543
|
-
- Improve performance in high-traffic endpoints
|
|
544
|
-
- Fail fast with only parameter validation errors
|
|
284
|
+
### Configuration Options
|
|
545
285
|
|
|
546
|
-
|
|
547
|
-
- Collect all possible validation errors in a single request
|
|
548
|
-
- Always run business logic validations regardless of param state
|
|
549
|
-
- Provide comprehensive error feedback to users
|
|
286
|
+
#### skip_validate
|
|
550
287
|
|
|
551
|
-
|
|
288
|
+
**Default:** `true`
|
|
552
289
|
|
|
553
|
-
|
|
290
|
+
Skip the custom `validate!` method when parameter validations fail. This prevents executing expensive custom validation logic (like database queries) when basic parameter checks have already failed.
|
|
554
291
|
|
|
555
292
|
```ruby
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
293
|
+
Interactor::Validation.configure do |config|
|
|
294
|
+
config.skip_validate = false # Always run custom validate! even if params fail
|
|
295
|
+
end
|
|
296
|
+
```
|
|
559
297
|
|
|
560
|
-
|
|
298
|
+
#### mode
|
|
561
299
|
|
|
562
|
-
|
|
563
|
-
validates :username, presence: true
|
|
300
|
+
**Default:** `:default`
|
|
564
301
|
|
|
565
|
-
|
|
302
|
+
Controls error message format. Choose between human-readable messages (`:default`) or machine-readable codes (`:code`).
|
|
566
303
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
304
|
+
**Default mode** - Human-readable messages with full context:
|
|
305
|
+
```ruby
|
|
306
|
+
Interactor::Validation.configure do |config|
|
|
307
|
+
config.mode = :default
|
|
570
308
|
end
|
|
571
|
-
```
|
|
572
|
-
|
|
573
|
-
### Performance Monitoring
|
|
574
309
|
|
|
575
|
-
|
|
310
|
+
result = CreateUser.call(email: "", age: -5)
|
|
311
|
+
result.errors
|
|
312
|
+
# => [
|
|
313
|
+
# { attribute: :email, type: :blank, message: "Email can't be blank" },
|
|
314
|
+
# { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
|
|
315
|
+
# ]
|
|
316
|
+
```
|
|
576
317
|
|
|
318
|
+
**Code mode** - Minimal error codes for API responses:
|
|
577
319
|
```ruby
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
ActiveSupport::Notifications.subscribe('validate_params.interactor_validation') do |*args|
|
|
581
|
-
event = ActiveSupport::Notifications::Event.new(*args)
|
|
582
|
-
Rails.logger.info "Validation: #{event.duration}ms (#{event.payload[:interactor]})"
|
|
320
|
+
Interactor::Validation.configure do |config|
|
|
321
|
+
config.mode = :code
|
|
583
322
|
end
|
|
584
|
-
```
|
|
585
323
|
|
|
586
|
-
|
|
324
|
+
result = CreateUser.call(email: "", age: -5)
|
|
325
|
+
result.errors
|
|
326
|
+
# => [
|
|
327
|
+
# { code: "EMAIL_IS_REQUIRED" },
|
|
328
|
+
# { code: "AGE_GREATER_THAN" }
|
|
329
|
+
# ]
|
|
330
|
+
```
|
|
587
331
|
|
|
588
|
-
|
|
332
|
+
#### halt
|
|
589
333
|
|
|
590
|
-
|
|
334
|
+
**Default:** `false`
|
|
591
335
|
|
|
592
|
-
|
|
336
|
+
Stop validation on the first error instead of collecting all validation failures.
|
|
593
337
|
|
|
594
338
|
```ruby
|
|
595
|
-
|
|
339
|
+
Interactor::Validation.configure do |config|
|
|
340
|
+
config.halt = true
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
result = CreateUser.call(email: "", username: "", age: -5)
|
|
344
|
+
result.errors.size # => 1 (only the first error is captured)
|
|
596
345
|
```
|
|
597
346
|
|
|
598
|
-
|
|
347
|
+
## Error Format
|
|
599
348
|
|
|
600
|
-
|
|
349
|
+
Validations run automatically before the `call` method executes. If any validation fails, the interactor halts with `context.fail!` and populates `context.errors`.
|
|
601
350
|
|
|
602
|
-
|
|
351
|
+
Errors are returned as an array of hashes. The format depends on the `mode` configuration:
|
|
603
352
|
|
|
353
|
+
**Default mode** (verbose with full context):
|
|
604
354
|
```ruby
|
|
605
|
-
|
|
355
|
+
{
|
|
356
|
+
attribute: :email, # The field that failed
|
|
357
|
+
type: :blank, # The validation type
|
|
358
|
+
message: "Email can't be blank" # Human-readable message
|
|
359
|
+
}
|
|
606
360
|
```
|
|
607
361
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
All validation operations are thread-safe for use with Puma, Sidekiq, etc.
|
|
611
|
-
|
|
612
|
-
### Best Practices
|
|
613
|
-
|
|
614
|
-
- Use simple regex patterns (avoid nested quantifiers)
|
|
615
|
-
- Sanitize error messages before displaying in HTML
|
|
616
|
-
- Set appropriate `max_array_size` limits for your use case
|
|
617
|
-
- Enable instrumentation to monitor performance
|
|
618
|
-
- Review [SECURITY.md](SECURITY.md) for detailed information
|
|
619
|
-
|
|
620
|
-
## Custom Validation Hook
|
|
621
|
-
|
|
622
|
-
Use the `validate!` hook to add custom validation logic that goes beyond standard validations.
|
|
623
|
-
|
|
624
|
-
### Basic Usage
|
|
625
|
-
|
|
626
|
-
The `validate!` method runs automatically after parameter validations:
|
|
627
|
-
|
|
362
|
+
**Code mode** (minimal for API responses):
|
|
628
363
|
```ruby
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
params :product_id, :quantity, :user_id
|
|
634
|
-
|
|
635
|
-
validates :product_id, presence: true
|
|
636
|
-
validates :quantity, numericality: { greater_than: 0 }
|
|
637
|
-
validates :user_id, presence: true
|
|
638
|
-
|
|
639
|
-
def validate!
|
|
640
|
-
# Custom business logic validation
|
|
641
|
-
product = Product.find_by(id: product_id)
|
|
642
|
-
|
|
643
|
-
if product.nil?
|
|
644
|
-
errors.add(:product_id, "PRODUCT_NOT_FOUND")
|
|
645
|
-
elsif product.stock < quantity
|
|
646
|
-
errors.add(:quantity, "INSUFFICIENT_STOCK")
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
user = User.find_by(id: user_id)
|
|
650
|
-
if user && !user.active?
|
|
651
|
-
errors.add(:user_id, "USER_NOT_ACTIVE")
|
|
652
|
-
end
|
|
653
|
-
end
|
|
364
|
+
{
|
|
365
|
+
code: "EMAIL_IS_REQUIRED" # Machine-readable error code (SCREAMING_SNAKE_CASE)
|
|
366
|
+
}
|
|
367
|
+
```
|
|
654
368
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
369
|
+
Access errors via `result.errors` after calling an interactor:
|
|
370
|
+
```ruby
|
|
371
|
+
result = CreateUser.call(email: "")
|
|
372
|
+
result.failure?
|
|
373
|
+
# => true
|
|
660
374
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
375
|
+
result.errors
|
|
376
|
+
# => [
|
|
377
|
+
# { attribute: :email, type: :blank, message: "Email can't be blank" }
|
|
378
|
+
# ]
|
|
665
379
|
```
|
|
666
380
|
|
|
667
|
-
|
|
381
|
+
## Parameter Delegation
|
|
668
382
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
1. **Parameter validations** (`validates :field, ...`)
|
|
672
|
-
2. **Custom validate! hook** (your custom logic)
|
|
673
|
-
3. **call method** (only if no errors)
|
|
383
|
+
The `params` macro provides convenient access to context values, allowing you to reference parameters directly without the `context.` prefix.
|
|
674
384
|
|
|
675
385
|
```ruby
|
|
676
|
-
class
|
|
386
|
+
class UpdateUser
|
|
677
387
|
include Interactor
|
|
678
388
|
include Interactor::Validation
|
|
679
389
|
|
|
680
|
-
params :
|
|
681
|
-
|
|
682
|
-
validates :amount, numericality: { greater_than: 0 }
|
|
683
|
-
validates :card_token, presence: true
|
|
390
|
+
params :user_id, :email
|
|
684
391
|
|
|
685
|
-
|
|
686
|
-
# This runs AFTER parameter validations pass
|
|
687
|
-
# Check payment gateway availability
|
|
688
|
-
errors.add(:base, "PAYMENT_GATEWAY_UNAVAILABLE") unless PaymentGateway.available?
|
|
689
|
-
end
|
|
392
|
+
validates :email, format: { with: /@/ }
|
|
690
393
|
|
|
691
394
|
def call
|
|
692
|
-
#
|
|
693
|
-
|
|
395
|
+
# Access params directly instead of context.user_id, context.email
|
|
396
|
+
user = User.find(user_id)
|
|
397
|
+
user.update!(email: email)
|
|
694
398
|
end
|
|
695
399
|
end
|
|
696
400
|
```
|
|
697
401
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
Works with both `:default` and `:code` error modes:
|
|
701
|
-
|
|
702
|
-
```ruby
|
|
703
|
-
# With :default mode (ActiveModel-style messages)
|
|
704
|
-
class UpdateProfile
|
|
705
|
-
include Interactor
|
|
706
|
-
include Interactor::Validation
|
|
402
|
+
This is purely syntactic sugar - under the hood, `user_id` and `email` still reference `context.user_id` and `context.email`.
|
|
707
403
|
|
|
708
|
-
|
|
404
|
+
## Requirements
|
|
709
405
|
|
|
710
|
-
|
|
406
|
+
- Ruby >= 3.2.0
|
|
407
|
+
- Interactor ~> 3.0
|
|
711
408
|
|
|
712
|
-
|
|
713
|
-
if username && username.include?("admin")
|
|
714
|
-
errors.add(:username, "cannot contain 'admin'")
|
|
715
|
-
end
|
|
716
|
-
end
|
|
717
|
-
end
|
|
409
|
+
## Design Philosophy
|
|
718
410
|
|
|
719
|
-
|
|
720
|
-
result.errors # => [{ attribute: :username, type: :invalid, message: "Username cannot contain 'admin'" }]
|
|
411
|
+
This gem follows a minimalist philosophy:
|
|
721
412
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
413
|
+
- **Sensible defaults** - Works out of the box; configure only when needed
|
|
414
|
+
- **Core validations only** - Essential validators without bloat
|
|
415
|
+
- **Zero dependencies** - Only requires Interactor and Ruby stdlib
|
|
416
|
+
- **Simple & readable** - Straightforward code over clever optimizations
|
|
417
|
+
- **Interactor-first** - Built specifically for service object patterns
|
|
726
418
|
|
|
727
|
-
|
|
728
|
-
config.error_mode = :code
|
|
729
|
-
end
|
|
419
|
+
### Why Not ActiveModel::Validations?
|
|
730
420
|
|
|
731
|
-
|
|
421
|
+
While ActiveModel::Validations is powerful, it's designed for ActiveRecord models and carries assumptions about persistence. Interactor::Validation is:
|
|
732
422
|
|
|
733
|
-
|
|
423
|
+
- Lighter weight
|
|
424
|
+
- Designed specifically for transient service objects
|
|
425
|
+
- Simpler API tailored to interactor patterns
|
|
426
|
+
- Configurable error formats for API responses
|
|
734
427
|
|
|
735
|
-
|
|
736
|
-
if username && username.include?("admin")
|
|
737
|
-
errors.add(:username, "RESERVED_WORD")
|
|
738
|
-
end
|
|
739
|
-
end
|
|
740
|
-
end
|
|
428
|
+
## Development
|
|
741
429
|
|
|
742
|
-
|
|
743
|
-
|
|
430
|
+
```bash
|
|
431
|
+
bundle install
|
|
432
|
+
bundle exec rspec # Run tests
|
|
433
|
+
bundle exec rubocop # Lint code
|
|
744
434
|
```
|
|
745
435
|
|
|
746
|
-
##
|
|
747
|
-
|
|
748
|
-
Create base interactors with shared validation logic that child classes automatically inherit.
|
|
749
|
-
|
|
750
|
-
### Basic Inheritance
|
|
751
|
-
|
|
752
|
-
```ruby
|
|
753
|
-
# Base interactor with common functionality
|
|
754
|
-
class ApplicationInteractor
|
|
755
|
-
include Interactor
|
|
756
|
-
include Interactor::Validation
|
|
757
|
-
|
|
758
|
-
# All child classes will inherit validation functionality
|
|
759
|
-
end
|
|
436
|
+
## Contributing
|
|
760
437
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
params :email, :username
|
|
438
|
+
Contributions welcome! Please open an issue or pull request at:
|
|
439
|
+
https://github.com/zyxzen/interactor-validation
|
|
764
440
|
|
|
765
|
-
|
|
766
|
-
validates :username, presence: true
|
|
767
|
-
|
|
768
|
-
def call
|
|
769
|
-
User.create!(email: email, username: username)
|
|
770
|
-
end
|
|
771
|
-
end
|
|
772
|
-
|
|
773
|
-
# Another child interactor
|
|
774
|
-
class UpdateUser < ApplicationInteractor
|
|
775
|
-
params :user_id, :email
|
|
776
|
-
|
|
777
|
-
validates :user_id, presence: true
|
|
778
|
-
validates :email, format: { with: /@/ }
|
|
779
|
-
|
|
780
|
-
def call
|
|
781
|
-
User.find(user_id).update!(email: email)
|
|
782
|
-
end
|
|
783
|
-
end
|
|
784
|
-
|
|
785
|
-
# Both work automatically
|
|
786
|
-
CreateUser.call(email: "user@example.com", username: "john") # => success
|
|
787
|
-
UpdateUser.call(user_id: 1, email: "invalid") # => failure with validation errors
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
### Shared Validation Configuration
|
|
791
|
-
|
|
792
|
-
Configure validation behavior in the base class:
|
|
793
|
-
|
|
794
|
-
```ruby
|
|
795
|
-
class ApiInteractor
|
|
796
|
-
include Interactor
|
|
797
|
-
include Interactor::Validation
|
|
798
|
-
|
|
799
|
-
configure_validation do |config|
|
|
800
|
-
config.error_mode = :code # All child classes use code mode
|
|
801
|
-
config.halt = true
|
|
802
|
-
end
|
|
803
|
-
end
|
|
804
|
-
|
|
805
|
-
class CreatePost < ApiInteractor
|
|
806
|
-
params :title, :body
|
|
807
|
-
|
|
808
|
-
validates :title, presence: true
|
|
809
|
-
validates :body, presence: true
|
|
810
|
-
|
|
811
|
-
def call
|
|
812
|
-
Post.create!(title: title, body: body)
|
|
813
|
-
end
|
|
814
|
-
end
|
|
815
|
-
|
|
816
|
-
result = CreatePost.call(title: "", body: "")
|
|
817
|
-
result.errors # => [{ code: "TITLE_IS_REQUIRED" }] # Halted on first error
|
|
818
|
-
```
|
|
819
|
-
|
|
820
|
-
### Shared Custom Validations
|
|
821
|
-
|
|
822
|
-
Define common validation logic in the base class:
|
|
823
|
-
|
|
824
|
-
```ruby
|
|
825
|
-
class AuthenticatedInteractor
|
|
826
|
-
include Interactor
|
|
827
|
-
include Interactor::Validation
|
|
828
|
-
|
|
829
|
-
params :user_id
|
|
830
|
-
|
|
831
|
-
validates :user_id, presence: true
|
|
832
|
-
|
|
833
|
-
def validate!
|
|
834
|
-
# This validation runs for ALL child classes
|
|
835
|
-
user = User.find_by(id: user_id)
|
|
836
|
-
|
|
837
|
-
if user.nil?
|
|
838
|
-
errors.add(:user_id, "USER_NOT_FOUND")
|
|
839
|
-
elsif !user.active?
|
|
840
|
-
errors.add(:user_id, "USER_INACTIVE")
|
|
841
|
-
end
|
|
842
|
-
end
|
|
843
|
-
end
|
|
844
|
-
|
|
845
|
-
class UpdateSettings < AuthenticatedInteractor
|
|
846
|
-
params :user_id, :theme
|
|
847
|
-
|
|
848
|
-
validates :theme, inclusion: { in: %w[light dark] }
|
|
849
|
-
|
|
850
|
-
def call
|
|
851
|
-
# user_id is already validated by parent
|
|
852
|
-
User.find(user_id).update!(theme: theme)
|
|
853
|
-
end
|
|
854
|
-
end
|
|
855
|
-
|
|
856
|
-
class DeleteAccount < AuthenticatedInteractor
|
|
857
|
-
params :user_id, :confirmation
|
|
858
|
-
|
|
859
|
-
validates :confirmation, presence: true
|
|
860
|
-
|
|
861
|
-
def validate!
|
|
862
|
-
super # Call parent's validate! first
|
|
863
|
-
|
|
864
|
-
# Add additional validation
|
|
865
|
-
if confirmation != "DELETE"
|
|
866
|
-
errors.add(:confirmation, "INVALID_CONFIRMATION")
|
|
867
|
-
end
|
|
868
|
-
end
|
|
869
|
-
|
|
870
|
-
def call
|
|
871
|
-
User.find(user_id).destroy!
|
|
872
|
-
end
|
|
873
|
-
end
|
|
874
|
-
```
|
|
875
|
-
|
|
876
|
-
### Multilevel Inheritance
|
|
877
|
-
|
|
878
|
-
Validation works across multiple inheritance levels:
|
|
879
|
-
|
|
880
|
-
```ruby
|
|
881
|
-
# Level 1: Base
|
|
882
|
-
class ApplicationInteractor
|
|
883
|
-
include Interactor
|
|
884
|
-
include Interactor::Validation
|
|
885
|
-
end
|
|
886
|
-
|
|
887
|
-
# Level 2: Feature-specific base
|
|
888
|
-
class AdminInteractor < ApplicationInteractor
|
|
889
|
-
params :admin_id
|
|
890
|
-
|
|
891
|
-
validates :admin_id, presence: true
|
|
892
|
-
|
|
893
|
-
def validate!
|
|
894
|
-
admin = Admin.find_by(id: admin_id)
|
|
895
|
-
errors.add(:admin_id, "NOT_AN_ADMIN") if admin.nil?
|
|
896
|
-
end
|
|
897
|
-
end
|
|
898
|
-
|
|
899
|
-
# Level 3: Specific action
|
|
900
|
-
class BanUser < AdminInteractor
|
|
901
|
-
params :admin_id, :target_user_id, :reason
|
|
902
|
-
|
|
903
|
-
validates :target_user_id, presence: true
|
|
904
|
-
validates :reason, presence: true, length: { minimum: 10 }
|
|
905
|
-
|
|
906
|
-
def validate!
|
|
907
|
-
super # Validates admin_id
|
|
908
|
-
|
|
909
|
-
# Additional validation
|
|
910
|
-
target = User.find_by(id: target_user_id)
|
|
911
|
-
errors.add(:target_user_id, "USER_NOT_FOUND") if target.nil?
|
|
912
|
-
end
|
|
913
|
-
|
|
914
|
-
def call
|
|
915
|
-
User.find(target_user_id).ban!(reason: reason, banned_by: admin_id)
|
|
916
|
-
end
|
|
917
|
-
end
|
|
918
|
-
|
|
919
|
-
# All three levels of validation run automatically
|
|
920
|
-
result = BanUser.call(admin_id: 1, target_user_id: 999, reason: "Spam")
|
|
921
|
-
# Validates: admin_id presence, admin exists, target_user_id presence, target exists, reason presence/length
|
|
922
|
-
```
|
|
923
|
-
|
|
924
|
-
### Override Parent Configuration
|
|
925
|
-
|
|
926
|
-
Child classes can override parent configuration:
|
|
927
|
-
|
|
928
|
-
```ruby
|
|
929
|
-
class BaseInteractor
|
|
930
|
-
include Interactor
|
|
931
|
-
include Interactor::Validation
|
|
932
|
-
|
|
933
|
-
configure_validation do |config|
|
|
934
|
-
config.error_mode = :default
|
|
935
|
-
end
|
|
936
|
-
end
|
|
937
|
-
|
|
938
|
-
class ApiCreateUser < BaseInteractor
|
|
939
|
-
# Override to use code mode for API
|
|
940
|
-
configure_validation do |config|
|
|
941
|
-
config.error_mode = :code
|
|
942
|
-
end
|
|
943
|
-
|
|
944
|
-
params :email
|
|
945
|
-
|
|
946
|
-
validates :email, presence: true
|
|
947
|
-
|
|
948
|
-
def call
|
|
949
|
-
User.create!(email: email)
|
|
950
|
-
end
|
|
951
|
-
end
|
|
952
|
-
|
|
953
|
-
result = ApiCreateUser.call(email: "")
|
|
954
|
-
result.errors # => [{ code: "EMAIL_IS_REQUIRED" }] # Uses :code mode, not :default
|
|
955
|
-
```
|
|
956
|
-
|
|
957
|
-
## Complete Usage Examples
|
|
958
|
-
|
|
959
|
-
### All Validation Types
|
|
960
|
-
|
|
961
|
-
```ruby
|
|
962
|
-
class CompleteExample
|
|
963
|
-
include Interactor
|
|
964
|
-
include Interactor::Validation
|
|
965
|
-
|
|
966
|
-
params :name, :email, :password, :age, :status, :terms, :profile, :tags
|
|
967
|
-
|
|
968
|
-
# Presence validation
|
|
969
|
-
validates :name, presence: true
|
|
970
|
-
# Error: { attribute: :name, type: :blank, message: "Name can't be blank" }
|
|
971
|
-
|
|
972
|
-
# Format validation (regex)
|
|
973
|
-
validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
|
|
974
|
-
# Error: { attribute: :email, type: :invalid, message: "Email is invalid" }
|
|
975
|
-
|
|
976
|
-
# Length validations
|
|
977
|
-
validates :password, length: { minimum: 8, maximum: 128 }
|
|
978
|
-
# Errors: { attribute: :password, type: :too_short, message: "Password is too short (minimum is 8 characters)" }
|
|
979
|
-
# { attribute: :password, type: :too_long, message: "Password is too long (maximum is 128 characters)" }
|
|
980
|
-
|
|
981
|
-
validates :name, length: { is: 10 }
|
|
982
|
-
# Error: { attribute: :name, type: :wrong_length, message: "Name is the wrong length (should be 10 characters)" }
|
|
983
|
-
|
|
984
|
-
# Numericality validations
|
|
985
|
-
validates :age,
|
|
986
|
-
numericality: {
|
|
987
|
-
greater_than: 0,
|
|
988
|
-
less_than: 150,
|
|
989
|
-
greater_than_or_equal_to: 18,
|
|
990
|
-
less_than_or_equal_to: 100,
|
|
991
|
-
equal_to: 25 # Exact value
|
|
992
|
-
}
|
|
993
|
-
# Errors: { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
|
|
994
|
-
# { attribute: :age, type: :less_than, message: "Age must be less than 150" }
|
|
995
|
-
# { attribute: :age, type: :greater_than_or_equal_to, message: "Age must be greater than or equal to 18" }
|
|
996
|
-
# { attribute: :age, type: :less_than_or_equal_to, message: "Age must be less than or equal to 100" }
|
|
997
|
-
# { attribute: :age, type: :equal_to, message: "Age must be equal to 25" }
|
|
998
|
-
|
|
999
|
-
# Inclusion validation
|
|
1000
|
-
validates :status, inclusion: { in: %w[active pending inactive suspended] }
|
|
1001
|
-
# Error: { attribute: :status, type: :inclusion, message: "Status is not included in the list" }
|
|
1002
|
-
|
|
1003
|
-
# Boolean validation
|
|
1004
|
-
validates :terms, boolean: true
|
|
1005
|
-
# Ensures value is exactly true or false (not truthy/falsy)
|
|
1006
|
-
|
|
1007
|
-
# Nested hash validation
|
|
1008
|
-
validates :profile do
|
|
1009
|
-
attribute :username, presence: true, length: { minimum: 3 }
|
|
1010
|
-
attribute :bio, length: { maximum: 500 }
|
|
1011
|
-
attribute :age, numericality: { greater_than: 0 }
|
|
1012
|
-
end
|
|
1013
|
-
|
|
1014
|
-
# Nested array validation
|
|
1015
|
-
validates :tags do
|
|
1016
|
-
attribute :name, presence: true
|
|
1017
|
-
attribute :priority, numericality: { greater_than_or_equal_to: 0 }
|
|
1018
|
-
end
|
|
1019
|
-
|
|
1020
|
-
def call
|
|
1021
|
-
# Your logic here
|
|
1022
|
-
end
|
|
1023
|
-
end
|
|
1024
|
-
```
|
|
1025
|
-
|
|
1026
|
-
### Custom Error Messages
|
|
1027
|
-
|
|
1028
|
-
```ruby
|
|
1029
|
-
class CustomMessages
|
|
1030
|
-
include Interactor
|
|
1031
|
-
include Interactor::Validation
|
|
1032
|
-
|
|
1033
|
-
params :username, :email, :age
|
|
1034
|
-
|
|
1035
|
-
# Custom message for presence
|
|
1036
|
-
validates :username, presence: { message: "Please provide a username" }
|
|
1037
|
-
|
|
1038
|
-
# Custom message for format
|
|
1039
|
-
validates :email, format: { with: /@/, message: "Must be a valid email address" }
|
|
1040
|
-
|
|
1041
|
-
# Custom message for numericality
|
|
1042
|
-
validates :age, numericality: { greater_than: 0, message: "Age must be positive" }
|
|
1043
|
-
|
|
1044
|
-
def call
|
|
1045
|
-
# Your logic
|
|
1046
|
-
end
|
|
1047
|
-
end
|
|
1048
|
-
|
|
1049
|
-
# With :default mode
|
|
1050
|
-
result = CustomMessages.call(username: "", email: "invalid", age: -5)
|
|
1051
|
-
result.errors # => [
|
|
1052
|
-
# { attribute: :username, type: :blank, message: "Username please provide a username" },
|
|
1053
|
-
# { attribute: :email, type: :invalid, message: "Email must be a valid email address" },
|
|
1054
|
-
# { attribute: :age, type: :greater_than, message: "Age age must be positive" }
|
|
1055
|
-
# ]
|
|
1056
|
-
|
|
1057
|
-
# With :code mode
|
|
1058
|
-
class CustomMessagesCode
|
|
1059
|
-
include Interactor
|
|
1060
|
-
include Interactor::Validation
|
|
1061
|
-
|
|
1062
|
-
configure_validation { |c| c.error_mode = :code }
|
|
1063
|
-
|
|
1064
|
-
params :username, :age
|
|
1065
|
-
|
|
1066
|
-
validates :username, presence: { message: "REQUIRED" }
|
|
1067
|
-
validates :age, numericality: { greater_than: 0, message: "INVALID" }
|
|
1068
|
-
end
|
|
1069
|
-
|
|
1070
|
-
result = CustomMessagesCode.call(username: "", age: -5)
|
|
1071
|
-
result.errors # => [
|
|
1072
|
-
# { code: "USERNAME_REQUIRED" },
|
|
1073
|
-
# { code: "AGE_INVALID" }
|
|
1074
|
-
# ]
|
|
1075
|
-
```
|
|
1076
|
-
|
|
1077
|
-
### Error Modes Comparison
|
|
1078
|
-
|
|
1079
|
-
```ruby
|
|
1080
|
-
class UserRegistration
|
|
1081
|
-
include Interactor
|
|
1082
|
-
include Interactor::Validation
|
|
1083
|
-
|
|
1084
|
-
params :email, :password, :age
|
|
1085
|
-
|
|
1086
|
-
validates :email, presence: true, format: { with: /@/ }
|
|
1087
|
-
validates :password, length: { minimum: 8 }
|
|
1088
|
-
validates :age, numericality: { greater_than_or_equal_to: 18 }
|
|
1089
|
-
|
|
1090
|
-
def call
|
|
1091
|
-
User.create!(email: email, password: password, age: age)
|
|
1092
|
-
end
|
|
1093
|
-
end
|
|
1094
|
-
|
|
1095
|
-
# Default mode (ActiveModel-style) - human-readable, detailed
|
|
1096
|
-
result = UserRegistration.call(email: "bad", password: "short", age: 15)
|
|
1097
|
-
result.errors # => [
|
|
1098
|
-
# { attribute: :email, type: :invalid, message: "Email is invalid" },
|
|
1099
|
-
# { attribute: :password, type: :too_short, message: "Password is too short (minimum is 8 characters)" },
|
|
1100
|
-
# { attribute: :age, type: :greater_than_or_equal_to, message: "Age must be greater than or equal to 18" }
|
|
1101
|
-
# ]
|
|
1102
|
-
|
|
1103
|
-
# Code mode - structured, API-friendly, i18n-ready
|
|
1104
|
-
class ApiUserRegistration
|
|
1105
|
-
include Interactor
|
|
1106
|
-
include Interactor::Validation
|
|
1107
|
-
|
|
1108
|
-
configure_validation { |c| c.error_mode = :code }
|
|
1109
|
-
|
|
1110
|
-
params :email, :password, :age
|
|
1111
|
-
|
|
1112
|
-
validates :email, presence: true, format: { with: /@/ }
|
|
1113
|
-
validates :password, length: { minimum: 8 }
|
|
1114
|
-
validates :age, numericality: { greater_than_or_equal_to: 18 }
|
|
1115
|
-
|
|
1116
|
-
def call
|
|
1117
|
-
User.create!(email: email, password: password, age: age)
|
|
1118
|
-
end
|
|
1119
|
-
end
|
|
1120
|
-
|
|
1121
|
-
result = ApiUserRegistration.call(email: "bad", password: "short", age: 15)
|
|
1122
|
-
result.errors # => [
|
|
1123
|
-
# { code: "EMAIL_INVALID_FORMAT" },
|
|
1124
|
-
# { code: "PASSWORD_BELOW_MIN_LENGTH_8" },
|
|
1125
|
-
# { code: "AGE_BELOW_MIN_VALUE_18" }
|
|
1126
|
-
# ]
|
|
1127
|
-
```
|
|
1128
|
-
|
|
1129
|
-
### Configuration Examples
|
|
1130
|
-
|
|
1131
|
-
```ruby
|
|
1132
|
-
# Global configuration (config/initializers/interactor_validation.rb)
|
|
1133
|
-
Interactor::Validation.configure do |config|
|
|
1134
|
-
# Error format
|
|
1135
|
-
config.error_mode = :code # or :default
|
|
1136
|
-
|
|
1137
|
-
# Performance
|
|
1138
|
-
config.halt = true # Stop at first validation error
|
|
1139
|
-
|
|
1140
|
-
# Security
|
|
1141
|
-
config.regex_timeout = 0.1 # 100ms timeout for regex (ReDoS protection)
|
|
1142
|
-
config.max_array_size = 1000 # Max array size for nested validation
|
|
1143
|
-
|
|
1144
|
-
# Optimization
|
|
1145
|
-
config.cache_regex_patterns = true # Cache compiled regex patterns
|
|
1146
|
-
|
|
1147
|
-
# Monitoring
|
|
1148
|
-
config.enable_instrumentation = true
|
|
1149
|
-
end
|
|
1150
|
-
|
|
1151
|
-
# Per-interactor configuration (overrides global)
|
|
1152
|
-
class FastValidator
|
|
1153
|
-
include Interactor
|
|
1154
|
-
include Interactor::Validation
|
|
1155
|
-
|
|
1156
|
-
configure_validation do |config|
|
|
1157
|
-
config.halt = true # Override global setting
|
|
1158
|
-
config.error_mode = :code
|
|
1159
|
-
end
|
|
1160
|
-
|
|
1161
|
-
params :field1, :field2, :field3
|
|
1162
|
-
|
|
1163
|
-
validates :field1, presence: true
|
|
1164
|
-
validates :field2, presence: true # Won't run if field1 fails
|
|
1165
|
-
validates :field3, presence: true # Won't run if earlier fails
|
|
1166
|
-
|
|
1167
|
-
def call
|
|
1168
|
-
# Your logic
|
|
1169
|
-
end
|
|
1170
|
-
end
|
|
1171
|
-
```
|
|
1172
|
-
|
|
1173
|
-
### Nested Validation Examples
|
|
1174
|
-
|
|
1175
|
-
```ruby
|
|
1176
|
-
# Optional hash validation (parameter can be nil)
|
|
1177
|
-
class SearchWithFilters
|
|
1178
|
-
include Interactor
|
|
1179
|
-
include Interactor::Validation
|
|
1180
|
-
|
|
1181
|
-
params :filters
|
|
1182
|
-
|
|
1183
|
-
validates :filters do
|
|
1184
|
-
attribute :category, presence: true
|
|
1185
|
-
attribute :min_price, numericality: { greater_than_or_equal_to: 0 }
|
|
1186
|
-
attribute :max_price, numericality: { greater_than_or_equal_to: 0 }
|
|
1187
|
-
end
|
|
1188
|
-
|
|
1189
|
-
def call
|
|
1190
|
-
# filters is optional - if nil, skip filtering
|
|
1191
|
-
results = filters ? apply_filters(Product.all) : Product.all
|
|
1192
|
-
context.results = results
|
|
1193
|
-
end
|
|
1194
|
-
end
|
|
1195
|
-
|
|
1196
|
-
# When filters is nil - succeeds
|
|
1197
|
-
result = SearchWithFilters.call(filters: nil)
|
|
1198
|
-
result.success? # => true
|
|
1199
|
-
|
|
1200
|
-
# When filters is present but invalid - fails
|
|
1201
|
-
result = SearchWithFilters.call(filters: { min_price: -10 })
|
|
1202
|
-
result.errors # => [
|
|
1203
|
-
# { attribute: "filters.category", type: :blank, message: "Filters.category can't be blank" },
|
|
1204
|
-
# { attribute: "filters.min_price", type: :greater_than_or_equal_to, message: "..." }
|
|
1205
|
-
# ]
|
|
1206
|
-
|
|
1207
|
-
# Required hash validation (parameter must be present)
|
|
1208
|
-
class CreateUserWithProfile
|
|
1209
|
-
include Interactor
|
|
1210
|
-
include Interactor::Validation
|
|
1211
|
-
|
|
1212
|
-
params :user
|
|
1213
|
-
|
|
1214
|
-
validates :user, presence: true do
|
|
1215
|
-
attribute :name, presence: true
|
|
1216
|
-
attribute :email, format: { with: /@/ }
|
|
1217
|
-
attribute :age, numericality: { greater_than: 0 }
|
|
1218
|
-
attribute :bio, length: { maximum: 500 }
|
|
1219
|
-
end
|
|
1220
|
-
|
|
1221
|
-
def call
|
|
1222
|
-
User.create!(user)
|
|
1223
|
-
end
|
|
1224
|
-
end
|
|
1225
|
-
|
|
1226
|
-
# When user is nil or empty - fails with presence error
|
|
1227
|
-
result = CreateUserWithProfile.call(user: nil)
|
|
1228
|
-
result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
|
|
1229
|
-
|
|
1230
|
-
result = CreateUserWithProfile.call(user: {})
|
|
1231
|
-
result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
|
|
1232
|
-
|
|
1233
|
-
# When user is present but invalid - validates nested attributes
|
|
1234
|
-
result = CreateUserWithProfile.call(
|
|
1235
|
-
user: {
|
|
1236
|
-
name: "",
|
|
1237
|
-
email: "invalid",
|
|
1238
|
-
age: -5,
|
|
1239
|
-
bio: "a" * 600
|
|
1240
|
-
}
|
|
1241
|
-
)
|
|
1242
|
-
result.errors # => [
|
|
1243
|
-
# { attribute: "user.name", type: :blank, message: "User.name can't be blank" },
|
|
1244
|
-
# { attribute: "user.email", type: :invalid, message: "User.email is invalid" },
|
|
1245
|
-
# { attribute: "user.age", type: :greater_than, message: "User.age must be greater than 0" },
|
|
1246
|
-
# { attribute: "user.bio", type: :too_long, message: "User.bio is too long (maximum is 500 characters)" }
|
|
1247
|
-
# ]
|
|
1248
|
-
|
|
1249
|
-
# Array validation (optional)
|
|
1250
|
-
class BulkCreateItems
|
|
1251
|
-
include Interactor
|
|
1252
|
-
include Interactor::Validation
|
|
1253
|
-
|
|
1254
|
-
params :items
|
|
1255
|
-
|
|
1256
|
-
validates :items do
|
|
1257
|
-
attribute :name, presence: true
|
|
1258
|
-
attribute :price, numericality: { greater_than: 0 }
|
|
1259
|
-
attribute :quantity, numericality: { greater_than_or_equal_to: 1 }
|
|
1260
|
-
end
|
|
1261
|
-
|
|
1262
|
-
def call
|
|
1263
|
-
return if items.nil? # Optional - handle nil gracefully
|
|
1264
|
-
items.each { |item| Item.create!(item) }
|
|
1265
|
-
end
|
|
1266
|
-
end
|
|
1267
|
-
|
|
1268
|
-
# When items is nil - succeeds
|
|
1269
|
-
result = BulkCreateItems.call(items: nil)
|
|
1270
|
-
result.success? # => true
|
|
1271
|
-
|
|
1272
|
-
# When items is present - validates each item
|
|
1273
|
-
result = BulkCreateItems.call(
|
|
1274
|
-
items: [
|
|
1275
|
-
{ name: "Widget", price: 10, quantity: 5 },
|
|
1276
|
-
{ name: "", price: -5, quantity: 0 }
|
|
1277
|
-
]
|
|
1278
|
-
)
|
|
1279
|
-
result.errors # => [
|
|
1280
|
-
# { attribute: "items[1].name", type: :blank, message: "Items[1].name can't be blank" },
|
|
1281
|
-
# { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" },
|
|
1282
|
-
# { attribute: "items[1].quantity", type: :greater_than_or_equal_to, message: "Items[1].quantity must be greater than or equal to 1" }
|
|
1283
|
-
# ]
|
|
1284
|
-
|
|
1285
|
-
# Required array validation
|
|
1286
|
-
class BulkCreateRequiredItems
|
|
1287
|
-
include Interactor
|
|
1288
|
-
include Interactor::Validation
|
|
1289
|
-
|
|
1290
|
-
params :items
|
|
1291
|
-
|
|
1292
|
-
validates :items, presence: true do
|
|
1293
|
-
attribute :name, presence: true
|
|
1294
|
-
attribute :price, numericality: { greater_than: 0 }
|
|
1295
|
-
end
|
|
1296
|
-
|
|
1297
|
-
def call
|
|
1298
|
-
items.each { |item| Item.create!(item) }
|
|
1299
|
-
end
|
|
1300
|
-
end
|
|
1301
|
-
|
|
1302
|
-
# When items is nil or empty - fails with presence error
|
|
1303
|
-
result = BulkCreateRequiredItems.call(items: nil)
|
|
1304
|
-
result.errors # => [{ attribute: :items, type: :blank, message: "Items can't be blank" }]
|
|
1305
|
-
|
|
1306
|
-
result = BulkCreateRequiredItems.call(items: [])
|
|
1307
|
-
result.errors # => [{ attribute: :items, type: :blank, message: "Items can't be blank" }]
|
|
1308
|
-
```
|
|
1309
|
-
|
|
1310
|
-
### ActiveModel Integration
|
|
1311
|
-
|
|
1312
|
-
```ruby
|
|
1313
|
-
class CustomValidations
|
|
1314
|
-
include Interactor
|
|
1315
|
-
include Interactor::Validation
|
|
1316
|
-
|
|
1317
|
-
params :username, :password, :password_confirmation
|
|
1318
|
-
|
|
1319
|
-
validates :username, presence: true
|
|
1320
|
-
validates :password, presence: true
|
|
1321
|
-
|
|
1322
|
-
# Use ActiveModel's validate callback for complex logic
|
|
1323
|
-
validate :passwords_match
|
|
1324
|
-
validate :username_not_reserved
|
|
1325
|
-
|
|
1326
|
-
private
|
|
1327
|
-
|
|
1328
|
-
def passwords_match
|
|
1329
|
-
if password != password_confirmation
|
|
1330
|
-
errors.add(:password_confirmation, "doesn't match password")
|
|
1331
|
-
end
|
|
1332
|
-
end
|
|
1333
|
-
|
|
1334
|
-
def username_not_reserved
|
|
1335
|
-
reserved = %w[admin root system]
|
|
1336
|
-
if reserved.include?(username&.downcase)
|
|
1337
|
-
errors.add(:username, "is reserved")
|
|
1338
|
-
end
|
|
1339
|
-
end
|
|
1340
|
-
end
|
|
1341
|
-
|
|
1342
|
-
result = CustomValidations.call(
|
|
1343
|
-
username: "admin",
|
|
1344
|
-
password: "secret123",
|
|
1345
|
-
password_confirmation: "different"
|
|
1346
|
-
)
|
|
1347
|
-
result.errors # => [
|
|
1348
|
-
# { attribute: :username, type: :invalid, message: "Username is reserved" },
|
|
1349
|
-
# { attribute: :password_confirmation, type: :invalid, message: "Password confirmation doesn't match password" }
|
|
1350
|
-
# ]
|
|
1351
|
-
```
|
|
1352
|
-
|
|
1353
|
-
### Performance Monitoring
|
|
1354
|
-
|
|
1355
|
-
```ruby
|
|
1356
|
-
# Enable instrumentation in configuration
|
|
1357
|
-
Interactor::Validation.configure do |config|
|
|
1358
|
-
config.enable_instrumentation = true
|
|
1359
|
-
end
|
|
1360
|
-
|
|
1361
|
-
# Subscribe to validation events
|
|
1362
|
-
ActiveSupport::Notifications.subscribe('validate_params.interactor_validation') do |*args|
|
|
1363
|
-
event = ActiveSupport::Notifications::Event.new(*args)
|
|
1364
|
-
|
|
1365
|
-
Rails.logger.info({
|
|
1366
|
-
event: 'validation',
|
|
1367
|
-
interactor: event.payload[:interactor],
|
|
1368
|
-
duration_ms: event.duration,
|
|
1369
|
-
validation_count: event.payload[:validation_count],
|
|
1370
|
-
error_count: event.payload[:error_count],
|
|
1371
|
-
halted: event.payload[:halted]
|
|
1372
|
-
}.to_json)
|
|
1373
|
-
end
|
|
1374
|
-
|
|
1375
|
-
# Now all validations are instrumented
|
|
1376
|
-
class SlowValidation
|
|
1377
|
-
include Interactor
|
|
1378
|
-
include Interactor::Validation
|
|
1379
|
-
|
|
1380
|
-
params :field1, :field2
|
|
1381
|
-
|
|
1382
|
-
validates :field1, presence: true
|
|
1383
|
-
validates :field2, format: { with: /complex.*regex/ }
|
|
1384
|
-
|
|
1385
|
-
def call
|
|
1386
|
-
# Your logic
|
|
1387
|
-
end
|
|
1388
|
-
end
|
|
1389
|
-
|
|
1390
|
-
# Logs: { "event": "validation", "interactor": "SlowValidation", "duration_ms": 2.5, ... }
|
|
1391
|
-
```
|
|
1392
|
-
|
|
1393
|
-
### Real-World Example: API Endpoint
|
|
1394
|
-
|
|
1395
|
-
```ruby
|
|
1396
|
-
# Base API interactor
|
|
1397
|
-
class ApiInteractor
|
|
1398
|
-
include Interactor
|
|
1399
|
-
include Interactor::Validation
|
|
1400
|
-
|
|
1401
|
-
configure_validation do |config|
|
|
1402
|
-
config.error_mode = :code
|
|
1403
|
-
config.halt = false # Return all errors
|
|
1404
|
-
end
|
|
1405
|
-
end
|
|
1406
|
-
|
|
1407
|
-
# User registration endpoint
|
|
1408
|
-
class Api::V1::RegisterUser < ApiInteractor
|
|
1409
|
-
params :email, :password, :password_confirmation, :first_name, :last_name, :terms_accepted
|
|
1410
|
-
|
|
1411
|
-
validates :email,
|
|
1412
|
-
presence: { message: "REQUIRED" },
|
|
1413
|
-
format: { with: URI::MailTo::EMAIL_REGEXP, message: "INVALID_FORMAT" }
|
|
1414
|
-
|
|
1415
|
-
validates :password,
|
|
1416
|
-
presence: { message: "REQUIRED" },
|
|
1417
|
-
length: { minimum: 12, message: "TOO_SHORT" }
|
|
1418
|
-
|
|
1419
|
-
validates :first_name, presence: { message: "REQUIRED" }
|
|
1420
|
-
validates :last_name, presence: { message: "REQUIRED" }
|
|
1421
|
-
validates :terms_accepted, boolean: true
|
|
1422
|
-
|
|
1423
|
-
def validate!
|
|
1424
|
-
# Custom validations
|
|
1425
|
-
if password != password_confirmation
|
|
1426
|
-
errors.add(:password_confirmation, "MISMATCH")
|
|
1427
|
-
end
|
|
1428
|
-
|
|
1429
|
-
if User.exists?(email: email)
|
|
1430
|
-
errors.add(:email, "ALREADY_TAKEN")
|
|
1431
|
-
end
|
|
1432
|
-
|
|
1433
|
-
unless terms_accepted == true
|
|
1434
|
-
errors.add(:terms_accepted, "MUST_ACCEPT")
|
|
1435
|
-
end
|
|
1436
|
-
end
|
|
1437
|
-
|
|
1438
|
-
def call
|
|
1439
|
-
user = User.create!(
|
|
1440
|
-
email: email,
|
|
1441
|
-
password: password,
|
|
1442
|
-
first_name: first_name,
|
|
1443
|
-
last_name: last_name
|
|
1444
|
-
)
|
|
1445
|
-
|
|
1446
|
-
context.user = user
|
|
1447
|
-
context.token = generate_token(user)
|
|
1448
|
-
end
|
|
1449
|
-
|
|
1450
|
-
private
|
|
1451
|
-
|
|
1452
|
-
def generate_token(user)
|
|
1453
|
-
JWT.encode({ user_id: user.id }, Rails.application.secret_key_base)
|
|
1454
|
-
end
|
|
1455
|
-
end
|
|
1456
|
-
|
|
1457
|
-
# Controller
|
|
1458
|
-
class Api::V1::UsersController < ApplicationController
|
|
1459
|
-
def create
|
|
1460
|
-
result = Api::V1::RegisterUser.call(user_params)
|
|
1461
|
-
|
|
1462
|
-
if result.success?
|
|
1463
|
-
render json: {
|
|
1464
|
-
user: result.user,
|
|
1465
|
-
token: result.token
|
|
1466
|
-
}, status: :created
|
|
1467
|
-
else
|
|
1468
|
-
render json: {
|
|
1469
|
-
errors: result.errors
|
|
1470
|
-
}, status: :unprocessable_entity
|
|
1471
|
-
end
|
|
1472
|
-
end
|
|
1473
|
-
end
|
|
1474
|
-
|
|
1475
|
-
# Example error response:
|
|
1476
|
-
# {
|
|
1477
|
-
# "errors": [
|
|
1478
|
-
# { "code": "EMAIL_INVALID_FORMAT" },
|
|
1479
|
-
# { "code": "PASSWORD_TOO_SHORT" },
|
|
1480
|
-
# { "code": "TERMS_ACCEPTED_MUST_ACCEPT" }
|
|
1481
|
-
# ]
|
|
1482
|
-
# }
|
|
1483
|
-
```
|
|
1484
|
-
|
|
1485
|
-
### Real-World Example: Background Job
|
|
1486
|
-
|
|
1487
|
-
```ruby
|
|
1488
|
-
# Background job with validation
|
|
1489
|
-
class ProcessOrderJob
|
|
1490
|
-
include Interactor
|
|
1491
|
-
include Interactor::Validation
|
|
1492
|
-
|
|
1493
|
-
configure_validation do |config|
|
|
1494
|
-
config.error_mode = :code
|
|
1495
|
-
end
|
|
1496
|
-
|
|
1497
|
-
params :order_id, :payment_method, :shipping_address
|
|
1498
|
-
|
|
1499
|
-
validates :order_id, presence: true
|
|
1500
|
-
validates :payment_method, inclusion: { in: %w[credit_card paypal stripe] }
|
|
1501
|
-
|
|
1502
|
-
validates :shipping_address do
|
|
1503
|
-
attribute :street, presence: true
|
|
1504
|
-
attribute :city, presence: true
|
|
1505
|
-
attribute :postal_code, presence: true, format: { with: /\A\d{5}\z/ }
|
|
1506
|
-
attribute :country, inclusion: { in: %w[US CA UK] }
|
|
1507
|
-
end
|
|
1508
|
-
|
|
1509
|
-
def validate!
|
|
1510
|
-
order = Order.find_by(id: order_id)
|
|
1511
|
-
|
|
1512
|
-
if order.nil?
|
|
1513
|
-
errors.add(:order_id, "NOT_FOUND")
|
|
1514
|
-
return
|
|
1515
|
-
end
|
|
1516
|
-
|
|
1517
|
-
if order.processed?
|
|
1518
|
-
errors.add(:order_id, "ALREADY_PROCESSED")
|
|
1519
|
-
end
|
|
1520
|
-
|
|
1521
|
-
if order.total_amount <= 0
|
|
1522
|
-
errors.add(:base, "INVALID_ORDER_AMOUNT")
|
|
1523
|
-
end
|
|
1524
|
-
end
|
|
1525
|
-
|
|
1526
|
-
def call
|
|
1527
|
-
order = Order.find(order_id)
|
|
1528
|
-
|
|
1529
|
-
payment = process_payment(order, payment_method)
|
|
1530
|
-
shipment = create_shipment(order, shipping_address)
|
|
1531
|
-
|
|
1532
|
-
order.update!(
|
|
1533
|
-
status: 'processed',
|
|
1534
|
-
payment_id: payment.id,
|
|
1535
|
-
shipment_id: shipment.id
|
|
1536
|
-
)
|
|
1537
|
-
|
|
1538
|
-
context.order = order
|
|
1539
|
-
end
|
|
1540
|
-
end
|
|
1541
|
-
|
|
1542
|
-
# Sidekiq job wrapper
|
|
1543
|
-
class ProcessOrderWorker
|
|
1544
|
-
include Sidekiq::Worker
|
|
1545
|
-
|
|
1546
|
-
def perform(order_id, payment_method, shipping_address)
|
|
1547
|
-
result = ProcessOrderJob.call(
|
|
1548
|
-
order_id: order_id,
|
|
1549
|
-
payment_method: payment_method,
|
|
1550
|
-
shipping_address: shipping_address
|
|
1551
|
-
)
|
|
1552
|
-
|
|
1553
|
-
unless result.success?
|
|
1554
|
-
# Log errors and retry or alert
|
|
1555
|
-
Rails.logger.error("Order processing failed: #{result.errors}")
|
|
1556
|
-
raise StandardError, "Validation failed: #{result.errors}"
|
|
1557
|
-
end
|
|
1558
|
-
end
|
|
1559
|
-
end
|
|
1560
|
-
```
|
|
1561
|
-
|
|
1562
|
-
### Security Best Practices
|
|
1563
|
-
|
|
1564
|
-
```ruby
|
|
1565
|
-
# ReDoS protection
|
|
1566
|
-
class SecureValidation
|
|
1567
|
-
include Interactor
|
|
1568
|
-
include Interactor::Validation
|
|
1569
|
-
|
|
1570
|
-
configure_validation do |config|
|
|
1571
|
-
config.regex_timeout = 0.05 # 50ms timeout
|
|
1572
|
-
end
|
|
1573
|
-
|
|
1574
|
-
params :input
|
|
1575
|
-
|
|
1576
|
-
# Potentially dangerous regex (nested quantifiers)
|
|
1577
|
-
validates :input, format: { with: /^(a+)+$/ }
|
|
1578
|
-
|
|
1579
|
-
def call
|
|
1580
|
-
# If regex takes > 50ms, validation fails safely
|
|
1581
|
-
end
|
|
1582
|
-
end
|
|
1583
|
-
|
|
1584
|
-
# Array size protection
|
|
1585
|
-
class BulkOperation
|
|
1586
|
-
include Interactor
|
|
1587
|
-
include Interactor::Validation
|
|
1588
|
-
|
|
1589
|
-
configure_validation do |config|
|
|
1590
|
-
config.max_array_size = 100 # Limit to 100 items
|
|
1591
|
-
end
|
|
1592
|
-
|
|
1593
|
-
params :items
|
|
1594
|
-
|
|
1595
|
-
validates :items do
|
|
1596
|
-
attribute :name, presence: true
|
|
1597
|
-
end
|
|
1598
|
-
|
|
1599
|
-
def call
|
|
1600
|
-
# If items.length > 100, validation fails
|
|
1601
|
-
items.each { |item| process(item) }
|
|
1602
|
-
end
|
|
1603
|
-
end
|
|
1604
|
-
|
|
1605
|
-
# Sanitize error messages before displaying
|
|
1606
|
-
class UserInput
|
|
1607
|
-
include Interactor
|
|
1608
|
-
include Interactor::Validation
|
|
1609
|
-
|
|
1610
|
-
params :content
|
|
1611
|
-
|
|
1612
|
-
validates :content, presence: true
|
|
1613
|
-
|
|
1614
|
-
def call
|
|
1615
|
-
# Always sanitize user input
|
|
1616
|
-
sanitized = ActionController::Base.helpers.sanitize(content)
|
|
1617
|
-
Content.create!(body: sanitized)
|
|
1618
|
-
end
|
|
1619
|
-
end
|
|
1620
|
-
```
|
|
1621
|
-
|
|
1622
|
-
---
|
|
1623
|
-
|
|
1624
|
-
## Requirements
|
|
1625
|
-
|
|
1626
|
-
- Ruby >= 3.2.0
|
|
1627
|
-
- Interactor ~> 3.0
|
|
1628
|
-
- ActiveModel >= 6.0
|
|
1629
|
-
- ActiveSupport >= 6.0
|
|
1630
|
-
|
|
1631
|
-
## Development
|
|
1632
|
-
|
|
1633
|
-
```bash
|
|
1634
|
-
bin/setup # Install dependencies
|
|
1635
|
-
bundle exec rspec # Run tests (231 examples)
|
|
1636
|
-
bundle exec rubocop # Lint code
|
|
1637
|
-
bin/console # Interactive console
|
|
1638
|
-
```
|
|
1639
|
-
|
|
1640
|
-
### Benchmarking
|
|
1641
|
-
|
|
1642
|
-
```bash
|
|
1643
|
-
bundle exec ruby benchmark/validation_benchmark.rb
|
|
1644
|
-
```
|
|
1645
|
-
|
|
1646
|
-
## License
|
|
441
|
+
## License
|
|
1647
442
|
|
|
1648
443
|
MIT License - see [LICENSE.txt](LICENSE.txt)
|
|
1649
|
-
|
|
1650
|
-
## Contributing
|
|
1651
|
-
|
|
1652
|
-
Issues and pull requests are welcome at [https://github.com/zyxzen/interactor-validation](https://github.com/zyxzen/interactor-validation)
|