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.
data/README.md CHANGED
@@ -1,28 +1,48 @@
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** - `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 the gem to your Gemfile:
39
+ Add to your Gemfile:
20
40
 
21
41
  ```ruby
22
42
  gem "interactor-validation"
23
43
  ```
24
44
 
25
- Install the gem:
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, :terms_accepted
62
+ # Declare expected parameters
63
+ params :email, :username, :age
42
64
 
43
- # Add validations
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
- ### Examples by Validation Type
76
-
77
- #### Presence
77
+ When validation fails, the interactor automatically halts with errors:
78
78
 
79
79
  ```ruby
80
- validates :name, presence: true
81
- # Error: { attribute: :name, type: :blank, message: "Name can't be blank" }
80
+ result = CreateUser.call(email: "", username: "", age: -5)
81
+ result.failure? # => true
82
+ result.errors # => Array of error hashes
82
83
  ```
83
84
 
84
- #### Format (Regex)
85
-
85
+ **Default mode** (human-readable messages):
86
86
  ```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" }
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
- #### Numericality
92
-
95
+ **Code mode** (machine-readable codes):
93
96
  ```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
97
+ # Set mode to :code in configuration
98
+ Interactor::Validation.configure { |config| config.mode = :code }
105
99
 
106
- ```ruby
107
- validates :is_active, boolean: true
108
- # Ensures value is true or false (not truthy/falsy)
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
- ## 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
- ```
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 empty.
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: "Only lowercase letters, numbers, and underscores" }
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 or array.
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 specific list.
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 that a value is numeric and optionally meets constraints.
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 check if numeric
188
- ```
162
+ validates :count, numericality: true # Just verify it's numeric
189
163
 
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" }`
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 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:
179
+ Validate complex nested structures like hashes and arrays using block syntax.
310
180
 
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
181
+ #### Hash Validation
350
182
 
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:
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
- configure_validation do |config|
393
- config.error_mode = :code
394
- config.halt = true
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
- validates :username, presence: true
398
- validates :email, presence: true
198
+ def call
199
+ User.create!(user)
200
+ end
399
201
  end
400
- ```
401
202
 
402
- ## Advanced Features
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
- ### Parameter Declaration
211
+ #### Array Validation
405
212
 
406
- Declare parameters for automatic delegation to context:
213
+ Validate each element in an array by passing a block without additional options:
407
214
 
408
215
  ```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
216
+ class BulkCreateItems
217
+ include Interactor
218
+ include Interactor::Validation
419
219
 
420
- Stop validation early for better performance and user experience:
220
+ params :items
421
221
 
422
- #### Global Configuration
222
+ validates :items do
223
+ attribute :name, presence: true
224
+ attribute :price, numericality: { greater_than: 0 }
225
+ end
423
226
 
424
- ```ruby
425
- configure_validation do |config|
426
- config.halt = true # Stop after first error (recommended)
227
+ def call
228
+ items.each { |item| Item.create!(item) }
229
+ end
427
230
  end
428
231
 
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
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
- #### Per-Error Halt
243
+ ## Custom Validations
435
244
 
436
- Use `halt: true` with `errors.add` for fine-grained control:
245
+ Override `validate!` for custom business logic that requires external dependencies (database queries, API calls, etc.):
437
246
 
438
247
  ```ruby
439
- class ProcessOrder
248
+ class CreateOrder
440
249
  include Interactor
441
250
  include Interactor::Validation
442
251
 
443
- params :order_id, :payment_method
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
- def check_order_exists
450
- order = Order.find_by(id: context.order_id)
254
+ validates :product_id, presence: true
255
+ validates :quantity, numericality: { greater_than: 0 }
256
+ validates :user_id, presence: true
451
257
 
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
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
- # This won't run if halt was triggered
459
- if order.cancelled?
460
- errors.add(:order_id, "order is cancelled")
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
- # Process order
271
+ Order.create!(product_id: product_id, quantity: quantity, user_id: user_id)
466
272
  end
467
273
  end
468
274
  ```
469
275
 
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
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
- **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)
280
+ Configuration can be set at three levels (in order of precedence):
481
281
 
482
- ### Skip Custom Validations on Parameter Errors
282
+ ### 1. Per-Interactor Configuration
483
283
 
484
- Control whether custom `validate!` hooks run when parameter validation fails:
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
- configure_validation do |config|
493
- config.skip_validate = true # Default
291
+ # Option 1: Using configure block
292
+ configure do |config|
293
+ config.halt = true
294
+ config.mode = :code
494
295
  end
495
296
 
496
- params :username, :email
497
-
498
- validates :username, presence: true
499
- validates :email, presence: true
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
- def call
509
- User.create!(username: username, email: email)
510
- end
302
+ # ... validations and call method
511
303
  end
304
+ ```
512
305
 
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
306
+ Configuration is inherited from parent classes and can be overridden in child classes.
523
307
 
524
- validates :username, presence: true
525
- validates :email, presence: true
308
+ ### 2. Global Configuration
526
309
 
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
310
+ Configure global defaults in an initializer or before your interactors are loaded:
533
311
 
