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.
data/README.md CHANGED
@@ -1,28 +1,45 @@
1
1
  # Interactor::Validation
2
2
 
3
- Add declarative parameter validation to your [Interactor](https://github.com/collectiveidea/interactor) service objects with Rails-style syntax.
3
+ Structured, lightweight parameter validation designed specifically for [Interactor](https://github.com/collectiveidea/interactor) service objects.
4
4
 
5
5
  ## Features
6
6
 
7
- - Declarative validations with Rails-style syntax
8
- - Nested validation for hash and array attributes
9
- - Fine-grained halt control for fail-fast validation
10
- - Multiple error formats (human-readable or structured codes)
11
- - Custom validation hooks for complex business logic
12
- - ActiveModel integration and custom validators
13
- - Built-in security (ReDoS protection, memory safeguards, thread-safe)
14
- - Performance monitoring via ActiveSupport::Notifications
15
- - Full inheritance support
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 the gem to your Gemfile:
36
+ Add to your Gemfile:
20
37
 
21
38
  ```ruby
22
39
  gem "interactor-validation"
23
40
  ```
24
41
 
25
- Install the gem:
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, :terms_accepted
59
+ # Declare expected parameters
60
+ params :email, :username, :age
42
61
 
43
- # Add validations
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
- ### Examples by Validation Type
76
-
77
- #### Presence
74
+ When validation fails, the interactor automatically halts with errors:
78
75
 
79
76
  ```ruby
80
- validates :name, presence: true
81
- # Error: { attribute: :name, type: :blank, message: "Name can't be blank" }
77
+ result = CreateUser.call(email: "", username: "", age: -5)
78
+ result.failure? # => true
79
+ result.errors # => Array of error hashes
82
80
  ```
83
81
 
84
- #### Format (Regex)
85
-
82
+ **Default mode** (human-readable messages):
86
83
  ```ruby
87
- validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
88
- # Error: { attribute: :email, type: :invalid, message: "Email is invalid" }
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
- #### Numericality
92
-
92
+ **Code mode** (machine-readable codes):
93
93
  ```ruby
94
- validates :price, numericality: { greater_than_or_equal_to: 0 }
95
- validates :quantity, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
96
- validates :count, numericality: true # Just check if numeric
97
-
98
- # Available constraints:
99
- # - greater_than, greater_than_or_equal_to
100
- # - less_than, less_than_or_equal_to
101
- # - equal_to
102
- ```
103
-
104
- #### Boolean
94
+ # Set mode to :code in configuration
95
+ Interactor::Validation.configure { |config| config.mode = :code }
105
96
 
106
- ```ruby
107
- validates :is_active, boolean: true
108
- # Ensures value is true or false (not truthy/falsy)
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
- ## Available Validations
114
-
115
- All standard validations support custom error messages:
116
-
117
- ```ruby
118
- validates :field, presence: { message: "Custom message" }
119
- validates :field, format: { with: /pattern/, message: "Invalid format" }
120
- ```
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 empty.
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: "Only lowercase letters, numbers, and underscores" }
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 or array.
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 specific list.
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 that a value is numeric and optionally meets constraints.
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 check if numeric
188
- ```
159
+ validates :count, numericality: true # Just verify it's numeric
189
160
 
190
- **Options:**
191
- - `greater_than`
192
- - `greater_than_or_equal_to`
193
- - `less_than`
194
- - `less_than_or_equal_to`
195
- - `equal_to`
196
-
197
- **Errors:**
198
- - Default mode: `{ attribute: :age, type: :greater_than, message: "Age must be greater than 0" }`
199
- - Code mode: `{ code: "AGE_BELOW_MIN_VALUE_0" }`
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 with support for both optional and required parameters.
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
- ```ruby
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
- ### Global Configuration
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
- configure_validation do |config|
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
- #### Global Configuration
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
- ```ruby
425
- configure_validation do |config|
426
- config.halt = true # Stop after first error (recommended)
195
+ def call
196
+ User.create!(user)
197
+ end
427
198
  end
428
199
 
429
- validates :field1, presence: true
430
- validates :field2, presence: true # Won't run if field1 fails
431
- validates :field3, presence: true # Won't run if earlier fields fail
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
- #### Per-Error Halt
208
+ #### Array Validation
435
209
 
436
- Use `halt: true` with `errors.add` for fine-grained control:
210
+ Validate each element in an array by passing a block without additional options:
437
211
 
438
212
  ```ruby
439
- class ProcessOrder
213
+ class BulkCreateItems
440
214
  include Interactor
441
215
  include Interactor::Validation
442
216
 
443
- params :order_id, :payment_method
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
- # This won't run if halt was triggered
459
- if order.cancelled?
460
- errors.add(:order_id, "order is cancelled")
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
- # Process order
225
+ items.each { |item| Item.create!(item) }
466
226
  end
467
227
  end
468
- ```
469
228
 
470
- **How it works:**
471
- - **Global `halt` config**: Stops validating subsequent parameters after first error
472
- - **Per-error `halt: true`**: Stops validation immediately when that specific error is added
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
475
-
476
- **Use cases:**
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)
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
- ### Skip Custom Validations on Parameter Errors
240
+ ## Custom Validations
483
241
 
484
- Control whether custom `validate!` hooks run when parameter validation fails:
242
+ Override `validate!` for custom business logic:
485
243
 
486
244
  ```ruby
487
- # Default behavior (skip_validate = true)
488
- class CreateUser
245
+ class CreateOrder
489
246
  include Interactor
490
247
  include Interactor::Validation
491
248
 
492
- configure_validation do |config|
493
- config.skip_validate = true # Default
494
- end
495
-
496
- params :username, :email
249
+ params :product_id, :quantity, :user_id
497
250
 
498
- validates :username, presence: true
499
- validates :email, presence: true
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
- # 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
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
- User.create!(username: username, email: email)
267
+ Order.create!(product_id: product_id, quantity: quantity, user_id: user_id)
510
268
  end
511
269
  end
270
+ ```
512
271
 
513
- # With skip_validate = false, always run validate!
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
- def validate!
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
- def call
535
- User.create!(username: username, email: email)
536
- end
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
- **When to use skip_validate = true (default):**
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
- **When to use skip_validate = false:**
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
- ### ActiveModel Integration
288
+ **Default:** `true`
552
289
 
553
- Use ActiveModel's custom validation callbacks:
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
- class CreateUser
557
- include Interactor
558
- include Interactor::Validation
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
- params :user_data
298
+ #### mode
561
299
 
562
- validate :check_custom_logic
563
- validates :username, presence: true
300
+ **Default:** `:default`
564
301
 
565
- private
302
+ Controls error message format. Choose between human-readable messages (`:default`) or machine-readable codes (`:code`).
566
303
 
567
- def check_custom_logic
568
- errors.add(:base, "Custom validation failed") unless custom_condition?
569
- end
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
- Track validation performance in production:
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
- config.enable_instrumentation = true
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
- ## Security
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
- Built-in protection against common vulnerabilities:
332
+ #### halt
589
333
 
590
- ### ReDoS Protection
334
+ **Default:** `false`
591
335
 
592
- Automatic timeouts prevent Regular Expression Denial of Service attacks:
336
+ Stop validation on the first error instead of collecting all validation failures.
593
337
 
594
338
  ```ruby
595
- config.regex_timeout = 0.1 # 100ms default
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
- If a regex exceeds the timeout, validation fails safely instead of hanging.
347
+ ## Error Format
599
348
 
600
- ### Memory Protection
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
- Array size limits prevent memory exhaustion:
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
- config.max_array_size = 1000 # Default limit
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
- ### Thread Safety
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
- class CreateOrder
630
- include Interactor
631
- include Interactor::Validation
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
- def call
656
- # This only runs if all validations pass
657
- Order.create!(product_id: product_id, quantity: quantity, user_id: user_id)
658
- end
659
- end
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
- # 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" }]
375
+ result.errors
376
+ # => [
377
+ # { attribute: :email, type: :blank, message: "Email can't be blank" }
378
+ # ]
665
379
  ```
666
380
 
667
- ### Execution Order
381
+ ## Parameter Delegation
668
382
 
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)
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 ProcessPayment
386
+ class UpdateUser
677
387
  include Interactor
678
388
  include Interactor::Validation
679
389
 
680
- params :amount, :card_token
681
-
682
- validates :amount, numericality: { greater_than: 0 }
683
- validates :card_token, presence: true
390
+ params :user_id, :email
684
391
 
685
- def validate!
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
- # This only runs if both parameter validations AND validate! pass
693
- PaymentGateway.charge(amount: amount, token: card_token)
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
- ### Combining with Error Modes
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
- params :username, :bio
404
+ ## Requirements
709
405
 
710
- validates :username, presence: true
406
+ - Ruby >= 3.2.0
407
+ - Interactor ~> 3.0
711
408
 
712
- def validate!
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
- result = UpdateProfile.call(username: "admin123")
720
- result.errors # => [{ attribute: :username, type: :invalid, message: "Username cannot contain 'admin'" }]
411
+ This gem follows a minimalist philosophy:
721
412
 
722
- # With :code mode (structured error codes)
723
- class UpdateProfile
724
- include Interactor
725
- include Interactor::Validation
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
- configure_validation do |config|
728
- config.error_mode = :code
729
- end
419
+ ### Why Not ActiveModel::Validations?
730
420
 
731
- params :username, :bio
421
+ While ActiveModel::Validations is powerful, it's designed for ActiveRecord models and carries assumptions about persistence. Interactor::Validation is:
732
422
 
733
- validates :username, presence: true
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
- def validate!
736
- if username && username.include?("admin")
737
- errors.add(:username, "RESERVED_WORD")
738
- end
739
- end
740
- end
428
+ ## Development
741
429
 
742
- result = UpdateProfile.call(username: "admin123")
743
- result.errors # => [{ code: "USERNAME_RESERVED_WORD" }]
430
+ ```bash
431
+ bundle install
432
+ bundle exec rspec # Run tests
433
+ bundle exec rubocop # Lint code
744
434
  ```
745
435
 
746
- ## Inheritance
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
- # Child interactor automatically gets validation
762
- class CreateUser < ApplicationInteractor
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
- validates :email, presence: true, format: { with: /@/ }
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)