interactor-validation 0.3.9 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +280 -1417
- 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 +157 -821
- 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,48 @@
|
|
|
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** - `validate!` for other 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)
|
|
33
|
+
- [Development](#development)
|
|
34
|
+
- [Contributing](#contributing)
|
|
35
|
+
- [License](#license)
|
|
16
36
|
|
|
17
37
|
## Installation
|
|
18
38
|
|
|
19
|
-
Add
|
|
39
|
+
Add to your Gemfile:
|
|
20
40
|
|
|
21
41
|
```ruby
|
|
22
42
|
gem "interactor-validation"
|
|
23
43
|
```
|
|
24
44
|
|
|
25
|
-
|
|
45
|
+
Then run:
|
|
26
46
|
|
|
27
47
|
```bash
|
|
28
48
|
bundle install
|
|
@@ -32,122 +52,86 @@ bundle install
|
|
|
32
52
|
|
|
33
53
|
### Quick Example
|
|
34
54
|
|
|
55
|
+
Define validations directly in your interactor:
|
|
56
|
+
|
|
35
57
|
```ruby
|
|
36
58
|
class CreateUser
|
|
37
59
|
include Interactor
|
|
38
60
|
include Interactor::Validation
|
|
39
61
|
|
|
40
|
-
# Declare parameters
|
|
41
|
-
params :email, :username, :age
|
|
62
|
+
# Declare expected parameters
|
|
63
|
+
params :email, :username, :age
|
|
42
64
|
|
|
43
|
-
#
|
|
65
|
+
# Define validation rules
|
|
44
66
|
validates :email, presence: true, format: { with: /@/ }
|
|
45
|
-
validates :username, presence: true
|
|
67
|
+
validates :username, presence: true, length: { maximum: 100 }
|
|
46
68
|
validates :age, numericality: { greater_than: 0 }
|
|
47
|
-
validates :terms_accepted, boolean: true
|
|
48
69
|
|
|
49
70
|
def call
|
|
50
71
|
# Validations run automatically before this
|
|
51
72
|
User.create!(email: email, username: username, age: age)
|
|
52
73
|
end
|
|
53
74
|
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
75
|
```
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
#### Presence
|
|
77
|
+
When validation fails, the interactor automatically halts with errors:
|
|
78
78
|
|
|
79
79
|
```ruby
|
|
80
|
-
|
|
81
|
-
#
|
|
80
|
+
result = CreateUser.call(email: "", username: "", age: -5)
|
|
81
|
+
result.failure? # => true
|
|
82
|
+
result.errors # => Array of error hashes
|
|
82
83
|
```
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
**Default mode** (human-readable messages):
|
|
86
86
|
```ruby
|
|
87
|
-
|
|
88
|
-
#
|
|
87
|
+
result.errors
|
|
88
|
+
# => [
|
|
89
|
+
# { attribute: :email, type: :blank, message: "Email can't be blank" },
|
|
90
|
+
# { attribute: :username, type: :blank, message: "Username can't be blank" },
|
|
91
|
+
# { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
|
|
92
|
+
# ]
|
|
89
93
|
```
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
**Code mode** (machine-readable codes):
|
|
93
96
|
```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
|
|
97
|
+
# Set mode to :code in configuration
|
|
98
|
+
Interactor::Validation.configure { |config| config.mode = :code }
|
|
105
99
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
#
|
|
100
|
+
result.errors
|
|
101
|
+
# => [
|
|
102
|
+
# { code: 'EMAIL_IS_REQUIRED' },
|
|
103
|
+
# { code: 'USERNAME_IS_REQUIRED' },
|
|
104
|
+
# { code: 'AGE_MUST_BE_GREATER_THAN_0' }
|
|
105
|
+
# ]
|
|
109
106
|
```
|
|
110
107
|
|
|
111
|
-
|
|
108
|
+
## Validations
|
|
112
109
|
|
|
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
|
-
```
|
|
110
|
+
All validators support custom error messages via the `message` option.
|
|
121
111
|
|
|
122
112
|
### Presence
|
|
123
113
|
|
|
124
|
-
Validates that a value is not nil or
|
|
114
|
+
Validates that a value is not `nil`, empty string, or blank.
|
|
125
115
|
|
|
126
116
|
```ruby
|
|
127
117
|
validates :name, presence: true
|
|
128
118
|
validates :email, presence: { message: "Email is required" }
|
|
129
119
|
```
|
|
130
120
|
|
|
131
|
-
**Errors:**
|
|
132
|
-
- Default mode: `{ attribute: :name, type: :blank, message: "Name can't be blank" }`
|
|
133
|
-
- Code mode: `{ code: "NAME_IS_REQUIRED" }`
|
|
134
|
-
|
|
135
121
|
### Format
|
|
136
122
|
|
|
137
123
|
Validates that a value matches a regular expression pattern.
|
|
138
124
|
|
|
139
125
|
```ruby
|
|
140
126
|
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: "
|
|
127
|
+
validates :username, format: { with: /\A[a-z0-9_]+\z/, message: "Invalid username" }
|
|
142
128
|
```
|
|
143
129
|
|
|
144
|
-
**Errors:**
|
|
145
|
-
- Default mode: `{ attribute: :email, type: :invalid, message: "Email is invalid" }`
|
|
146
|
-
- Code mode: `{ code: "EMAIL_INVALID_FORMAT" }`
|
|
147
|
-
|
|
148
130
|
### Length
|
|
149
131
|
|
|
150
|
-
Validates the length of a string
|
|
132
|
+
Validates the length of a string.
|
|
133
|
+
|
|
134
|
+
**Options:** `minimum`, `maximum`, `is`
|
|
151
135
|
|
|
152
136
|
```ruby
|
|
153
137
|
validates :password, length: { minimum: 8, maximum: 128 }
|
|
@@ -155,48 +139,31 @@ validates :code, length: { is: 6 }
|
|
|
155
139
|
validates :bio, length: { maximum: 500 }
|
|
156
140
|
```
|
|
157
141
|
|
|
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
142
|
### Inclusion
|
|
166
143
|
|
|
167
|
-
Validates that a value is in a
|
|
144
|
+
Validates that a value is included in a set of allowed values.
|
|
168
145
|
|
|
169
146
|
```ruby
|
|
170
147
|
validates :status, inclusion: { in: %w[active pending inactive] }
|
|
171
|
-
validates :role, inclusion: { in: ["admin", "user", "guest"] }
|
|
148
|
+
validates :role, inclusion: { in: ["admin", "user", "guest"], message: "Invalid role" }
|
|
172
149
|
```
|
|
173
150
|
|
|
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
151
|
### Numericality
|
|
179
152
|
|
|
180
|
-
Validates
|
|
153
|
+
Validates numeric values and comparisons.
|
|
154
|
+
|
|
155
|
+
**Options:** `greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `equal_to`
|
|
181
156
|
|
|
182
157
|
```ruby
|
|
183
158
|
validates :age, numericality: { greater_than: 0 }
|
|
184
159
|
validates :price, numericality: { greater_than_or_equal_to: 0 }
|
|
185
160
|
validates :quantity, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
|
|
186
161
|
validates :rating, numericality: { equal_to: 5 }
|
|
187
|
-
validates :count, numericality: true # Just
|
|
188
|
-
```
|
|
162
|
+
validates :count, numericality: true # Just verify it's numeric
|
|
189
163
|
|
|
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" }`
|
|
164
|
+
# Shorthand: 'numeric' alias
|
|
165
|
+
validates :age, numeric: { greater_than: 0 }
|
|
166
|
+
```
|
|
200
167
|
|
|
201
168
|
### Boolean
|
|
202
169
|
|
|
@@ -207,1446 +174,342 @@ validates :is_active, boolean: true
|
|
|
207
174
|
validates :terms_accepted, boolean: true
|
|
208
175
|
```
|
|
209
176
|
|
|
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
177
|
### Nested Validation
|
|
215
178
|
|
|
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:
|
|
179
|
+
Validate complex nested structures like hashes and arrays using block syntax.
|
|
310
180
|
|
|
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
|
|
181
|
+
#### Hash Validation
|
|
350
182
|
|
|
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:
|
|
183
|
+
Use a block to define validations for hash attributes:
|
|
386
184
|
|
|
387
185
|
```ruby
|
|
388
186
|
class CreateUser
|
|
389
187
|
include Interactor
|
|
390
188
|
include Interactor::Validation
|
|
391
189
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
190
|
+
params :user
|
|
191
|
+
|
|
192
|
+
validates :user, presence: true do
|
|
193
|
+
attribute :name, presence: true
|
|
194
|
+
attribute :email, format: { with: /@/ }
|
|
195
|
+
attribute :age, numericality: { greater_than: 0 }
|
|
395
196
|
end
|
|
396
197
|
|
|
397
|
-
|
|
398
|
-
|
|
198
|
+
def call
|
|
199
|
+
User.create!(user)
|
|
200
|
+
end
|
|
399
201
|
end
|
|
400
|
-
```
|
|
401
202
|
|
|
402
|
-
|
|
203
|
+
result = CreateUser.call(user: { name: "", email: "bad" })
|
|
204
|
+
result.errors
|
|
205
|
+
# => [
|
|
206
|
+
# { attribute: :"user.name", type: :blank, message: "User name can't be blank" },
|
|
207
|
+
# { attribute: :"user.email", type: :invalid, message: "User email is invalid" }
|
|
208
|
+
# ]
|
|
209
|
+
```
|
|
403
210
|
|
|
404
|
-
|
|
211
|
+
#### Array Validation
|
|
405
212
|
|
|
406
|
-
|
|
213
|
+
Validate each element in an array by passing a block without additional options:
|
|
407
214
|
|
|
408
215
|
```ruby
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
216
|
+
class BulkCreateItems
|
|
217
|
+
include Interactor
|
|
218
|
+
include Interactor::Validation
|
|
419
219
|
|
|
420
|
-
|
|
220
|
+
params :items
|
|
421
221
|
|
|
422
|
-
|
|
222
|
+
validates :items do
|
|
223
|
+
attribute :name, presence: true
|
|
224
|
+
attribute :price, numericality: { greater_than: 0 }
|
|
225
|
+
end
|
|
423
226
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
227
|
+
def call
|
|
228
|
+
items.each { |item| Item.create!(item) }
|
|
229
|
+
end
|
|
427
230
|
end
|
|
428
231
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
232
|
+
result = BulkCreateItems.call(items: [
|
|
233
|
+
{ name: "Widget", price: 10 },
|
|
234
|
+
{ name: "", price: -5 }
|
|
235
|
+
])
|
|
236
|
+
result.errors
|
|
237
|
+
# => [
|
|
238
|
+
# { attribute: :"items[1].name", type: :blank, message: "Items[1] name can't be blank" },
|
|
239
|
+
# { attribute: :"items[1].price", type: :greater_than, message: "Items[1] price must be greater than 0" }
|
|
240
|
+
# ]
|
|
432
241
|
```
|
|
433
242
|
|
|
434
|
-
|
|
243
|
+
## Custom Validations
|
|
435
244
|
|
|
436
|
-
|
|
245
|
+
Override `validate!` for custom business logic that requires external dependencies (database queries, API calls, etc.):
|
|
437
246
|
|
|
438
247
|
```ruby
|
|
439
|
-
class
|
|
248
|
+
class CreateOrder
|
|
440
249
|
include Interactor
|
|
441
250
|
include Interactor::Validation
|
|
442
251
|
|
|
443
|
-
params :
|
|
444
|
-
|
|
445
|
-
validates :payment_method, inclusion: { in: %w[credit_card paypal] }
|
|
446
|
-
|
|
447
|
-
validate :check_order_exists
|
|
252
|
+
params :product_id, :quantity, :user_id
|
|
448
253
|
|
|
449
|
-
|
|
450
|
-
|
|
254
|
+
validates :product_id, presence: true
|
|
255
|
+
validates :quantity, numericality: { greater_than: 0 }
|
|
256
|
+
validates :user_id, presence: true
|
|
451
257
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
return
|
|
456
|
-
end
|
|
258
|
+
def validate!
|
|
259
|
+
# Parameter validations have already run at this point
|
|
260
|
+
# No need to call super - there is no parent validate! method
|
|
457
261
|
|
|
458
|
-
|
|
459
|
-
if
|
|
460
|
-
errors.add(:
|
|
262
|
+
product = Product.find_by(id: product_id)
|
|
263
|
+
if product.nil?
|
|
264
|
+
errors.add(:product_id, :not_found, message: "Product not found")
|
|
265
|
+
elsif product.stock < quantity
|
|
266
|
+
errors.add(:quantity, :insufficient, message: "Insufficient stock")
|
|
461
267
|
end
|
|
462
268
|
end
|
|
463
269
|
|
|
464
270
|
def call
|
|
465
|
-
|
|
271
|
+
Order.create!(product_id: product_id, quantity: quantity, user_id: user_id)
|
|
466
272
|
end
|
|
467
273
|
end
|
|
468
274
|
```
|
|
469
275
|
|
|
470
|
-
**
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
- **Within-parameter halt**: When halt is triggered, remaining validation rules for that parameter are skipped
|
|
474
|
-
- **Across-parameter halt**: Subsequent parameters won't be validated
|
|
276
|
+
**Important:** Parameter validations (defined via `validates`) run automatically before `validate!`. You should never call `super` in your `validate!` method as there is no parent implementation.
|
|
277
|
+
|
|
278
|
+
## Configuration
|
|
475
279
|
|
|
476
|
-
|
|
477
|
-
- Stop validating dependent fields when a required field is missing
|
|
478
|
-
- Skip expensive validations when basic checks fail
|
|
479
|
-
- Improve API response times by failing fast
|
|
480
|
-
- Provide cleaner error messages (only the most relevant error)
|
|
280
|
+
Configuration can be set at three levels (in order of precedence):
|
|
481
281
|
|
|
482
|
-
###
|
|
282
|
+
### 1. Per-Interactor Configuration
|
|
483
283
|
|
|
484
|
-
|
|
284
|
+
Configure individual interactors using either a `configure` block or dedicated methods:
|
|
485
285
|
|
|
486
286
|
```ruby
|
|
487
|
-
# Default behavior (skip_validate = true)
|
|
488
287
|
class CreateUser
|
|
489
288
|
include Interactor
|
|
490
289
|
include Interactor::Validation
|
|
491
290
|
|
|
492
|
-
|
|
493
|
-
|
|
291
|
+
# Option 1: Using configure block
|
|
292
|
+
configure do |config|
|
|
293
|
+
config.halt = true
|
|
294
|
+
config.mode = :code
|
|
494
295
|
end
|
|
495
296
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
def validate!
|
|
502
|
-
# This will NOT run if username or email validation fails
|
|
503
|
-
# Improves performance by skipping expensive checks
|
|
504
|
-
user_exists = User.exists?(username: username)
|
|
505
|
-
errors.add(:username, "already taken") if user_exists
|
|
506
|
-
end
|
|
297
|
+
# Option 2: Using dedicated methods
|
|
298
|
+
validation_halt true
|
|
299
|
+
validation_mode :code
|
|
300
|
+
validation_skip_validate false
|
|
507
301
|
|
|
508
|
-
|
|
509
|
-
User.create!(username: username, email: email)
|
|
510
|
-
end
|
|
302
|
+
# ... validations and call method
|
|
511
303
|
end
|
|
304
|
+
```
|
|
512
305
|
|
|
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
|
|
306
|
+
Configuration is inherited from parent classes and can be overridden in child classes.
|
|
523
307
|
|
|
524
|
-
|
|
525
|
-
validates :email, presence: true
|
|
308
|
+
### 2. Global Configuration
|
|
526
309
|
|
|
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
|
|
310
|
+
Configure global defaults in an initializer or before your interactors are loaded:
|
|
533
311
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
312
|
+
```ruby
|
|
313
|
+
Interactor::Validation.configure do |config|
|
|
314
|
+
config.skip_validate = true # Skip custom validate! if params fail (default: true)
|
|
315
|
+
config.mode = :default # Error format: :default or :code (default: :default)
|
|
316
|
+
config.halt = false # Stop on first error (default: false)
|
|
537
317
|
end
|
|
538
318
|
```
|
|
539
319
|
|
|
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
|
|
320
|
+
### Configuration Options
|
|
545
321
|
|
|
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
|
|
322
|
+
#### skip_validate
|
|
550
323
|
|
|
551
|
-
|
|
324
|
+
**Default:** `true`
|
|
552
325
|
|
|
553
|
-
|
|
326
|
+
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
327
|
|
|
555
328
|
```ruby
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
329
|
+
Interactor::Validation.configure do |config|
|
|
330
|
+
config.skip_validate = false # Always run custom validate! even if params fail
|
|
331
|
+
end
|
|
332
|
+
```
|
|
559
333
|
|
|
560
|
-
|
|
334
|
+
#### mode
|
|
561
335
|
|
|
562
|
-
|
|
563
|
-
validates :username, presence: true
|
|
336
|
+
**Default:** `:default`
|
|
564
337
|
|
|
565
|
-
|
|
338
|
+
Controls error message format. Choose between human-readable messages (`:default`) or machine-readable codes (`:code`).
|
|
566
339
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
340
|
+
**Default mode** - Human-readable messages with full context:
|
|
341
|
+
```ruby
|
|
342
|
+
Interactor::Validation.configure do |config|
|
|
343
|
+
config.mode = :default
|
|
570
344
|
end
|
|
571
|
-
```
|
|
572
345
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
346
|
+
result = CreateUser.call(email: "", age: -5)
|
|
347
|
+
result.errors
|
|
348
|
+
# => [
|
|
349
|
+
# { attribute: :email, type: :blank, message: "Email can't be blank" },
|
|
350
|
+
# { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
|
|
351
|
+
# ]
|
|
352
|
+
```
|
|
576
353
|
|
|
354
|
+
**Code mode** - Minimal error codes for API responses:
|
|
577
355
|
```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]})"
|
|
356
|
+
Interactor::Validation.configure do |config|
|
|
357
|
+
config.mode = :code
|
|
583
358
|
end
|
|
584
|
-
```
|
|
585
359
|
|
|
586
|
-
|
|
360
|
+
result = CreateUser.call(email: "", age: -5)
|
|
361
|
+
result.errors
|
|
362
|
+
# => [
|
|
363
|
+
# { code: "EMAIL_IS_REQUIRED" },
|
|
364
|
+
# { code: "AGE_GREATER_THAN" }
|
|
365
|
+
# ]
|
|
366
|
+
```
|
|
587
367
|
|
|
588
|
-
|
|
368
|
+
#### halt
|
|
589
369
|
|
|
590
|
-
|
|
370
|
+
**Default:** `false`
|
|
591
371
|
|
|
592
|
-
|
|
372
|
+
Stop validation on the first error instead of collecting all validation failures.
|
|
593
373
|
|
|
594
374
|
```ruby
|
|
595
|
-
|
|
375
|
+
Interactor::Validation.configure do |config|
|
|
376
|
+
config.halt = true
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
result = CreateUser.call(email: "", username: "", age: -5)
|
|
380
|
+
result.errors.size # => 1 (only the first error is captured)
|
|
596
381
|
```
|
|
597
382
|
|
|
598
|
-
|
|
383
|
+
## Error Format
|
|
599
384
|
|
|
600
|
-
|
|
385
|
+
Validations run automatically before the `call` method executes. If any validation fails, the interactor halts with `context.fail!` and populates `context.errors`.
|
|
601
386
|
|
|
602
|
-
|
|
387
|
+
Errors are returned as an array of hashes. The format depends on the `mode` configuration:
|
|
603
388
|
|
|
389
|
+
**Default mode** (verbose with full context):
|
|
604
390
|
```ruby
|
|
605
|
-
|
|
391
|
+
{
|
|
392
|
+
attribute: :email, # The field that failed
|
|
393
|
+
type: :blank, # The validation type
|
|
394
|
+
message: "Email can't be blank" # Human-readable message
|
|
395
|
+
}
|
|
606
396
|
```
|
|
607
397
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
398
|
+
**Code mode** (minimal for API responses):
|
|
399
|
+
```ruby
|
|
400
|
+
{
|
|
401
|
+
code: "EMAIL_IS_REQUIRED" # Machine-readable error code (SCREAMING_SNAKE_CASE)
|
|
402
|
+
}
|
|
403
|
+
```
|
|
619
404
|
|
|
620
|
-
|
|
405
|
+
Access errors via `result.errors` after calling an interactor:
|
|
406
|
+
```ruby
|
|
407
|
+
result = CreateUser.call(email: "")
|
|
408
|
+
result.failure?
|
|
409
|
+
# => true
|
|
621
410
|
|
|
622
|
-
|
|
411
|
+
result.errors
|
|
412
|
+
# => [
|
|
413
|
+
# { attribute: :email, type: :blank, message: "Email can't be blank" }
|
|
414
|
+
# ]
|
|
415
|
+
```
|
|
623
416
|
|
|
624
|
-
|
|
417
|
+
## Parameter Delegation
|
|
625
418
|
|
|
626
|
-
The `
|
|
419
|
+
The `params` macro provides convenient access to context values, allowing you to reference parameters directly without the `context.` prefix.
|
|
627
420
|
|
|
628
421
|
```ruby
|
|
629
|
-
class
|
|
422
|
+
class UpdateUser
|
|
630
423
|
include Interactor
|
|
631
424
|
include Interactor::Validation
|
|
632
425
|
|
|
633
|
-
params :
|
|
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
|
|
426
|
+
params :user_id, :email
|
|
648
427
|
|
|
649
|
-
|
|
650
|
-
if user && !user.active?
|
|
651
|
-
errors.add(:user_id, "USER_NOT_ACTIVE")
|
|
652
|
-
end
|
|
653
|
-
end
|
|
428
|
+
validates :email, format: { with: /@/ }
|
|
654
429
|
|
|
655
430
|
def call
|
|
656
|
-
#
|
|
657
|
-
|
|
431
|
+
# Access params directly instead of context.user_id, context.email
|
|
432
|
+
user = User.find(user_id)
|
|
433
|
+
user.update!(email: email)
|
|
658
434
|
end
|
|
659
435
|
end
|
|
660
|
-
|
|
661
|
-
# Usage
|
|
662
|
-
result = CreateOrder.call(product_id: 999, quantity: 100, user_id: 1)
|
|
663
|
-
result.failure? # => true
|
|
664
|
-
result.errors # => [{ code: "PRODUCT_ID_PRODUCT_NOT_FOUND" }]
|
|
665
436
|
```
|
|
666
437
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
Validations run in this order:
|
|
670
|
-
|
|
671
|
-
1. **Parameter validations** (`validates :field, ...`)
|
|
672
|
-
2. **Custom validate! hook** (your custom logic)
|
|
673
|
-
3. **call method** (only if no errors)
|
|
674
|
-
|
|
675
|
-
```ruby
|
|
676
|
-
class ProcessPayment
|
|
677
|
-
include Interactor
|
|
678
|
-
include Interactor::Validation
|
|
438
|
+
This is purely syntactic sugar - under the hood, `user_id` and `email` still reference `context.user_id` and `context.email`.
|
|
679
439
|
|
|
680
|
-
|
|
440
|
+
## Requirements
|
|
681
441
|
|
|
682
|
-
|
|
683
|
-
|
|
442
|
+
- Ruby >= 3.2.0
|
|
443
|
+
- Interactor ~> 3.0
|
|
684
444
|
|
|
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
|
|
445
|
+
## Design Philosophy
|
|
690
446
|
|
|
691
|
-
|
|
692
|
-
# This only runs if both parameter validations AND validate! pass
|
|
693
|
-
PaymentGateway.charge(amount: amount, token: card_token)
|
|
694
|
-
end
|
|
695
|
-
end
|
|
696
|
-
```
|
|
447
|
+
This gem follows a minimalist philosophy:
|
|
697
448
|
|
|
698
|
-
|
|
449
|
+
- **Sensible defaults** - Works out of the box; configure only when needed
|
|
450
|
+
- **Core validations only** - Essential validators without bloat
|
|
451
|
+
- **Zero dependencies** - Only requires Interactor and Ruby stdlib
|
|
452
|
+
- **Simple & readable** - Straightforward code over clever optimizations
|
|
453
|
+
- **Interactor-first** - Built specifically for service object patterns
|
|
699
454
|
|
|
700
|
-
|
|
455
|
+
### Why Not ActiveModel::Validations?
|
|
701
456
|
|
|
702
|
-
|
|
703
|
-
# With :default mode (ActiveModel-style messages)
|
|
704
|
-
class UpdateProfile
|
|
705
|
-
include Interactor
|
|
706
|
-
include Interactor::Validation
|
|
457
|
+
While ActiveModel::Validations is powerful, it's designed for ActiveRecord models and carries assumptions about persistence. Interactor::Validation is:
|
|
707
458
|
|
|
708
|
-
|
|
459
|
+
- Lighter weight
|
|
460
|
+
- Designed specifically for transient service objects
|
|
461
|
+
- Simpler API tailored to interactor patterns
|
|
462
|
+
- Configurable error formats for API responses
|
|
709
463
|
|
|
710
|
-
|
|
464
|
+
## Development
|
|
711
465
|
|
|
712
|
-
|
|
713
|
-
if username && username.include?("admin")
|
|
714
|
-
errors.add(:username, "cannot contain 'admin'")
|
|
715
|
-
end
|
|
716
|
-
end
|
|
717
|
-
end
|
|
466
|
+
### Setup
|
|
718
467
|
|
|
719
|
-
|
|
720
|
-
|
|
468
|
+
```bash
|
|
469
|
+
bundle install
|
|
470
|
+
```
|
|
721
471
|
|
|
722
|
-
|
|
723
|
-
class UpdateProfile
|
|
724
|
-
include Interactor
|
|
725
|
-
include Interactor::Validation
|
|
472
|
+
### Running Tests
|
|
726
473
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
474
|
+
```bash
|
|
475
|
+
bundle exec rspec # Run all tests
|
|
476
|
+
bundle exec rspec spec/interactor/validation_spec.rb # Run specific test file
|
|
477
|
+
bundle exec rspec spec/interactor/validation_spec.rb:42 # Run specific test at line 42
|
|
478
|
+
```
|
|
730
479
|
|
|
731
|
-
|
|
480
|
+
### Linting
|
|
732
481
|
|
|
733
|
-
|
|
482
|
+
```bash
|
|
483
|
+
bundle exec rubocop # Check code style
|
|
484
|
+
bundle exec rubocop -a # Auto-fix safe issues
|
|
485
|
+
bundle exec rubocop -A # Auto-fix all issues (use with caution)
|
|
486
|
+
```
|
|
734
487
|
|
|
735
|
-
|
|
736
|
-
if username && username.include?("admin")
|
|
737
|
-
errors.add(:username, "RESERVED_WORD")
|
|
738
|
-
end
|
|
739
|
-
end
|
|
740
|
-
end
|
|
488
|
+
### Combined (Default Rake Task)
|
|
741
489
|
|
|
742
|
-
|
|
743
|
-
|
|
490
|
+
```bash
|
|
491
|
+
bundle exec rake # Runs both rspec and rubocop
|
|
744
492
|
```
|
|
745
493
|
|
|
746
|
-
|
|
494
|
+
### Interactive Console
|
|
747
495
|
|
|
748
|
-
|
|
496
|
+
```bash
|
|
497
|
+
bundle exec irb -r ./lib/interactor/validation # Load gem in IRB
|
|
498
|
+
```
|
|
749
499
|
|
|
750
|
-
###
|
|
500
|
+
### Gem Management
|
|
751
501
|
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
502
|
+
```bash
|
|
503
|
+
bundle exec rake build # Build gem file
|
|
504
|
+
bundle exec rake install # Install gem locally
|
|
505
|
+
bundle exec rake release # Release gem (requires permissions)
|
|
506
|
+
```
|
|
757
507
|
|
|
758
|
-
|
|
759
|
-
end
|
|
508
|
+
## Contributing
|
|
760
509
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
params :email, :username
|
|
510
|
+
Contributions welcome! Please open an issue or pull request at:
|
|
511
|
+
https://github.com/zyxzen/interactor-validation
|
|
764
512
|
|
|
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
|
|
513
|
+
## License
|
|
1647
514
|
|
|
1648
515
|
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)
|