534
- def call
535
- User.create!(username: username, email: email)
536
- end
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
- **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
320
+ ### Configuration Options
545
321
 
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
322
+ #### skip_validate
550
323
 
551
- ### ActiveModel Integration
324
+ **Default:** `true`
552
325
 
553
- Use ActiveModel's custom validation callbacks:
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
- class CreateUser
557
- include Interactor
558
- include Interactor::Validation
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
- params :user_data
334
+ #### mode
561
335
 
562
- validate :check_custom_logic
563
- validates :username, presence: true
336
+ **Default:** `:default`
564
337
 
565
- private
338
+ Controls error message format. Choose between human-readable messages (`:default`) or machine-readable codes (`:code`).
566
339
 
567
- def check_custom_logic
568
- errors.add(:base, "Custom validation failed") unless custom_condition?
569
- end
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
- ### Performance Monitoring
574
-
575
- Track validation performance in production:
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
- 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]})"
356
+ Interactor::Validation.configure do |config|
357
+ config.mode = :code
583
358
  end
584
- ```
585
359
 
586
- ## Security
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
- Built-in protection against common vulnerabilities:
368
+ #### halt
589
369
 
590
- ### ReDoS Protection
370
+ **Default:** `false`
591
371
 
592
- Automatic timeouts prevent Regular Expression Denial of Service attacks:
372
+ Stop validation on the first error instead of collecting all validation failures.
593
373
 
594
374
  ```ruby
595
- config.regex_timeout = 0.1 # 100ms default
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
- If a regex exceeds the timeout, validation fails safely instead of hanging.
383
+ ## Error Format
599
384
 
600
- ### Memory Protection
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
- Array size limits prevent memory exhaustion:
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
- config.max_array_size = 1000 # Default limit
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
- ### 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
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
- ## Custom Validation Hook
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
- Use the `validate!` hook to add custom validation logic that goes beyond standard validations.
411
+ result.errors
412
+ # => [
413
+ # { attribute: :email, type: :blank, message: "Email can't be blank" }
414
+ # ]
415
+ ```
623
416
 
624
- ### Basic Usage
417
+ ## Parameter Delegation
625
418
 
626
- The `validate!` method runs automatically after parameter validations:
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 CreateOrder
422
+ class UpdateUser
630
423
  include Interactor
631
424
  include Interactor::Validation
632
425
 
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
426
+ params :user_id, :email
648
427
 
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
428
+ validates :email, format: { with: /@/ }
654
429
 
655
430
  def call
656
- # This only runs if all validations pass
657
- Order.create!(product_id: product_id, quantity: quantity, user_id: user_id)
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
- ### Execution Order
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
- params :amount, :card_token
440
+ ## Requirements
681
441
 
682
- validates :amount, numericality: { greater_than: 0 }
683
- validates :card_token, presence: true
442
+ - Ruby >= 3.2.0
443
+ - Interactor ~> 3.0
684
444
 
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
445
+ ## Design Philosophy
690
446
 
691
- def call
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
- ### Combining with Error Modes
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
- Works with both `:default` and `:code` error modes:
455
+ ### Why Not ActiveModel::Validations?
701
456
 
702
- ```ruby
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
- params :username, :bio
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
- validates :username, presence: true
464
+ ## Development
711
465
 
712
- def validate!
713
- if username && username.include?("admin")
714
- errors.add(:username, "cannot contain 'admin'")
715
- end
716
- end
717
- end
466
+ ### Setup
718
467
 
719
- result = UpdateProfile.call(username: "admin123")
720
- result.errors # => [{ attribute: :username, type: :invalid, message: "Username cannot contain 'admin'" }]
468
+ ```bash
469
+ bundle install
470
+ ```
721
471
 
722
- # With :code mode (structured error codes)
723
- class UpdateProfile
724
- include Interactor
725
- include Interactor::Validation
472
+ ### Running Tests
726
473
 
727
- configure_validation do |config|
728
- config.error_mode = :code
729
- end
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
- params :username, :bio
480
+ ### Linting
732
481
 
733
- validates :username, presence: true
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
- def validate!
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
- result = UpdateProfile.call(username: "admin123")
743
- result.errors # => [{ code: "USERNAME_RESERVED_WORD" }]
490
+ ```bash
491
+ bundle exec rake # Runs both rspec and rubocop
744
492
  ```
745
493
 
746
- ## Inheritance
494
+ ### Interactive Console
747
495
 
748
- Create base interactors with shared validation logic that child classes automatically inherit.
496
+ ```bash
497
+ bundle exec irb -r ./lib/interactor/validation # Load gem in IRB
498
+ ```
749
499
 
750
- ### Basic Inheritance
500
+ ### Gem Management
751
501
 
752
- ```ruby
753
- # Base interactor with common functionality
754
- class ApplicationInteractor
755
- include Interactor
756
- include Interactor::Validation
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
- # All child classes will inherit validation functionality
759
- end
508
+ ## Contributing
760
509
 
761
- # Child interactor automatically gets validation
762
- class CreateUser < ApplicationInteractor
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
- 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
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)