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