interactor-validation 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +190 -9
- data/lib/interactor/validation/configuration.rb +3 -1
- data/lib/interactor/validation/validates.rb +32 -9
- data/lib/interactor/validation/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9c7888fc968eb40b00dbb2225455cd34f60444dfa710eb2d2e3ca991178a572
|
|
4
|
+
data.tar.gz: 2ff3d6b998b0bc1ec4112c888c7169842335f4829fd215b40e08b98e7e6a38a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 622c1af2f52477ccef49d4fea329a2d27d34b4a9cf30387a5c213ad58c7ae0ee8f645a942cde0dc12e2605128c607a204a1d44d7c54004c4205f27a6a3745107
|
|
7
|
+
data.tar.gz: 57adf88f0a34b152a3af76afdcce71b183dc0e5ef4d79b4bf2ccde88c4dc5cbc15173a2c1e5a257674487b0367ce29261cd721e483a333ac255ca31e5604db59
|
data/README.md
CHANGED
|
@@ -213,19 +213,49 @@ validates :terms_accepted, boolean: true
|
|
|
213
213
|
|
|
214
214
|
### Nested Validation
|
|
215
215
|
|
|
216
|
-
Validate nested hashes and arrays.
|
|
216
|
+
Validate nested hashes and arrays with support for both optional and required parameters.
|
|
217
217
|
|
|
218
|
-
**
|
|
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):**
|
|
219
241
|
|
|
220
242
|
```ruby
|
|
221
243
|
params :user
|
|
222
|
-
validates :user do
|
|
244
|
+
validates :user, presence: true do
|
|
223
245
|
attribute :name, presence: true
|
|
224
246
|
attribute :email, format: { with: /@/ }
|
|
225
247
|
attribute :age, numericality: { greater_than: 0 }
|
|
226
248
|
end
|
|
227
249
|
|
|
228
|
-
#
|
|
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
|
|
229
259
|
result = CreateUser.call(user: { name: "", email: "bad", age: -1 })
|
|
230
260
|
result.errors # => [
|
|
231
261
|
# { attribute: "user.name", type: :blank, message: "User.name can't be blank" },
|
|
@@ -252,6 +282,18 @@ result.errors # => [
|
|
|
252
282
|
# { attribute: "items[1].name", type: :blank, message: "Items[1].name can't be blank" },
|
|
253
283
|
# { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" }
|
|
254
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" }]
|
|
255
297
|
```
|
|
256
298
|
|
|
257
299
|
---
|
|
@@ -323,6 +365,9 @@ Interactor::Validation.configure do |config|
|
|
|
323
365
|
# Stop at first error for better performance
|
|
324
366
|
config.halt = false # Set to true to stop on first validation error
|
|
325
367
|
|
|
368
|
+
# Skip custom validate! hook when parameter validation fails
|
|
369
|
+
config.skip_validate = true # Set to false to always run validate! hook
|
|
370
|
+
|
|
326
371
|
# Security settings
|
|
327
372
|
config.regex_timeout = 0.1 # Regex timeout in seconds (ReDoS protection)
|
|
328
373
|
config.max_array_size = 1000 # Max array size for nested validation
|
|
@@ -434,6 +479,75 @@ end
|
|
|
434
479
|
- Improve API response times by failing fast
|
|
435
480
|
- Provide cleaner error messages (only the most relevant error)
|
|
436
481
|
|
|
482
|
+
### Skip Custom Validations on Parameter Errors
|
|
483
|
+
|
|
484
|
+
Control whether custom `validate!` hooks run when parameter validation fails:
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
# Default behavior (skip_validate = true)
|
|
488
|
+
class CreateUser
|
|
489
|
+
include Interactor
|
|
490
|
+
include Interactor::Validation
|
|
491
|
+
|
|
492
|
+
configure_validation do |config|
|
|
493
|
+
config.skip_validate = true # Default
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
params :username, :email
|
|
497
|
+
|
|
498
|
+
validates :username, presence: true
|
|
499
|
+
validates :email, presence: true
|
|
500
|
+
|
|
501
|
+
def validate!
|
|
502
|
+
# This will NOT run if username or email validation fails
|
|
503
|
+
# Improves performance by skipping expensive checks
|
|
504
|
+
user_exists = User.exists?(username: username)
|
|
505
|
+
errors.add(:username, "already taken") if user_exists
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def call
|
|
509
|
+
User.create!(username: username, email: email)
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# With skip_validate = false, always run validate!
|
|
514
|
+
class CreateUserAlwaysValidate
|
|
515
|
+
include Interactor
|
|
516
|
+
include Interactor::Validation
|
|
517
|
+
|
|
518
|
+
configure_validation do |config|
|
|
519
|
+
config.skip_validate = false # Run validate! even with param errors
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
params :username, :email
|
|
523
|
+
|
|
524
|
+
validates :username, presence: true
|
|
525
|
+
validates :email, presence: true
|
|
526
|
+
|
|
527
|
+
def validate!
|
|
528
|
+
# This WILL run even if username or email is missing
|
|
529
|
+
# Useful when you need to collect all possible errors
|
|
530
|
+
user_exists = User.exists?(username: username) if username.present?
|
|
531
|
+
errors.add(:username, "already taken") if user_exists
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def call
|
|
535
|
+
User.create!(username: username, email: email)
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**When to use skip_validate = true (default):**
|
|
541
|
+
- Skip expensive database lookups when basic params are invalid
|
|
542
|
+
- Avoid unnecessary API calls when required fields are missing
|
|
543
|
+
- Improve performance in high-traffic endpoints
|
|
544
|
+
- Fail fast with only parameter validation errors
|
|
545
|
+
|
|
546
|
+
**When to use skip_validate = false:**
|
|
547
|
+
- Collect all possible validation errors in a single request
|
|
548
|
+
- Always run business logic validations regardless of param state
|
|
549
|
+
- Provide comprehensive error feedback to users
|
|
550
|
+
|
|
437
551
|
### ActiveModel Integration
|
|
438
552
|
|
|
439
553
|
Use ActiveModel's custom validation callbacks:
|
|
@@ -1059,14 +1173,45 @@ end
|
|
|
1059
1173
|
### Nested Validation Examples
|
|
1060
1174
|
|
|
1061
1175
|
```ruby
|
|
1062
|
-
#
|
|
1176
|
+
# Optional hash validation (parameter can be nil)
|
|
1177
|
+
class SearchWithFilters
|
|
1178
|
+
include Interactor
|
|
1179
|
+
include Interactor::Validation
|
|
1180
|
+
|
|
1181
|
+
params :filters
|
|
1182
|
+
|
|
1183
|
+
validates :filters do
|
|
1184
|
+
attribute :category, presence: true
|
|
1185
|
+
attribute :min_price, numericality: { greater_than_or_equal_to: 0 }
|
|
1186
|
+
attribute :max_price, numericality: { greater_than_or_equal_to: 0 }
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def call
|
|
1190
|
+
# filters is optional - if nil, skip filtering
|
|
1191
|
+
results = filters ? apply_filters(Product.all) : Product.all
|
|
1192
|
+
context.results = results
|
|
1193
|
+
end
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
# When filters is nil - succeeds
|
|
1197
|
+
result = SearchWithFilters.call(filters: nil)
|
|
1198
|
+
result.success? # => true
|
|
1199
|
+
|
|
1200
|
+
# When filters is present but invalid - fails
|
|
1201
|
+
result = SearchWithFilters.call(filters: { min_price: -10 })
|
|
1202
|
+
result.errors # => [
|
|
1203
|
+
# { attribute: "filters.category", type: :blank, message: "Filters.category can't be blank" },
|
|
1204
|
+
# { attribute: "filters.min_price", type: :greater_than_or_equal_to, message: "..." }
|
|
1205
|
+
# ]
|
|
1206
|
+
|
|
1207
|
+
# Required hash validation (parameter must be present)
|
|
1063
1208
|
class CreateUserWithProfile
|
|
1064
1209
|
include Interactor
|
|
1065
1210
|
include Interactor::Validation
|
|
1066
1211
|
|
|
1067
1212
|
params :user
|
|
1068
1213
|
|
|
1069
|
-
validates :user do
|
|
1214
|
+
validates :user, presence: true do
|
|
1070
1215
|
attribute :name, presence: true
|
|
1071
1216
|
attribute :email, format: { with: /@/ }
|
|
1072
1217
|
attribute :age, numericality: { greater_than: 0 }
|
|
@@ -1078,7 +1223,14 @@ class CreateUserWithProfile
|
|
|
1078
1223
|
end
|
|
1079
1224
|
end
|
|
1080
1225
|
|
|
1081
|
-
#
|
|
1226
|
+
# When user is nil or empty - fails with presence error
|
|
1227
|
+
result = CreateUserWithProfile.call(user: nil)
|
|
1228
|
+
result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
|
|
1229
|
+
|
|
1230
|
+
result = CreateUserWithProfile.call(user: {})
|
|
1231
|
+
result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
|
|
1232
|
+
|
|
1233
|
+
# When user is present but invalid - validates nested attributes
|
|
1082
1234
|
result = CreateUserWithProfile.call(
|
|
1083
1235
|
user: {
|
|
1084
1236
|
name: "",
|
|
@@ -1094,7 +1246,7 @@ result.errors # => [
|
|
|
1094
1246
|
# { attribute: "user.bio", type: :too_long, message: "User.bio is too long (maximum is 500 characters)" }
|
|
1095
1247
|
# ]
|
|
1096
1248
|
|
|
1097
|
-
# Array validation
|
|
1249
|
+
# Array validation (optional)
|
|
1098
1250
|
class BulkCreateItems
|
|
1099
1251
|
include Interactor
|
|
1100
1252
|
include Interactor::Validation
|
|
@@ -1108,11 +1260,16 @@ class BulkCreateItems
|
|
|
1108
1260
|
end
|
|
1109
1261
|
|
|
1110
1262
|
def call
|
|
1263
|
+
return if items.nil? # Optional - handle nil gracefully
|
|
1111
1264
|
items.each { |item| Item.create!(item) }
|
|
1112
1265
|
end
|
|
1113
1266
|
end
|
|
1114
1267
|
|
|
1115
|
-
#
|
|
1268
|
+
# When items is nil - succeeds
|
|
1269
|
+
result = BulkCreateItems.call(items: nil)
|
|
1270
|
+
result.success? # => true
|
|
1271
|
+
|
|
1272
|
+
# When items is present - validates each item
|
|
1116
1273
|
result = BulkCreateItems.call(
|
|
1117
1274
|
items: [
|
|
1118
1275
|
{ name: "Widget", price: 10, quantity: 5 },
|
|
@@ -1124,6 +1281,30 @@ result.errors # => [
|
|
|
1124
1281
|
# { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" },
|
|
1125
1282
|
# { attribute: "items[1].quantity", type: :greater_than_or_equal_to, message: "Items[1].quantity must be greater than or equal to 1" }
|
|
1126
1283
|
# ]
|
|
1284
|
+
|
|
1285
|
+
# Required array validation
|
|
1286
|
+
class BulkCreateRequiredItems
|
|
1287
|
+
include Interactor
|
|
1288
|
+
include Interactor::Validation
|
|
1289
|
+
|
|
1290
|
+
params :items
|
|
1291
|
+
|
|
1292
|
+
validates :items, presence: true do
|
|
1293
|
+
attribute :name, presence: true
|
|
1294
|
+
attribute :price, numericality: { greater_than: 0 }
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
def call
|
|
1298
|
+
items.each { |item| Item.create!(item) }
|
|
1299
|
+
end
|
|
1300
|
+
end
|
|
1301
|
+
|
|
1302
|
+
# When items is nil or empty - fails with presence error
|
|
1303
|
+
result = BulkCreateRequiredItems.call(items: nil)
|
|
1304
|
+
result.errors # => [{ attribute: :items, type: :blank, message: "Items can't be blank" }]
|
|
1305
|
+
|
|
1306
|
+
result = BulkCreateRequiredItems.call(items: [])
|
|
1307
|
+
result.errors # => [{ attribute: :items, type: :blank, message: "Items can't be blank" }]
|
|
1127
1308
|
```
|
|
1128
1309
|
|
|
1129
1310
|
### ActiveModel Integration
|
|
@@ -5,7 +5,8 @@ module Interactor
|
|
|
5
5
|
# Configuration class for interactor validation behavior
|
|
6
6
|
class Configuration
|
|
7
7
|
attr_accessor :halt, :regex_timeout, :max_array_size,
|
|
8
|
-
:enable_instrumentation, :cache_regex_patterns
|
|
8
|
+
:enable_instrumentation, :cache_regex_patterns,
|
|
9
|
+
:skip_validate
|
|
9
10
|
attr_reader :error_mode
|
|
10
11
|
|
|
11
12
|
# Backward compatibility alias for halt_on_first_error
|
|
@@ -22,6 +23,7 @@ module Interactor
|
|
|
22
23
|
@max_array_size = 1000 # Maximum array size for nested validation (memory protection)
|
|
23
24
|
@enable_instrumentation = false # ActiveSupport::Notifications instrumentation
|
|
24
25
|
@cache_regex_patterns = true # Cache compiled regex patterns for performance
|
|
26
|
+
@skip_validate = true # Skip validate! hook if validate_params! has errors
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def error_mode=(mode)
|
|
@@ -46,8 +46,9 @@ module Interactor
|
|
|
46
46
|
nested_rules = build_nested_rules(&)
|
|
47
47
|
current_validations = _param_validations.dup
|
|
48
48
|
existing_rules = current_validations[param_name] || {}
|
|
49
|
+
# Merge both the validation rules (like presence: true) AND the nested rules
|
|
49
50
|
self._param_validations = current_validations.merge(
|
|
50
|
-
param_name => existing_rules.merge(_nested: nested_rules)
|
|
51
|
+
param_name => existing_rules.merge(rules).merge(_nested: nested_rules)
|
|
51
52
|
)
|
|
52
53
|
return
|
|
53
54
|
end
|
|
@@ -145,8 +146,11 @@ module Interactor
|
|
|
145
146
|
{ attribute: error.attribute, type: :invalid, options: {} }
|
|
146
147
|
end
|
|
147
148
|
|
|
148
|
-
# Skip custom validations if
|
|
149
|
-
|
|
149
|
+
# Skip custom validations if configured to skip when param errors exist
|
|
150
|
+
skip_custom_validation = current_config.skip_validate && existing_error_details.any?
|
|
151
|
+
|
|
152
|
+
# Skip custom validations if halt was requested or if configured to skip on param errors
|
|
153
|
+
unless @halt_validation || skip_custom_validation
|
|
150
154
|
# Call super to allow class's validate! to run and add custom errors
|
|
151
155
|
# Rescue exceptions that might be raised
|
|
152
156
|
begin
|
|
@@ -244,7 +248,14 @@ module Interactor
|
|
|
244
248
|
# Get the current configuration (instance config overrides global config)
|
|
245
249
|
# @return [Configuration] the active configuration
|
|
246
250
|
def current_config
|
|
247
|
-
@current_config
|
|
251
|
+
return @current_config if @current_config
|
|
252
|
+
|
|
253
|
+
# Try to get class-level config if available
|
|
254
|
+
if self.class.respond_to?(:validation_config)
|
|
255
|
+
self.class.validation_config || Interactor::Validation.configuration
|
|
256
|
+
else
|
|
257
|
+
Interactor::Validation.configuration
|
|
258
|
+
end
|
|
248
259
|
end
|
|
249
260
|
|
|
250
261
|
# Instrument a block of code if instrumentation is enabled
|
|
@@ -260,14 +271,23 @@ module Interactor
|
|
|
260
271
|
end
|
|
261
272
|
|
|
262
273
|
# Validates a single parameter with the given rules
|
|
263
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
274
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
264
275
|
def validate_param(param_name, value, rules)
|
|
265
276
|
# Skip validation if explicitly marked
|
|
266
277
|
return if rules[:_skip]
|
|
267
278
|
|
|
268
279
|
# Handle nested validation (hash or array)
|
|
269
280
|
if rules[:_nested]
|
|
270
|
-
|
|
281
|
+
# Run presence validation first if it exists
|
|
282
|
+
# This allows optional vs required nested validation
|
|
283
|
+
validate_presence(param_name, value, rules)
|
|
284
|
+
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
285
|
+
|
|
286
|
+
# Only run nested validation if value is present (not nil and not empty)
|
|
287
|
+
# If parent has no presence requirement, treat empty hash/array as "not provided"
|
|
288
|
+
# If parent has presence: true, empty hash/array already failed presence check above
|
|
289
|
+
should_validate_nested = value.present? || value == false
|
|
290
|
+
validate_nested(param_name, value, rules[:_nested]) if should_validate_nested
|
|
271
291
|
return
|
|
272
292
|
end
|
|
273
293
|
|
|
@@ -289,10 +309,13 @@ module Interactor
|
|
|
289
309
|
|
|
290
310
|
validate_numericality(param_name, value, rules)
|
|
291
311
|
end
|
|
292
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
312
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
293
313
|
|
|
294
314
|
# Validates nested attributes in a hash or array
|
|
295
315
|
def validate_nested(param_name, value, nested_rules)
|
|
316
|
+
# Return early if value is nil - presence validation handles this if required
|
|
317
|
+
return if value.nil?
|
|
318
|
+
|
|
296
319
|
if value.is_a?(Array)
|
|
297
320
|
validate_array_of_hashes(param_name, value, nested_rules)
|
|
298
321
|
elsif value.is_a?(Hash)
|
|
@@ -501,7 +524,7 @@ module Interactor
|
|
|
501
524
|
errors.add(attribute_path, error_type, halt: halt, **interpolations)
|
|
502
525
|
end
|
|
503
526
|
|
|
504
|
-
#
|
|
527
|
+
# NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
|
|
505
528
|
end
|
|
506
529
|
# rubocop:enable Metrics/ParameterLists
|
|
507
530
|
|
|
@@ -708,7 +731,7 @@ module Interactor
|
|
|
708
731
|
errors.add(param_name, error_type, halt: halt, **interpolations)
|
|
709
732
|
end
|
|
710
733
|
|
|
711
|
-
#
|
|
734
|
+
# NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
|
|
712
735
|
end
|
|
713
736
|
|
|
714
737
|
# Generate error code for :code mode using constants
|