interactor-validation 0.3.2 → 0.3.3

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
@@ -2,99 +2,222 @@
2
2
 
3
3
  Add declarative parameter validation to your [Interactor](https://github.com/collectiveidea/interactor) service objects with Rails-style syntax.
4
4
 
5
+ ## Features
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
16
+
5
17
  ## Installation
6
18
 
19
+ Add the gem to your Gemfile:
20
+
7
21
  ```ruby
8
22
  gem "interactor-validation"
9
23
  ```
10
24
 
11
- ## Quick Start
25
+ Install the gem:
26
+
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ### Quick Example
12
34
 
13
35
  ```ruby
14
36
  class CreateUser
15
37
  include Interactor
16
38
  include Interactor::Validation
17
39
 
18
- params :email, :username, :age
40
+ # Declare parameters
41
+ params :email, :username, :age, :terms_accepted
19
42
 
20
- validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
21
- validates :username, presence: true, length: { minimum: 3, maximum: 20 }
22
- validates :age, numericality: { greater_than: 0, less_than: 150 }
43
+ # Add validations
44
+ validates :email, presence: true, format: { with: /@/ }
45
+ validates :username, presence: true
46
+ validates :age, numericality: { greater_than: 0 }
47
+ validates :terms_accepted, boolean: true
23
48
 
24
49
  def call
25
- user = User.create!(email: email, username: username, age: age)
26
- context.user = user
50
+ # Validations run automatically before this
51
+ User.create!(email: email, username: username, age: age)
27
52
  end
28
53
  end
29
54
 
30
- # Validation runs automatically before call
31
- result = CreateUser.call(email: "", username: "ab", age: -5)
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")
32
66
  result.failure? # => true
33
67
  result.errors # => [
34
68
  # { attribute: :email, type: :blank, message: "Email can't be blank" },
35
- # { attribute: :username, type: :too_short, message: "Username is too short (minimum is 3 characters)" },
36
- # { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
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" }
37
72
  # ]
38
73
  ```
39
74
 
40
- ## Validation Types
75
+ ### Examples by Validation Type
41
76
 
42
- ### Presence
77
+ #### Presence
43
78
 
44
79
  ```ruby
45
80
  validates :name, presence: true
46
81
  # Error: { attribute: :name, type: :blank, message: "Name can't be blank" }
47
82
  ```
48
83
 
49
- ### Format (Regex)
84
+ #### Format (Regex)
50
85
 
51
86
  ```ruby
52
87
  validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
53
88
  # Error: { attribute: :email, type: :invalid, message: "Email is invalid" }
54
89
  ```
55
90
 
91
+ #### Numericality
92
+
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
105
+
106
+ ```ruby
107
+ validates :is_active, boolean: true
108
+ # Ensures value is true or false (not truthy/falsy)
109
+ ```
110
+
111
+ ---
112
+
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
+ ```
121
+
122
+ ### Presence
123
+
124
+ Validates that a value is not nil or empty.
125
+
126
+ ```ruby
127
+ validates :name, presence: true
128
+ validates :email, presence: { message: "Email is required" }
129
+ ```
130
+
131
+ **Errors:**
132
+ - Default mode: `{ attribute: :name, type: :blank, message: "Name can't be blank" }`
133
+ - Code mode: `{ code: "NAME_IS_REQUIRED" }`
134
+
135
+ ### Format
136
+
137
+ Validates that a value matches a regular expression pattern.
138
+
139
+ ```ruby
140
+ 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" }
142
+ ```
143
+
144
+ **Errors:**
145
+ - Default mode: `{ attribute: :email, type: :invalid, message: "Email is invalid" }`
146
+ - Code mode: `{ code: "EMAIL_INVALID_FORMAT" }`
147
+
56
148
  ### Length
57
149
 
150
+ Validates the length of a string or array.
151
+
58
152
  ```ruby
59
153
  validates :password, length: { minimum: 8, maximum: 128 }
60
154
  validates :code, length: { is: 6 }
61
- # Errors: { attribute: :password, type: :too_short, message: "Password is too short (minimum is 8 characters)" }
62
- # { attribute: :code, type: :wrong_length, message: "Code is the wrong length (should be 6 characters)" }
155
+ validates :bio, length: { maximum: 500 }
63
156
  ```
64
157
 
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
+
65
165
  ### Inclusion
66
166
 
167
+ Validates that a value is in a specific list.
168
+
67
169
  ```ruby
68
170
  validates :status, inclusion: { in: %w[active pending inactive] }
69
- # Error: { attribute: :status, type: :inclusion, message: "Status is not included in the list" }
171
+ validates :role, inclusion: { in: ["admin", "user", "guest"] }
70
172
  ```
71
173
 
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
+
72
178
  ### Numericality
73
179
 
180
+ Validates that a value is numeric and optionally meets constraints.
181
+
74
182
  ```ruby
183
+ validates :age, numericality: { greater_than: 0 }
75
184
  validates :price, numericality: { greater_than_or_equal_to: 0 }
76
185
  validates :quantity, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
186
+ validates :rating, numericality: { equal_to: 5 }
77
187
  validates :count, numericality: true # Just check if numeric
78
-
79
- # Available constraints:
80
- # - greater_than, greater_than_or_equal_to
81
- # - less_than, less_than_or_equal_to
82
- # - equal_to
83
188
  ```
84
189
 
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" }`
200
+
85
201
  ### Boolean
86
202
 
203
+ Validates that a value is exactly `true` or `false` (not truthy/falsy).
204
+
87
205
  ```ruby
88
206
  validates :is_active, boolean: true
89
- # Ensures value is true or false (not truthy/falsy)
207
+ validates :terms_accepted, boolean: true
90
208
  ```
91
209
 
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
+
92
214
  ### Nested Validation
93
215
 
94
- Validate nested hashes and arrays:
216
+ Validate nested hashes and arrays.
217
+
218
+ **Hash Validation:**
95
219
 
96
220
  ```ruby
97
- # Hash validation
98
221
  params :user
99
222
  validates :user do
100
223
  attribute :name, presence: true
@@ -102,14 +225,39 @@ validates :user do
102
225
  attribute :age, numericality: { greater_than: 0 }
103
226
  end
104
227
 
105
- # Array validation
228
+ # Usage
229
+ result = CreateUser.call(user: { name: "", email: "bad", age: -1 })
230
+ result.errors # => [
231
+ # { attribute: "user.name", type: :blank, message: "User.name can't be blank" },
232
+ # { attribute: "user.email", type: :invalid, message: "User.email is invalid" },
233
+ # { attribute: "user.age", type: :greater_than, message: "User.age must be greater than 0" }
234
+ # ]
235
+ ```
236
+
237
+ **Array Validation:**
238
+
239
+ ```ruby
106
240
  params :items
107
241
  validates :items do
108
242
  attribute :name, presence: true
109
243
  attribute :price, numericality: { greater_than: 0 }
110
244
  end
245
+
246
+ # Usage
247
+ result = ProcessItems.call(items: [
248
+ { name: "Widget", price: 10 },
249
+ { name: "", price: -5 }
250
+ ])
251
+ result.errors # => [
252
+ # { attribute: "items[1].name", type: :blank, message: "Items[1].name can't be blank" },
253
+ # { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" }
254
+ # ]
111
255
  ```
112
256
 
257
+ ---
258
+
259
+ # Detailed Documentation
260
+
113
261
  ## Error Formats
114
262
 
115
263
  Choose between two error format modes:
@@ -173,7 +321,7 @@ Interactor::Validation.configure do |config|
173
321
  config.error_mode = :default
174
322
 
175
323
  # Stop at first error for better performance
176
- config.halt_on_first_error = false
324
+ config.halt = false # Set to true to stop on first validation error
177
325
 
178
326
  # Security settings
179
327
  config.regex_timeout = 0.1 # Regex timeout in seconds (ReDoS protection)
@@ -198,7 +346,7 @@ class CreateUser
198
346
 
199
347
  configure_validation do |config|
200
348
  config.error_mode = :code
201
- config.halt_on_first_error = true
349
+ config.halt = true
202
350
  end
203
351
 
204
352
  validates :username, presence: true
@@ -224,11 +372,13 @@ end
224
372
 
225
373
  ### Halt on First Error
226
374
 
227
- Improve performance by stopping validation early:
375
+ Stop validation early for better performance and user experience:
376
+
377
+ #### Global Configuration
228
378
 
229
379
  ```ruby
230
380
  configure_validation do |config|
231
- config.halt_on_first_error = true
381
+ config.halt = true # Stop after first error (recommended)
232
382
  end
233
383
 
234
384
  validates :field1, presence: true
@@ -236,6 +386,54 @@ validates :field2, presence: true # Won't run if field1 fails
236
386
  validates :field3, presence: true # Won't run if earlier fields fail
237
387
  ```
238
388
 
389
+ #### Per-Error Halt
390
+
391
+ Use `halt: true` with `errors.add` for fine-grained control:
392
+
393
+ ```ruby
394
+ class ProcessOrder
395
+ include Interactor
396
+ include Interactor::Validation
397
+
398
+ params :order_id, :payment_method
399
+
400
+ validates :payment_method, inclusion: { in: %w[credit_card paypal] }
401
+
402
+ validate :check_order_exists
403
+
404
+ def check_order_exists
405
+ order = Order.find_by(id: context.order_id)
406
+
407
+ if order.nil?
408
+ # Halt immediately - no point validating payment if order doesn't exist
409
+ errors.add(:order_id, "not found", halt: true)
410
+ return
411
+ end
412
+
413
+ # This won't run if halt was triggered
414
+ if order.cancelled?
415
+ errors.add(:order_id, "order is cancelled")
416
+ end
417
+ end
418
+
419
+ def call
420
+ # Process order
421
+ end
422
+ end
423
+ ```
424
+
425
+ **How it works:**
426
+ - **Global `halt` config**: Stops validating subsequent parameters after first error
427
+ - **Per-error `halt: true`**: Stops validation immediately when that specific error is added
428
+ - **Within-parameter halt**: When halt is triggered, remaining validation rules for that parameter are skipped
429
+ - **Across-parameter halt**: Subsequent parameters won't be validated
430
+
431
+ **Use cases:**
432
+ - Stop validating dependent fields when a required field is missing
433
+ - Skip expensive validations when basic checks fail
434
+ - Improve API response times by failing fast
435
+ - Provide cleaner error messages (only the most relevant error)
436
+
239
437
  ### ActiveModel Integration
240
438
 
241
439
  Use ActiveModel's custom validation callbacks:
@@ -305,27 +503,964 @@ All validation operations are thread-safe for use with Puma, Sidekiq, etc.
305
503
  - Enable instrumentation to monitor performance
306
504
  - Review [SECURITY.md](SECURITY.md) for detailed information
307
505
 
308
- ## Development
506
+ ## Custom Validation Hook
309
507
 
310
- ```bash
311
- bin/setup # Install dependencies
312
- bundle exec rspec # Run tests (231 examples)
313
- bundle exec rubocop # Lint code
314
- bin/console # Interactive console
508
+ Use the `validate!` hook to add custom validation logic that goes beyond standard validations.
509
+
510
+ ### Basic Usage
511
+
512
+ The `validate!` method runs automatically after parameter validations:
513
+
514
+ ```ruby
515
+ class CreateOrder
516
+ include Interactor
517
+ include Interactor::Validation
518
+
519
+ params :product_id, :quantity, :user_id
520
+
521
+ validates :product_id, presence: true
522
+ validates :quantity, numericality: { greater_than: 0 }
523
+ validates :user_id, presence: true
524
+
525
+ def validate!
526
+ # Custom business logic validation
527
+ product = Product.find_by(id: product_id)
528
+
529
+ if product.nil?
530
+ errors.add(:product_id, "PRODUCT_NOT_FOUND")
531
+ elsif product.stock < quantity
532
+ errors.add(:quantity, "INSUFFICIENT_STOCK")
533
+ end
534
+
535
+ user = User.find_by(id: user_id)
536
+ if user && !user.active?
537
+ errors.add(:user_id, "USER_NOT_ACTIVE")
538
+ end
539
+ end
540
+
541
+ def call
542
+ # This only runs if all validations pass
543
+ Order.create!(product_id: product_id, quantity: quantity, user_id: user_id)
544
+ end
545
+ end
546
+
547
+ # Usage
548
+ result = CreateOrder.call(product_id: 999, quantity: 100, user_id: 1)
549
+ result.failure? # => true
550
+ result.errors # => [{ code: "PRODUCT_ID_PRODUCT_NOT_FOUND" }]
315
551
  ```
316
552
 
317
- ### Benchmarking
553
+ ### Execution Order
318
554
 
319
- ```bash
320
- bundle exec ruby benchmark/validation_benchmark.rb
555
+ Validations run in this order:
556
+
557
+ 1. **Parameter validations** (`validates :field, ...`)
558
+ 2. **Custom validate! hook** (your custom logic)
559
+ 3. **call method** (only if no errors)
560
+
561
+ ```ruby
562
+ class ProcessPayment
563
+ include Interactor
564
+ include Interactor::Validation
565
+
566
+ params :amount, :card_token
567
+
568
+ validates :amount, numericality: { greater_than: 0 }
569
+ validates :card_token, presence: true
570
+
571
+ def validate!
572
+ # This runs AFTER parameter validations pass
573
+ # Check payment gateway availability
574
+ errors.add(:base, "PAYMENT_GATEWAY_UNAVAILABLE") unless PaymentGateway.available?
575
+ end
576
+
577
+ def call
578
+ # This only runs if both parameter validations AND validate! pass
579
+ PaymentGateway.charge(amount: amount, token: card_token)
580
+ end
581
+ end
321
582
  ```
322
583
 
323
- ## Requirements
584
+ ### Combining with Error Modes
324
585
 
325
- - Ruby >= 3.2.0
326
- - Interactor ~> 3.0
327
- - ActiveModel >= 6.0
328
- - ActiveSupport >= 6.0
586
+ Works with both `:default` and `:code` error modes:
587
+
588
+ ```ruby
589
+ # With :default mode (ActiveModel-style messages)
590
+ class UpdateProfile
591
+ include Interactor
592
+ include Interactor::Validation
593
+
594
+ params :username, :bio
595
+
596
+ validates :username, presence: true
597
+
598
+ def validate!
599
+ if username && username.include?("admin")
600
+ errors.add(:username, "cannot contain 'admin'")
601
+ end
602
+ end
603
+ end
604
+
605
+ result = UpdateProfile.call(username: "admin123")
606
+ result.errors # => [{ attribute: :username, type: :invalid, message: "Username cannot contain 'admin'" }]
607
+
608
+ # With :code mode (structured error codes)
609
+ class UpdateProfile
610
+ include Interactor
611
+ include Interactor::Validation
612
+
613
+ configure_validation do |config|
614
+ config.error_mode = :code
615
+ end
616
+
617
+ params :username, :bio
618
+
619
+ validates :username, presence: true
620
+
621
+ def validate!
622
+ if username && username.include?("admin")
623
+ errors.add(:username, "RESERVED_WORD")
624
+ end
625
+ end
626
+ end
627
+
628
+ result = UpdateProfile.call(username: "admin123")
629
+ result.errors # => [{ code: "USERNAME_RESERVED_WORD" }]
630
+ ```
631
+
632
+ ## Inheritance
633
+
634
+ Create base interactors with shared validation logic that child classes automatically inherit.
635
+
636
+ ### Basic Inheritance
637
+
638
+ ```ruby
639
+ # Base interactor with common functionality
640
+ class ApplicationInteractor
641
+ include Interactor
642
+ include Interactor::Validation
643
+
644
+ # All child classes will inherit validation functionality
645
+ end
646
+
647
+ # Child interactor automatically gets validation
648
+ class CreateUser < ApplicationInteractor
649
+ params :email, :username
650
+
651
+ validates :email, presence: true, format: { with: /@/ }
652
+ validates :username, presence: true
653
+
654
+ def call
655
+ User.create!(email: email, username: username)
656
+ end
657
+ end
658
+
659
+ # Another child interactor
660
+ class UpdateUser < ApplicationInteractor
661
+ params :user_id, :email
662
+
663
+ validates :user_id, presence: true
664
+ validates :email, format: { with: /@/ }
665
+
666
+ def call
667
+ User.find(user_id).update!(email: email)
668
+ end
669
+ end
670
+
671
+ # Both work automatically
672
+ CreateUser.call(email: "user@example.com", username: "john") # => success
673
+ UpdateUser.call(user_id: 1, email: "invalid") # => failure with validation errors
674
+ ```
675
+
676
+ ### Shared Validation Configuration
677
+
678
+ Configure validation behavior in the base class:
679
+
680
+ ```ruby
681
+ class ApiInteractor
682
+ include Interactor
683
+ include Interactor::Validation
684
+
685
+ configure_validation do |config|
686
+ config.error_mode = :code # All child classes use code mode
687
+ config.halt = true
688
+ end
689
+ end
690
+
691
+ class CreatePost < ApiInteractor
692
+ params :title, :body
693
+
694
+ validates :title, presence: true
695
+ validates :body, presence: true
696
+
697
+ def call
698
+ Post.create!(title: title, body: body)
699
+ end
700
+ end
701
+
702
+ result = CreatePost.call(title: "", body: "")
703
+ result.errors # => [{ code: "TITLE_IS_REQUIRED" }] # Halted on first error
704
+ ```
705
+
706
+ ### Shared Custom Validations
707
+
708
+ Define common validation logic in the base class:
709
+
710
+ ```ruby
711
+ class AuthenticatedInteractor
712
+ include Interactor
713
+ include Interactor::Validation
714
+
715
+ params :user_id
716
+
717
+ validates :user_id, presence: true
718
+
719
+ def validate!
720
+ # This validation runs for ALL child classes
721
+ user = User.find_by(id: user_id)
722
+
723
+ if user.nil?
724
+ errors.add(:user_id, "USER_NOT_FOUND")
725
+ elsif !user.active?
726
+ errors.add(:user_id, "USER_INACTIVE")
727
+ end
728
+ end
729
+ end
730
+
731
+ class UpdateSettings < AuthenticatedInteractor
732
+ params :user_id, :theme
733
+
734
+ validates :theme, inclusion: { in: %w[light dark] }
735
+
736
+ def call
737
+ # user_id is already validated by parent
738
+ User.find(user_id).update!(theme: theme)
739
+ end
740
+ end
741
+
742
+ class DeleteAccount < AuthenticatedInteractor
743
+ params :user_id, :confirmation
744
+
745
+ validates :confirmation, presence: true
746
+
747
+ def validate!
748
+ super # Call parent's validate! first
749
+
750
+ # Add additional validation
751
+ if confirmation != "DELETE"
752
+ errors.add(:confirmation, "INVALID_CONFIRMATION")
753
+ end
754
+ end
755
+
756
+ def call
757
+ User.find(user_id).destroy!
758
+ end
759
+ end
760
+ ```
761
+
762
+ ### Multilevel Inheritance
763
+
764
+ Validation works across multiple inheritance levels:
765
+
766
+ ```ruby
767
+ # Level 1: Base
768
+ class ApplicationInteractor
769
+ include Interactor
770
+ include Interactor::Validation
771
+ end
772
+
773
+ # Level 2: Feature-specific base
774
+ class AdminInteractor < ApplicationInteractor
775
+ params :admin_id
776
+
777
+ validates :admin_id, presence: true
778
+
779
+ def validate!
780
+ admin = Admin.find_by(id: admin_id)
781
+ errors.add(:admin_id, "NOT_AN_ADMIN") if admin.nil?
782
+ end
783
+ end
784
+
785
+ # Level 3: Specific action
786
+ class BanUser < AdminInteractor
787
+ params :admin_id, :target_user_id, :reason
788
+
789
+ validates :target_user_id, presence: true
790
+ validates :reason, presence: true, length: { minimum: 10 }
791
+
792
+ def validate!
793
+ super # Validates admin_id
794
+
795
+ # Additional validation
796
+ target = User.find_by(id: target_user_id)
797
+ errors.add(:target_user_id, "USER_NOT_FOUND") if target.nil?
798
+ end
799
+
800
+ def call
801
+ User.find(target_user_id).ban!(reason: reason, banned_by: admin_id)
802
+ end
803
+ end
804
+
805
+ # All three levels of validation run automatically
806
+ result = BanUser.call(admin_id: 1, target_user_id: 999, reason: "Spam")
807
+ # Validates: admin_id presence, admin exists, target_user_id presence, target exists, reason presence/length
808
+ ```
809
+
810
+ ### Override Parent Configuration
811
+
812
+ Child classes can override parent configuration:
813
+
814
+ ```ruby
815
+ class BaseInteractor
816
+ include Interactor
817
+ include Interactor::Validation
818
+
819
+ configure_validation do |config|
820
+ config.error_mode = :default
821
+ end
822
+ end
823
+
824
+ class ApiCreateUser < BaseInteractor
825
+ # Override to use code mode for API
826
+ configure_validation do |config|
827
+ config.error_mode = :code
828
+ end
829
+
830
+ params :email
831
+
832
+ validates :email, presence: true
833
+
834
+ def call
835
+ User.create!(email: email)
836
+ end
837
+ end
838
+
839
+ result = ApiCreateUser.call(email: "")
840
+ result.errors # => [{ code: "EMAIL_IS_REQUIRED" }] # Uses :code mode, not :default
841
+ ```
842
+
843
+ ## Complete Usage Examples
844
+
845
+ ### All Validation Types
846
+
847
+ ```ruby
848
+ class CompleteExample
849
+ include Interactor
850
+ include Interactor::Validation
851
+
852
+ params :name, :email, :password, :age, :status, :terms, :profile, :tags
853
+
854
+ # Presence validation
855
+ validates :name, presence: true
856
+ # Error: { attribute: :name, type: :blank, message: "Name can't be blank" }
857
+
858
+ # Format validation (regex)
859
+ validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
860
+ # Error: { attribute: :email, type: :invalid, message: "Email is invalid" }
861
+
862
+ # Length validations
863
+ validates :password, length: { minimum: 8, maximum: 128 }
864
+ # Errors: { attribute: :password, type: :too_short, message: "Password is too short (minimum is 8 characters)" }
865
+ # { attribute: :password, type: :too_long, message: "Password is too long (maximum is 128 characters)" }
866
+
867
+ validates :name, length: { is: 10 }
868
+ # Error: { attribute: :name, type: :wrong_length, message: "Name is the wrong length (should be 10 characters)" }
869
+
870
+ # Numericality validations
871
+ validates :age,
872
+ numericality: {
873
+ greater_than: 0,
874
+ less_than: 150,
875
+ greater_than_or_equal_to: 18,
876
+ less_than_or_equal_to: 100,
877
+ equal_to: 25 # Exact value
878
+ }
879
+ # Errors: { attribute: :age, type: :greater_than, message: "Age must be greater than 0" }
880
+ # { attribute: :age, type: :less_than, message: "Age must be less than 150" }
881
+ # { attribute: :age, type: :greater_than_or_equal_to, message: "Age must be greater than or equal to 18" }
882
+ # { attribute: :age, type: :less_than_or_equal_to, message: "Age must be less than or equal to 100" }
883
+ # { attribute: :age, type: :equal_to, message: "Age must be equal to 25" }
884
+
885
+ # Inclusion validation
886
+ validates :status, inclusion: { in: %w[active pending inactive suspended] }
887
+ # Error: { attribute: :status, type: :inclusion, message: "Status is not included in the list" }
888
+
889
+ # Boolean validation
890
+ validates :terms, boolean: true
891
+ # Ensures value is exactly true or false (not truthy/falsy)
892
+
893
+ # Nested hash validation
894
+ validates :profile do
895
+ attribute :username, presence: true, length: { minimum: 3 }
896
+ attribute :bio, length: { maximum: 500 }
897
+ attribute :age, numericality: { greater_than: 0 }
898
+ end
899
+
900
+ # Nested array validation
901
+ validates :tags do
902
+ attribute :name, presence: true
903
+ attribute :priority, numericality: { greater_than_or_equal_to: 0 }
904
+ end
905
+
906
+ def call
907
+ # Your logic here
908
+ end
909
+ end
910
+ ```
911
+
912
+ ### Custom Error Messages
913
+
914
+ ```ruby
915
+ class CustomMessages
916
+ include Interactor
917
+ include Interactor::Validation
918
+
919
+ params :username, :email, :age
920
+
921
+ # Custom message for presence
922
+ validates :username, presence: { message: "Please provide a username" }
923
+
924
+ # Custom message for format
925
+ validates :email, format: { with: /@/, message: "Must be a valid email address" }
926
+
927
+ # Custom message for numericality
928
+ validates :age, numericality: { greater_than: 0, message: "Age must be positive" }
929
+
930
+ def call
931
+ # Your logic
932
+ end
933
+ end
934
+
935
+ # With :default mode
936
+ result = CustomMessages.call(username: "", email: "invalid", age: -5)
937
+ result.errors # => [
938
+ # { attribute: :username, type: :blank, message: "Username please provide a username" },
939
+ # { attribute: :email, type: :invalid, message: "Email must be a valid email address" },
940
+ # { attribute: :age, type: :greater_than, message: "Age age must be positive" }
941
+ # ]
942
+
943
+ # With :code mode
944
+ class CustomMessagesCode
945
+ include Interactor
946
+ include Interactor::Validation
947
+
948
+ configure_validation { |c| c.error_mode = :code }
949
+
950
+ params :username, :age
951
+
952
+ validates :username, presence: { message: "REQUIRED" }
953
+ validates :age, numericality: { greater_than: 0, message: "INVALID" }
954
+ end
955
+
956
+ result = CustomMessagesCode.call(username: "", age: -5)
957
+ result.errors # => [
958
+ # { code: "USERNAME_REQUIRED" },
959
+ # { code: "AGE_INVALID" }
960
+ # ]
961
+ ```
962
+
963
+ ### Error Modes Comparison
964
+
965
+ ```ruby
966
+ class UserRegistration
967
+ include Interactor
968
+ include Interactor::Validation
969
+
970
+ params :email, :password, :age
971
+
972
+ validates :email, presence: true, format: { with: /@/ }
973
+ validates :password, length: { minimum: 8 }
974
+ validates :age, numericality: { greater_than_or_equal_to: 18 }
975
+
976
+ def call
977
+ User.create!(email: email, password: password, age: age)
978
+ end
979
+ end
980
+
981
+ # Default mode (ActiveModel-style) - human-readable, detailed
982
+ result = UserRegistration.call(email: "bad", password: "short", age: 15)
983
+ result.errors # => [
984
+ # { attribute: :email, type: :invalid, message: "Email is invalid" },
985
+ # { attribute: :password, type: :too_short, message: "Password is too short (minimum is 8 characters)" },
986
+ # { attribute: :age, type: :greater_than_or_equal_to, message: "Age must be greater than or equal to 18" }
987
+ # ]
988
+
989
+ # Code mode - structured, API-friendly, i18n-ready
990
+ class ApiUserRegistration
991
+ include Interactor
992
+ include Interactor::Validation
993
+
994
+ configure_validation { |c| c.error_mode = :code }
995
+
996
+ params :email, :password, :age
997
+
998
+ validates :email, presence: true, format: { with: /@/ }
999
+ validates :password, length: { minimum: 8 }
1000
+ validates :age, numericality: { greater_than_or_equal_to: 18 }
1001
+
1002
+ def call
1003
+ User.create!(email: email, password: password, age: age)
1004
+ end
1005
+ end
1006
+
1007
+ result = ApiUserRegistration.call(email: "bad", password: "short", age: 15)
1008
+ result.errors # => [
1009
+ # { code: "EMAIL_INVALID_FORMAT" },
1010
+ # { code: "PASSWORD_BELOW_MIN_LENGTH_8" },
1011
+ # { code: "AGE_BELOW_MIN_VALUE_18" }
1012
+ # ]
1013
+ ```
1014
+
1015
+ ### Configuration Examples
1016
+
1017
+ ```ruby
1018
+ # Global configuration (config/initializers/interactor_validation.rb)
1019
+ Interactor::Validation.configure do |config|
1020
+ # Error format
1021
+ config.error_mode = :code # or :default
1022
+
1023
+ # Performance
1024
+ config.halt = true # Stop at first validation error
1025
+
1026
+ # Security
1027
+ config.regex_timeout = 0.1 # 100ms timeout for regex (ReDoS protection)
1028
+ config.max_array_size = 1000 # Max array size for nested validation
1029
+
1030
+ # Optimization
1031
+ config.cache_regex_patterns = true # Cache compiled regex patterns
1032
+
1033
+ # Monitoring
1034
+ config.enable_instrumentation = true
1035
+ end
1036
+
1037
+ # Per-interactor configuration (overrides global)
1038
+ class FastValidator
1039
+ include Interactor
1040
+ include Interactor::Validation
1041
+
1042
+ configure_validation do |config|
1043
+ config.halt = true # Override global setting
1044
+ config.error_mode = :code
1045
+ end
1046
+
1047
+ params :field1, :field2, :field3
1048
+
1049
+ validates :field1, presence: true
1050
+ validates :field2, presence: true # Won't run if field1 fails
1051
+ validates :field3, presence: true # Won't run if earlier fails
1052
+
1053
+ def call
1054
+ # Your logic
1055
+ end
1056
+ end
1057
+ ```
1058
+
1059
+ ### Nested Validation Examples
1060
+
1061
+ ```ruby
1062
+ # Hash validation
1063
+ class CreateUserWithProfile
1064
+ include Interactor
1065
+ include Interactor::Validation
1066
+
1067
+ params :user
1068
+
1069
+ validates :user do
1070
+ attribute :name, presence: true
1071
+ attribute :email, format: { with: /@/ }
1072
+ attribute :age, numericality: { greater_than: 0 }
1073
+ attribute :bio, length: { maximum: 500 }
1074
+ end
1075
+
1076
+ def call
1077
+ User.create!(user)
1078
+ end
1079
+ end
1080
+
1081
+ # Usage
1082
+ result = CreateUserWithProfile.call(
1083
+ user: {
1084
+ name: "",
1085
+ email: "invalid",
1086
+ age: -5,
1087
+ bio: "a" * 600
1088
+ }
1089
+ )
1090
+ result.errors # => [
1091
+ # { attribute: "user.name", type: :blank, message: "User.name can't be blank" },
1092
+ # { attribute: "user.email", type: :invalid, message: "User.email is invalid" },
1093
+ # { attribute: "user.age", type: :greater_than, message: "User.age must be greater than 0" },
1094
+ # { attribute: "user.bio", type: :too_long, message: "User.bio is too long (maximum is 500 characters)" }
1095
+ # ]
1096
+
1097
+ # Array validation
1098
+ class BulkCreateItems
1099
+ include Interactor
1100
+ include Interactor::Validation
1101
+
1102
+ params :items
1103
+
1104
+ validates :items do
1105
+ attribute :name, presence: true
1106
+ attribute :price, numericality: { greater_than: 0 }
1107
+ attribute :quantity, numericality: { greater_than_or_equal_to: 1 }
1108
+ end
1109
+
1110
+ def call
1111
+ items.each { |item| Item.create!(item) }
1112
+ end
1113
+ end
1114
+
1115
+ # Usage
1116
+ result = BulkCreateItems.call(
1117
+ items: [
1118
+ { name: "Widget", price: 10, quantity: 5 },
1119
+ { name: "", price: -5, quantity: 0 }
1120
+ ]
1121
+ )
1122
+ result.errors # => [
1123
+ # { attribute: "items[1].name", type: :blank, message: "Items[1].name can't be blank" },
1124
+ # { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" },
1125
+ # { attribute: "items[1].quantity", type: :greater_than_or_equal_to, message: "Items[1].quantity must be greater than or equal to 1" }
1126
+ # ]
1127
+ ```
1128
+
1129
+ ### ActiveModel Integration
1130
+
1131
+ ```ruby
1132
+ class CustomValidations
1133
+ include Interactor
1134
+ include Interactor::Validation
1135
+
1136
+ params :username, :password, :password_confirmation
1137
+
1138
+ validates :username, presence: true
1139
+ validates :password, presence: true
1140
+
1141
+ # Use ActiveModel's validate callback for complex logic
1142
+ validate :passwords_match
1143
+ validate :username_not_reserved
1144
+
1145
+ private
1146
+
1147
+ def passwords_match
1148
+ if password != password_confirmation
1149
+ errors.add(:password_confirmation, "doesn't match password")
1150
+ end
1151
+ end
1152
+
1153
+ def username_not_reserved
1154
+ reserved = %w[admin root system]
1155
+ if reserved.include?(username&.downcase)
1156
+ errors.add(:username, "is reserved")
1157
+ end
1158
+ end
1159
+ end
1160
+
1161
+ result = CustomValidations.call(
1162
+ username: "admin",
1163
+ password: "secret123",
1164
+ password_confirmation: "different"
1165
+ )
1166
+ result.errors # => [
1167
+ # { attribute: :username, type: :invalid, message: "Username is reserved" },
1168
+ # { attribute: :password_confirmation, type: :invalid, message: "Password confirmation doesn't match password" }
1169
+ # ]
1170
+ ```
1171
+
1172
+ ### Performance Monitoring
1173
+
1174
+ ```ruby
1175
+ # Enable instrumentation in configuration
1176
+ Interactor::Validation.configure do |config|
1177
+ config.enable_instrumentation = true
1178
+ end
1179
+
1180
+ # Subscribe to validation events
1181
+ ActiveSupport::Notifications.subscribe('validate_params.interactor_validation') do |*args|
1182
+ event = ActiveSupport::Notifications::Event.new(*args)
1183
+
1184
+ Rails.logger.info({
1185
+ event: 'validation',
1186
+ interactor: event.payload[:interactor],
1187
+ duration_ms: event.duration,
1188
+ validation_count: event.payload[:validation_count],
1189
+ error_count: event.payload[:error_count],
1190
+ halted: event.payload[:halted]
1191
+ }.to_json)
1192
+ end
1193
+
1194
+ # Now all validations are instrumented
1195
+ class SlowValidation
1196
+ include Interactor
1197
+ include Interactor::Validation
1198
+
1199
+ params :field1, :field2
1200
+
1201
+ validates :field1, presence: true
1202
+ validates :field2, format: { with: /complex.*regex/ }
1203
+
1204
+ def call
1205
+ # Your logic
1206
+ end
1207
+ end
1208
+
1209
+ # Logs: { "event": "validation", "interactor": "SlowValidation", "duration_ms": 2.5, ... }
1210
+ ```
1211
+
1212
+ ### Real-World Example: API Endpoint
1213
+
1214
+ ```ruby
1215
+ # Base API interactor
1216
+ class ApiInteractor
1217
+ include Interactor
1218
+ include Interactor::Validation
1219
+
1220
+ configure_validation do |config|
1221
+ config.error_mode = :code
1222
+ config.halt = false # Return all errors
1223
+ end
1224
+ end
1225
+
1226
+ # User registration endpoint
1227
+ class Api::V1::RegisterUser < ApiInteractor
1228
+ params :email, :password, :password_confirmation, :first_name, :last_name, :terms_accepted
1229
+
1230
+ validates :email,
1231
+ presence: { message: "REQUIRED" },
1232
+ format: { with: URI::MailTo::EMAIL_REGEXP, message: "INVALID_FORMAT" }
1233
+
1234
+ validates :password,
1235
+ presence: { message: "REQUIRED" },
1236
+ length: { minimum: 12, message: "TOO_SHORT" }
1237
+
1238
+ validates :first_name, presence: { message: "REQUIRED" }
1239
+ validates :last_name, presence: { message: "REQUIRED" }
1240
+ validates :terms_accepted, boolean: true
1241
+
1242
+ def validate!
1243
+ # Custom validations
1244
+ if password != password_confirmation
1245
+ errors.add(:password_confirmation, "MISMATCH")
1246
+ end
1247
+
1248
+ if User.exists?(email: email)
1249
+ errors.add(:email, "ALREADY_TAKEN")
1250
+ end
1251
+
1252
+ unless terms_accepted == true
1253
+ errors.add(:terms_accepted, "MUST_ACCEPT")
1254
+ end
1255
+ end
1256
+
1257
+ def call
1258
+ user = User.create!(
1259
+ email: email,
1260
+ password: password,
1261
+ first_name: first_name,
1262
+ last_name: last_name
1263
+ )
1264
+
1265
+ context.user = user
1266
+ context.token = generate_token(user)
1267
+ end
1268
+
1269
+ private
1270
+
1271
+ def generate_token(user)
1272
+ JWT.encode({ user_id: user.id }, Rails.application.secret_key_base)
1273
+ end
1274
+ end
1275
+
1276
+ # Controller
1277
+ class Api::V1::UsersController < ApplicationController
1278
+ def create
1279
+ result = Api::V1::RegisterUser.call(user_params)
1280
+
1281
+ if result.success?
1282
+ render json: {
1283
+ user: result.user,
1284
+ token: result.token
1285
+ }, status: :created
1286
+ else
1287
+ render json: {
1288
+ errors: result.errors
1289
+ }, status: :unprocessable_entity
1290
+ end
1291
+ end
1292
+ end
1293
+
1294
+ # Example error response:
1295
+ # {
1296
+ # "errors": [
1297
+ # { "code": "EMAIL_INVALID_FORMAT" },
1298
+ # { "code": "PASSWORD_TOO_SHORT" },
1299
+ # { "code": "TERMS_ACCEPTED_MUST_ACCEPT" }
1300
+ # ]
1301
+ # }
1302
+ ```
1303
+
1304
+ ### Real-World Example: Background Job
1305
+
1306
+ ```ruby
1307
+ # Background job with validation
1308
+ class ProcessOrderJob
1309
+ include Interactor
1310
+ include Interactor::Validation
1311
+
1312
+ configure_validation do |config|
1313
+ config.error_mode = :code
1314
+ end
1315
+
1316
+ params :order_id, :payment_method, :shipping_address
1317
+
1318
+ validates :order_id, presence: true
1319
+ validates :payment_method, inclusion: { in: %w[credit_card paypal stripe] }
1320
+
1321
+ validates :shipping_address do
1322
+ attribute :street, presence: true
1323
+ attribute :city, presence: true
1324
+ attribute :postal_code, presence: true, format: { with: /\A\d{5}\z/ }
1325
+ attribute :country, inclusion: { in: %w[US CA UK] }
1326
+ end
1327
+
1328
+ def validate!
1329
+ order = Order.find_by(id: order_id)
1330
+
1331
+ if order.nil?
1332
+ errors.add(:order_id, "NOT_FOUND")
1333
+ return
1334
+ end
1335
+
1336
+ if order.processed?
1337
+ errors.add(:order_id, "ALREADY_PROCESSED")
1338
+ end
1339
+
1340
+ if order.total_amount <= 0
1341
+ errors.add(:base, "INVALID_ORDER_AMOUNT")
1342
+ end
1343
+ end
1344
+
1345
+ def call
1346
+ order = Order.find(order_id)
1347
+
1348
+ payment = process_payment(order, payment_method)
1349
+ shipment = create_shipment(order, shipping_address)
1350
+
1351
+ order.update!(
1352
+ status: 'processed',
1353
+ payment_id: payment.id,
1354
+ shipment_id: shipment.id
1355
+ )
1356
+
1357
+ context.order = order
1358
+ end
1359
+ end
1360
+
1361
+ # Sidekiq job wrapper
1362
+ class ProcessOrderWorker
1363
+ include Sidekiq::Worker
1364
+
1365
+ def perform(order_id, payment_method, shipping_address)
1366
+ result = ProcessOrderJob.call(
1367
+ order_id: order_id,
1368
+ payment_method: payment_method,
1369
+ shipping_address: shipping_address
1370
+ )
1371
+
1372
+ unless result.success?
1373
+ # Log errors and retry or alert
1374
+ Rails.logger.error("Order processing failed: #{result.errors}")
1375
+ raise StandardError, "Validation failed: #{result.errors}"
1376
+ end
1377
+ end
1378
+ end
1379
+ ```
1380
+
1381
+ ### Security Best Practices
1382
+
1383
+ ```ruby
1384
+ # ReDoS protection
1385
+ class SecureValidation
1386
+ include Interactor
1387
+ include Interactor::Validation
1388
+
1389
+ configure_validation do |config|
1390
+ config.regex_timeout = 0.05 # 50ms timeout
1391
+ end
1392
+
1393
+ params :input
1394
+
1395
+ # Potentially dangerous regex (nested quantifiers)
1396
+ validates :input, format: { with: /^(a+)+$/ }
1397
+
1398
+ def call
1399
+ # If regex takes > 50ms, validation fails safely
1400
+ end
1401
+ end
1402
+
1403
+ # Array size protection
1404
+ class BulkOperation
1405
+ include Interactor
1406
+ include Interactor::Validation
1407
+
1408
+ configure_validation do |config|
1409
+ config.max_array_size = 100 # Limit to 100 items
1410
+ end
1411
+
1412
+ params :items
1413
+
1414
+ validates :items do
1415
+ attribute :name, presence: true
1416
+ end
1417
+
1418
+ def call
1419
+ # If items.length > 100, validation fails
1420
+ items.each { |item| process(item) }
1421
+ end
1422
+ end
1423
+
1424
+ # Sanitize error messages before displaying
1425
+ class UserInput
1426
+ include Interactor
1427
+ include Interactor::Validation
1428
+
1429
+ params :content
1430
+
1431
+ validates :content, presence: true
1432
+
1433
+ def call
1434
+ # Always sanitize user input
1435
+ sanitized = ActionController::Base.helpers.sanitize(content)
1436
+ Content.create!(body: sanitized)
1437
+ end
1438
+ end
1439
+ ```
1440
+
1441
+ ---
1442
+
1443
+ ## Requirements
1444
+
1445
+ - Ruby >= 3.2.0
1446
+ - Interactor ~> 3.0
1447
+ - ActiveModel >= 6.0
1448
+ - ActiveSupport >= 6.0
1449
+
1450
+ ## Development
1451
+
1452
+ ```bash
1453
+ bin/setup # Install dependencies
1454
+ bundle exec rspec # Run tests (231 examples)
1455
+ bundle exec rubocop # Lint code
1456
+ bin/console # Interactive console
1457
+ ```
1458
+
1459
+ ### Benchmarking
1460
+
1461
+ ```bash
1462
+ bundle exec ruby benchmark/validation_benchmark.rb
1463
+ ```
329
1464
 
330
1465
  ## License
331
1466