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.
- checksums.yaml +4 -4
- data/README.md +218 -1355
- data/benchmark/validation_benchmark.rb +0 -3
- data/lib/interactor/validation/configuration.rb +4 -26
- data/lib/interactor/validation/core_ext.rb +120 -0
- data/lib/interactor/validation/errors.rb +49 -0
- data/lib/interactor/validation/params.rb +6 -16
- data/lib/interactor/validation/validates.rb +118 -816
- data/lib/interactor/validation/validators/array.rb +20 -0
- data/lib/interactor/validation/validators/boolean.rb +15 -0
- data/lib/interactor/validation/validators/format.rb +17 -0
- data/lib/interactor/validation/validators/hash.rb +43 -0
- data/lib/interactor/validation/validators/inclusion.rb +17 -0
- data/lib/interactor/validation/validators/length.rb +46 -0
- data/lib/interactor/validation/validators/numeric.rb +46 -0
- data/lib/interactor/validation/validators/presence.rb +16 -0
- data/lib/interactor/validation/version.rb +1 -1
- data/lib/interactor/validation.rb +13 -44
- data/smoke_test.rb +252 -0
- metadata +28 -44
- data/lib/interactor/validation/error_codes.rb +0 -51
|
@@ -1,888 +1,190 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "validators/presence"
|
|
4
|
+
require_relative "validators/numeric"
|
|
5
|
+
require_relative "validators/boolean"
|
|
6
|
+
require_relative "validators/format"
|
|
7
|
+
require_relative "validators/length"
|
|
8
|
+
require_relative "validators/inclusion"
|
|
9
|
+
require_relative "validators/hash"
|
|
10
|
+
require_relative "validators/array"
|
|
11
|
+
|
|
3
12
|
module Interactor
|
|
4
13
|
module Validation
|
|
5
|
-
# rubocop:disable Metrics/ModuleLength
|
|
6
14
|
module Validates
|
|
7
|
-
extend ActiveSupport::Concern
|
|
8
|
-
|
|
9
|
-
# Exception raised when validation should halt immediately
|
|
10
|
-
class HaltValidation < StandardError; end
|
|
11
|
-
|
|
12
|
-
included do
|
|
13
|
-
class_attribute :_param_validations, instance_writer: false, default: {}
|
|
14
|
-
# Regex pattern cache for performance
|
|
15
|
-
class_attribute :_regex_cache, instance_writer: false, default: {}
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Class-level mutex for thread-safe validation updates
|
|
19
|
-
@validations_mutex = Mutex.new
|
|
20
|
-
|
|
21
|
-
def self.validations_mutex
|
|
22
|
-
@validations_mutex ||= Mutex.new
|
|
23
|
-
end
|
|
24
|
-
|
|
25
15
|
def self.included(base)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
base.
|
|
29
|
-
base.
|
|
30
|
-
|
|
31
|
-
base.prepend(
|
|
16
|
+
base.extend(ClassMethods)
|
|
17
|
+
base.class_attribute :_validations
|
|
18
|
+
base._validations = {}
|
|
19
|
+
base.class_attribute :_validation_config
|
|
20
|
+
base._validation_config = {}
|
|
21
|
+
base.prepend(InstanceMethods)
|
|
32
22
|
end
|
|
33
23
|
|
|
34
|
-
module
|
|
35
|
-
# Override ActiveModel's validates to handle our simple validation rules
|
|
36
|
-
# Falls back to ActiveModel's validates for complex cases
|
|
37
|
-
# @param param_name [Symbol] the parameter name to validate
|
|
38
|
-
# @param rules [Hash] validation rules (presence, format, length, etc.)
|
|
39
|
-
# @yield [NestedValidationBuilder] optional block for nested validation DSL
|
|
40
|
-
# @return [void]
|
|
24
|
+
module ClassMethods
|
|
41
25
|
def validates(param_name, **rules, &)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
nested_rules = build_nested_rules(&)
|
|
47
|
-
current_validations = _param_validations.dup
|
|
48
|
-
existing_rules = current_validations[param_name] || {}
|
|
49
|
-
# Merge both the validation rules (like presence: true) AND the nested rules
|
|
50
|
-
self._param_validations = current_validations.merge(
|
|
51
|
-
param_name => existing_rules.merge(rules).merge(_nested: nested_rules)
|
|
52
|
-
)
|
|
53
|
-
return
|
|
54
|
-
end
|
|
26
|
+
_validations[param_name] ||= {}
|
|
27
|
+
_validations[param_name].merge!(rules)
|
|
28
|
+
_validations[param_name][:_nested] = build_nested_rules(&) if block_given?
|
|
29
|
+
end
|
|
55
30
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
existing_rules = current_validations[param_name] || {}
|
|
60
|
-
self._param_validations = current_validations.merge(
|
|
61
|
-
param_name => existing_rules.merge(_skip: true)
|
|
62
|
-
)
|
|
63
|
-
return
|
|
64
|
-
end
|
|
31
|
+
def validation_halt(value)
|
|
32
|
+
_validation_config[:halt] = value
|
|
33
|
+
end
|
|
65
34
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
existing_rules = current_validations[param_name] || {}
|
|
69
|
-
self._param_validations = current_validations.merge(param_name => existing_rules.merge(rules))
|
|
70
|
-
end
|
|
35
|
+
def validation_mode(value)
|
|
36
|
+
_validation_config[:mode] = value
|
|
71
37
|
end
|
|
72
38
|
|
|
73
39
|
private
|
|
74
40
|
|
|
75
|
-
# Build nested validation rules from a block
|
|
76
41
|
def build_nested_rules(&)
|
|
77
|
-
builder =
|
|
42
|
+
builder = NestedBuilder.new
|
|
78
43
|
builder.instance_eval(&)
|
|
79
44
|
builder.rules
|
|
80
45
|
end
|
|
81
46
|
end
|
|
82
47
|
|
|
83
|
-
|
|
84
|
-
class NestedValidationBuilder
|
|
48
|
+
class NestedBuilder
|
|
85
49
|
attr_reader :rules
|
|
86
50
|
|
|
87
51
|
def initialize
|
|
88
52
|
@rules = {}
|
|
89
53
|
end
|
|
90
54
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
@rules[attr_name] = validations
|
|
55
|
+
def attribute(name, **validations)
|
|
56
|
+
@rules[name] = validations
|
|
94
57
|
end
|
|
95
58
|
end
|
|
96
59
|
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
60
|
+
# Base module with default validate! that does nothing
|
|
61
|
+
module BaseValidation
|
|
62
|
+
def validate!
|
|
63
|
+
# Default implementation - does nothing
|
|
64
|
+
# Subclasses can override and call super
|
|
102
65
|
end
|
|
66
|
+
end
|
|
103
67
|
|
|
104
|
-
|
|
105
|
-
def
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# Call the original add method
|
|
110
|
-
__getobj__.add(attribute, message, **options)
|
|
111
|
-
|
|
112
|
-
# Set halt flag and raise exception if requested
|
|
113
|
-
return unless halt
|
|
68
|
+
module InstanceMethods
|
|
69
|
+
def self.prepended(base)
|
|
70
|
+
# Include BaseValidation so super works in user's validate!
|
|
71
|
+
base.include(BaseValidation) unless base.ancestors.include?(BaseValidation)
|
|
114
72
|
|
|
115
|
-
|
|
116
|
-
|
|
73
|
+
# Include all validator modules
|
|
74
|
+
base.include(Validators::Presence)
|
|
75
|
+
base.include(Validators::Numeric)
|
|
76
|
+
base.include(Validators::Boolean)
|
|
77
|
+
base.include(Validators::Format)
|
|
78
|
+
base.include(Validators::Length)
|
|
79
|
+
base.include(Validators::Inclusion)
|
|
80
|
+
base.include(Validators::Hash)
|
|
81
|
+
base.include(Validators::Array)
|
|
117
82
|
end
|
|
118
|
-
end
|
|
119
83
|
|
|
120
|
-
# Module prepended to override ActiveModel instance methods
|
|
121
|
-
module InstanceMethodsOverride
|
|
122
|
-
# Override errors to return a wrapper that intercepts halt: option
|
|
123
84
|
def errors
|
|
124
|
-
@errors ||=
|
|
85
|
+
@errors ||= Errors.new
|
|
125
86
|
end
|
|
126
87
|
|
|
127
|
-
# Override ActiveModel's validate! to prevent exception-raising behavior
|
|
128
|
-
# This hook is called automatically after validate_params!
|
|
129
|
-
# Users can override this to add custom validation logic
|
|
130
|
-
# @return [void]
|
|
131
|
-
# @example
|
|
132
|
-
# def validate!
|
|
133
|
-
# super # Optional: call parent implementation if needed
|
|
134
|
-
# errors.add(:base, "Custom error") if some_condition?
|
|
135
|
-
# end
|
|
136
|
-
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
137
88
|
def validate!
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
# Rescue exceptions that might be raised
|
|
153
|
-
begin
|
|
154
|
-
super
|
|
155
|
-
rescue NoMethodError
|
|
156
|
-
# No parent validate! method, which is fine
|
|
157
|
-
rescue ArgumentError => e
|
|
158
|
-
# For anonymous classes, super may fail when generating messages
|
|
159
|
-
raise unless e.message.include?("Class name cannot be blank")
|
|
160
|
-
rescue ActiveModel::ValidationError
|
|
161
|
-
# ActiveModel's validate! raises this when there are errors
|
|
162
|
-
# We handle errors differently, so just ignore this exception
|
|
163
|
-
rescue HaltValidation
|
|
164
|
-
# Validation halted - error already added, continue to fail context
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# Restore errors from validate_params! and add any new ones from custom validate!
|
|
169
|
-
existing_error_details.each do |error_detail|
|
|
170
|
-
# Only add if not already present (avoid duplicates)
|
|
171
|
-
next if errors.where(error_detail[:attribute], error_detail[:type]).any?
|
|
172
|
-
|
|
173
|
-
begin
|
|
174
|
-
errors.add(error_detail[:attribute], error_detail[:type], **error_detail[:options])
|
|
175
|
-
rescue ArgumentError => e
|
|
176
|
-
# For anonymous classes, fall back to adding with message directly
|
|
177
|
-
raise unless e.message.include?("Class name cannot be blank")
|
|
178
|
-
|
|
179
|
-
message = error_detail[:options][:message] || error_detail[:type].to_s
|
|
180
|
-
errors.add(error_detail[:attribute], message)
|
|
89
|
+
errors.clear
|
|
90
|
+
param_errors = false
|
|
91
|
+
|
|
92
|
+
# Run parameter validations
|
|
93
|
+
if self.class._validations
|
|
94
|
+
self.class._validations.each do |param, rules|
|
|
95
|
+
value = context.respond_to?(param) ? context.public_send(param) : nil
|
|
96
|
+
validate_param(param, value, rules)
|
|
97
|
+
|
|
98
|
+
# Halt on first error if configured
|
|
99
|
+
if validation_config(:halt) && errors.any?
|
|
100
|
+
context.fail!(errors: format_errors)
|
|
101
|
+
break
|
|
102
|
+
end
|
|
181
103
|
end
|
|
104
|
+
param_errors = errors.any?
|
|
182
105
|
end
|
|
183
106
|
|
|
184
|
-
#
|
|
185
|
-
|
|
107
|
+
# Call super to allow user-defined validate! to run
|
|
108
|
+
# Skip if param validations failed and skip_validate is true
|
|
109
|
+
super unless param_errors && validation_config(:skip_validate)
|
|
186
110
|
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
return if errors.empty?
|
|
190
|
-
|
|
191
|
-
# Use the existing formatted_errors method which handles all the edge cases
|
|
192
|
-
context.fail!(errors: formatted_errors)
|
|
111
|
+
# Fail context if any errors exist
|
|
112
|
+
context.fail!(errors: format_errors) if errors.any?
|
|
193
113
|
end
|
|
194
|
-
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
private
|
|
198
|
-
|
|
199
|
-
# Validates all declared parameters before execution
|
|
200
|
-
# Accumulates errors but does not fail the context
|
|
201
|
-
# The validate! hook will fail the context if there are any errors
|
|
202
|
-
# @return [void]
|
|
203
|
-
# rubocop:disable Metrics/PerceivedComplexity
|
|
204
|
-
def validate_params!
|
|
205
|
-
# Memoize config for performance
|
|
206
|
-
@current_config = current_config
|
|
207
|
-
# Initialize halt flag (but don't reset if already set)
|
|
208
|
-
@halt_validation ||= false
|
|
209
|
-
|
|
210
|
-
# Instrument validation if enabled
|
|
211
|
-
instrument("validate_params.interactor_validation") do
|
|
212
|
-
# Trigger ActiveModel validations first (validate callbacks)
|
|
213
|
-
# This runs any custom validations defined with validate :method_name
|
|
214
|
-
begin
|
|
215
|
-
valid?
|
|
216
|
-
rescue ArgumentError => e
|
|
217
|
-
# For anonymous classes, valid? may fail when generating messages
|
|
218
|
-
raise unless e.message.include?("Class name cannot be blank")
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Check if halt was requested during ActiveModel validations
|
|
222
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
223
|
-
|
|
224
|
-
# Run our custom param validations after ActiveModel validations
|
|
225
|
-
self.class._param_validations.each do |param_name, rules|
|
|
226
|
-
# Safe param access - returns nil if not present in context
|
|
227
|
-
value = context.respond_to?(param_name) ? context.public_send(param_name) : nil
|
|
228
|
-
validate_param(param_name, value, rules)
|
|
229
|
-
|
|
230
|
-
# Halt on first error if configured or if halt was explicitly requested
|
|
231
|
-
break if @halt_validation || (@current_config.halt && errors.any?)
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
# Don't fail here - let validate! hook handle failure
|
|
235
|
-
# This allows validate! to run and add additional custom errors
|
|
236
|
-
end
|
|
237
|
-
rescue HaltValidation
|
|
238
|
-
# Validation halted - error already added, stop processing
|
|
239
|
-
ensure
|
|
240
|
-
@current_config = nil # Clear memoization
|
|
241
|
-
# Don't reset @halt_validation here - let validate! handle it
|
|
242
|
-
end
|
|
243
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
244
|
-
|
|
245
|
-
# Get the current configuration (instance config overrides global config)
|
|
246
|
-
# @return [Configuration] the active configuration
|
|
247
|
-
def current_config
|
|
248
|
-
@current_config || self.class.validation_config || Interactor::Validation.configuration
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
# Instrument a block of code if instrumentation is enabled
|
|
252
|
-
# @param event_name [String] the event name for ActiveSupport::Notifications
|
|
253
|
-
# @yield the block to instrument
|
|
254
|
-
# @return [Object] the return value of the block
|
|
255
|
-
def instrument(event_name, &)
|
|
256
|
-
if current_config.enable_instrumentation
|
|
257
|
-
ActiveSupport::Notifications.instrument(event_name, interactor: self.class.name, &)
|
|
258
|
-
else
|
|
259
|
-
yield
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Validates a single parameter with the given rules
|
|
264
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
265
|
-
def validate_param(param_name, value, rules)
|
|
266
|
-
# Skip validation if explicitly marked
|
|
267
|
-
return if rules[:_skip]
|
|
268
|
-
|
|
269
|
-
# Handle nested validation (hash or array)
|
|
270
|
-
if rules[:_nested]
|
|
271
|
-
# Run presence validation first if it exists
|
|
272
|
-
# This allows optional vs required nested validation
|
|
273
|
-
validate_presence(param_name, value, rules)
|
|
274
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
275
114
|
|
|
276
|
-
|
|
277
|
-
# If parent has no presence requirement, treat empty hash/array as "not provided"
|
|
278
|
-
# If parent has presence: true, empty hash/array already failed presence check above
|
|
279
|
-
should_validate_nested = value.present? || value == false
|
|
280
|
-
validate_nested(param_name, value, rules[:_nested]) if should_validate_nested
|
|
281
|
-
return
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Standard validations - halt if requested or configured after each validation
|
|
285
|
-
validate_presence(param_name, value, rules)
|
|
286
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
287
|
-
|
|
288
|
-
validate_boolean(param_name, value, rules)
|
|
289
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
290
|
-
|
|
291
|
-
validate_format(param_name, value, rules)
|
|
292
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
293
|
-
|
|
294
|
-
validate_length(param_name, value, rules)
|
|
295
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
296
|
-
|
|
297
|
-
validate_inclusion(param_name, value, rules)
|
|
298
|
-
return if @halt_validation || (@current_config.halt && errors.any?)
|
|
299
|
-
|
|
300
|
-
validate_numericality(param_name, value, rules)
|
|
301
|
-
end
|
|
302
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
303
|
-
|
|
304
|
-
# Validates nested attributes in a hash or array
|
|
305
|
-
def validate_nested(param_name, value, nested_rules)
|
|
306
|
-
# Return early if value is nil - presence validation handles this if required
|
|
307
|
-
return if value.nil?
|
|
308
|
-
|
|
309
|
-
if value.is_a?(Array)
|
|
310
|
-
validate_array_of_hashes(param_name, value, nested_rules)
|
|
311
|
-
elsif value.is_a?(Hash)
|
|
312
|
-
validate_hash_attributes(param_name, value, nested_rules)
|
|
313
|
-
else
|
|
314
|
-
# If value is not hash or array, add type error
|
|
315
|
-
add_nested_error(param_name, nil, nil, :invalid_type)
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
# Validates each hash in an array
|
|
320
|
-
# @param param_name [Symbol] the parameter name
|
|
321
|
-
# @param array [Array] the array of hashes to validate
|
|
322
|
-
# @param nested_rules [Hash] validation rules for nested attributes
|
|
323
|
-
# @return [void]
|
|
324
|
-
def validate_array_of_hashes(param_name, array, nested_rules)
|
|
325
|
-
# Memory protection: limit array size
|
|
326
|
-
if array.size > current_config.max_array_size
|
|
327
|
-
add_error(param_name, ErrorCodes::ARRAY_TOO_LARGE, :too_large,
|
|
328
|
-
count: current_config.max_array_size)
|
|
329
|
-
return
|
|
330
|
-
end
|
|
115
|
+
private
|
|
331
116
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
117
|
+
def validate_param(param, value, rules)
|
|
118
|
+
# Handle nested validation
|
|
119
|
+
if rules[:_nested]
|
|
120
|
+
validate_presence(param, value, rules[:presence]) if rules[:presence]
|
|
121
|
+
validate_nested(param, value, rules[:_nested]) if value.present?
|
|
122
|
+
return
|
|
337
123
|
end
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
124
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
# @param nested_rules [Hash] validation rules for each attribute
|
|
345
|
-
# @param index [Integer, nil] optional array index for error messages
|
|
346
|
-
# @return [void]
|
|
347
|
-
def validate_hash_attributes(param_name, hash, nested_rules, index: nil)
|
|
348
|
-
nested_rules.each do |attr_name, attr_rules|
|
|
349
|
-
# Check both symbol and string keys, handling nil/false values and missing keys properly
|
|
350
|
-
attr_value = get_nested_value(hash, attr_name)
|
|
351
|
-
validate_nested_attribute(param_name, attr_name, attr_value, attr_rules, index: index)
|
|
352
|
-
end
|
|
353
|
-
end
|
|
125
|
+
# Standard validations
|
|
126
|
+
validate_presence(param, value, rules[:presence]) if rules[:presence]
|
|
127
|
+
return unless value.present?
|
|
354
128
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if hash.key?(attr_name)
|
|
361
|
-
hash[attr_name]
|
|
362
|
-
elsif hash.key?(attr_name.to_s)
|
|
363
|
-
hash[attr_name.to_s]
|
|
364
|
-
else
|
|
365
|
-
:__missing__ # Sentinel value to distinguish from nil
|
|
129
|
+
validate_boolean(param, value) if rules[:boolean]
|
|
130
|
+
validate_format(param, value, rules[:format]) if rules[:format]
|
|
131
|
+
validate_length(param, value, rules[:length]) if rules[:length]
|
|
132
|
+
validate_inclusion(param, value, rules[:inclusion]) if rules[:inclusion]
|
|
133
|
+
validate_numeric(param, value, rules[:numeric] || rules[:numericality]) if rules[:numeric] || rules[:numericality]
|
|
366
134
|
end
|
|
367
|
-
end
|
|
368
135
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
value = nil if is_missing
|
|
375
|
-
|
|
376
|
-
# Validate presence (false is a valid present value for booleans)
|
|
377
|
-
if rules[:presence] && !value.present? && value != false
|
|
378
|
-
message = extract_message(rules[:presence], :blank)
|
|
379
|
-
add_nested_error(param_name, attr_name, message, :blank, index: index)
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
# Validate boolean (works on all values, not just present ones)
|
|
383
|
-
# Don't validate boolean for missing keys (sentinel value)
|
|
384
|
-
if rules[:boolean] && !is_missing && !boolean?(value)
|
|
385
|
-
message = extract_message(rules[:boolean], :not_boolean)
|
|
386
|
-
add_nested_error(param_name, attr_name, message, :not_boolean, index: index)
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
# Only run other validations if value is present (false is considered present for booleans)
|
|
390
|
-
return unless value.present? || value == false
|
|
391
|
-
|
|
392
|
-
# Validate format (with ReDoS protection)
|
|
393
|
-
if rules[:format]
|
|
394
|
-
format_options = rules[:format]
|
|
395
|
-
pattern = format_options.is_a?(Hash) ? format_options[:with] : format_options
|
|
396
|
-
|
|
397
|
-
# Safe regex matching with timeout protection
|
|
398
|
-
unless safe_regex_match?(value.to_s, pattern)
|
|
399
|
-
message = extract_message(format_options, :invalid)
|
|
400
|
-
add_nested_error(param_name, attr_name, message, :invalid, index: index)
|
|
136
|
+
def validate_nested(param, value, nested_rules)
|
|
137
|
+
if value.is_a?(::Array)
|
|
138
|
+
validate_array(param, value, nested_rules)
|
|
139
|
+
elsif value.is_a?(::Hash)
|
|
140
|
+
validate_hash(param, value, nested_rules)
|
|
401
141
|
end
|
|
402
142
|
end
|
|
403
143
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
# Validate inclusion
|
|
408
|
-
if rules[:inclusion]
|
|
409
|
-
inclusion_options = rules[:inclusion]
|
|
410
|
-
allowed_values = inclusion_options.is_a?(Hash) ? inclusion_options[:in] : inclusion_options
|
|
411
|
-
unless allowed_values.include?(value)
|
|
412
|
-
message = extract_message(inclusion_options, :inclusion)
|
|
413
|
-
add_nested_error(param_name, attr_name, message, :inclusion, index: index)
|
|
414
|
-
end
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
# Validate numericality
|
|
418
|
-
return unless rules[:numericality]
|
|
419
|
-
|
|
420
|
-
validate_nested_numericality(param_name, attr_name, value, rules[:numericality], index: index)
|
|
421
|
-
end
|
|
422
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
423
|
-
|
|
424
|
-
# Validates length for nested attributes
|
|
425
|
-
def validate_nested_length(param_name, attr_name, value, length_rules, index: nil)
|
|
426
|
-
length = value.to_s.length
|
|
427
|
-
|
|
428
|
-
if length_rules[:maximum] && length > length_rules[:maximum]
|
|
429
|
-
message = extract_message(length_rules, :too_long, count: length_rules[:maximum])
|
|
430
|
-
add_nested_error(param_name, attr_name, message, :too_long,
|
|
431
|
-
count: length_rules[:maximum], index: index)
|
|
432
|
-
end
|
|
433
|
-
|
|
434
|
-
if length_rules[:minimum] && length < length_rules[:minimum]
|
|
435
|
-
message = extract_message(length_rules, :too_short, count: length_rules[:minimum])
|
|
436
|
-
add_nested_error(param_name, attr_name, message, :too_short,
|
|
437
|
-
count: length_rules[:minimum], index: index)
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
return unless length_rules[:is] && length != length_rules[:is]
|
|
441
|
-
|
|
442
|
-
message = extract_message(length_rules, :wrong_length, count: length_rules[:is])
|
|
443
|
-
add_nested_error(param_name, attr_name, message, :wrong_length,
|
|
444
|
-
count: length_rules[:is], index: index)
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
# Validates numericality for nested attributes
|
|
448
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
449
|
-
def validate_nested_numericality(param_name, attr_name, value, numeric_rules, index: nil)
|
|
450
|
-
numeric_rules = {} unless numeric_rules.is_a?(Hash)
|
|
451
|
-
|
|
452
|
-
unless numeric?(value)
|
|
453
|
-
message = extract_message(numeric_rules, :not_a_number)
|
|
454
|
-
add_nested_error(param_name, attr_name, message, :not_a_number, index: index)
|
|
455
|
-
return
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
numeric_value = coerce_to_numeric(value)
|
|
459
|
-
|
|
460
|
-
if numeric_rules[:greater_than] && numeric_value <= numeric_rules[:greater_than]
|
|
461
|
-
message = extract_message(numeric_rules, :greater_than, count: numeric_rules[:greater_than])
|
|
462
|
-
add_nested_error(param_name, attr_name, message, :greater_than,
|
|
463
|
-
count: numeric_rules[:greater_than], index: index)
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
if numeric_rules[:greater_than_or_equal_to] && numeric_value < numeric_rules[:greater_than_or_equal_to]
|
|
467
|
-
message = extract_message(numeric_rules, :greater_than_or_equal_to,
|
|
468
|
-
count: numeric_rules[:greater_than_or_equal_to])
|
|
469
|
-
add_nested_error(param_name, attr_name, message, :greater_than_or_equal_to,
|
|
470
|
-
count: numeric_rules[:greater_than_or_equal_to], index: index)
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
if numeric_rules[:less_than] && numeric_value >= numeric_rules[:less_than]
|
|
474
|
-
message = extract_message(numeric_rules, :less_than, count: numeric_rules[:less_than])
|
|
475
|
-
add_nested_error(param_name, attr_name, message, :less_than,
|
|
476
|
-
count: numeric_rules[:less_than], index: index)
|
|
144
|
+
def validation_config(key)
|
|
145
|
+
# Check per-interactor config first, then fall back to global config
|
|
146
|
+
self.class._validation_config.key?(key) ? self.class._validation_config[key] : Interactor::Validation.configuration.public_send(key)
|
|
477
147
|
end
|
|
478
148
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return unless numeric_rules[:equal_to] && numeric_value != numeric_rules[:equal_to]
|
|
487
|
-
|
|
488
|
-
message = extract_message(numeric_rules, :equal_to, count: numeric_rules[:equal_to])
|
|
489
|
-
add_nested_error(param_name, attr_name, message, :equal_to,
|
|
490
|
-
count: numeric_rules[:equal_to], index: index)
|
|
491
|
-
end
|
|
492
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
493
|
-
|
|
494
|
-
# Add error for nested validation
|
|
495
|
-
# rubocop:disable Metrics/ParameterLists
|
|
496
|
-
def add_nested_error(param_name, attr_name, custom_message, error_type, index: nil, halt: false, **interpolations)
|
|
497
|
-
# Build the attribute path for the error
|
|
498
|
-
attribute_path = if index.nil?
|
|
499
|
-
# Hash validation: param_name.attr_name
|
|
500
|
-
attr_name ? :"#{param_name}.#{attr_name}" : param_name
|
|
501
|
-
else
|
|
502
|
-
# Array validation: param_name[index].attr_name
|
|
503
|
-
attr_name ? :"#{param_name}[#{index}].#{attr_name}" : :"#{param_name}[#{index}]"
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
if current_config.error_mode == :code
|
|
507
|
-
# Code mode: use custom message or generate code
|
|
508
|
-
code_message = custom_message || error_code_for(error_type, **interpolations)
|
|
509
|
-
errors.add(attribute_path, code_message, halt: halt)
|
|
510
|
-
elsif custom_message
|
|
511
|
-
# Default mode: use ActiveModel's error messages with custom message
|
|
512
|
-
errors.add(attribute_path, custom_message, halt: halt)
|
|
513
|
-
else
|
|
514
|
-
errors.add(attribute_path, error_type, halt: halt, **interpolations)
|
|
515
|
-
end
|
|
516
|
-
|
|
517
|
-
# NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
|
|
518
|
-
end
|
|
519
|
-
# rubocop:enable Metrics/ParameterLists
|
|
520
|
-
|
|
521
|
-
def validate_presence(param_name, value, rules)
|
|
522
|
-
return unless rules[:presence]
|
|
523
|
-
# For booleans, false is a valid present value
|
|
524
|
-
return if value.present? || value == false
|
|
525
|
-
|
|
526
|
-
message = extract_message(rules[:presence], :blank)
|
|
527
|
-
add_error(param_name, message, :blank)
|
|
528
|
-
end
|
|
529
|
-
|
|
530
|
-
def validate_boolean(param_name, value, rules)
|
|
531
|
-
return unless rules[:boolean]
|
|
532
|
-
return if boolean?(value)
|
|
533
|
-
|
|
534
|
-
message = extract_message(rules[:boolean], :not_boolean)
|
|
535
|
-
add_error(param_name, message, :not_boolean)
|
|
536
|
-
end
|
|
537
|
-
|
|
538
|
-
def boolean?(value)
|
|
539
|
-
[true, false].include?(value)
|
|
540
|
-
end
|
|
541
|
-
|
|
542
|
-
# Validates format using regex patterns with ReDoS protection
|
|
543
|
-
# @param param_name [Symbol] the parameter name
|
|
544
|
-
# @param value [Object] the value to validate
|
|
545
|
-
# @param rules [Hash] validation rules containing :format
|
|
546
|
-
# @return [void]
|
|
547
|
-
def validate_format(param_name, value, rules)
|
|
548
|
-
return unless rules[:format] && value.present?
|
|
549
|
-
|
|
550
|
-
format_options = rules[:format]
|
|
551
|
-
pattern = format_options.is_a?(Hash) ? format_options[:with] : format_options
|
|
552
|
-
|
|
553
|
-
# Safe regex matching with timeout and caching
|
|
554
|
-
return if safe_regex_match?(value.to_s, pattern)
|
|
555
|
-
|
|
556
|
-
message = extract_message(format_options, :invalid)
|
|
557
|
-
add_error(param_name, message, :invalid)
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
# Safely match a value against a regex pattern with timeout protection
|
|
561
|
-
# @param value [String] the string to match
|
|
562
|
-
# @param pattern [Regexp] the regex pattern
|
|
563
|
-
# @return [Boolean] true if matches, false if no match or timeout
|
|
564
|
-
def safe_regex_match?(value, pattern)
|
|
565
|
-
# Get cached pattern if caching is enabled
|
|
566
|
-
cached_pattern = current_config.cache_regex_patterns ? get_cached_regex(pattern) : pattern
|
|
567
|
-
|
|
568
|
-
# Use Regexp.timeout if available (Ruby 3.2+)
|
|
569
|
-
if Regexp.respond_to?(:timeout)
|
|
570
|
-
begin
|
|
571
|
-
Regexp.timeout = current_config.regex_timeout
|
|
572
|
-
value.match?(cached_pattern)
|
|
573
|
-
rescue Regexp::TimeoutError
|
|
574
|
-
# Log timeout and treat as validation failure
|
|
575
|
-
add_error(:regex, ErrorCodes::REGEX_TIMEOUT, :timeout) if errors.respond_to?(:add)
|
|
576
|
-
false
|
|
577
|
-
ensure
|
|
578
|
-
Regexp.timeout = nil
|
|
579
|
-
end
|
|
580
|
-
else
|
|
581
|
-
# Fallback for older Ruby versions - use Timeout module
|
|
582
|
-
require "timeout"
|
|
583
|
-
begin
|
|
584
|
-
Timeout.timeout(current_config.regex_timeout) do
|
|
585
|
-
value.match?(cached_pattern)
|
|
586
|
-
end
|
|
587
|
-
rescue Timeout::Error
|
|
588
|
-
add_error(:regex, ErrorCodes::REGEX_TIMEOUT, :timeout) if errors.respond_to?(:add)
|
|
589
|
-
false
|
|
149
|
+
def format_errors
|
|
150
|
+
case validation_config(:mode)
|
|
151
|
+
when :code
|
|
152
|
+
format_errors_as_code
|
|
153
|
+
else
|
|
154
|
+
format_errors_as_default
|
|
590
155
|
end
|
|
591
156
|
end
|
|
592
|
-
end
|
|
593
|
-
|
|
594
|
-
# Get or cache a compiled regex pattern
|
|
595
|
-
# @param pattern [Regexp] the pattern to cache
|
|
596
|
-
# @return [Regexp] the cached pattern
|
|
597
|
-
def get_cached_regex(pattern)
|
|
598
|
-
cache_key = pattern.source
|
|
599
|
-
self.class._regex_cache[cache_key] ||= pattern
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
def validate_length(param_name, value, rules)
|
|
603
|
-
return unless rules[:length] && value.present?
|
|
604
|
-
|
|
605
|
-
length_rules = rules[:length]
|
|
606
|
-
length = value.to_s.length
|
|
607
|
-
|
|
608
|
-
if length_rules[:maximum] && length > length_rules[:maximum]
|
|
609
|
-
message = extract_message(length_rules, :too_long, count: length_rules[:maximum])
|
|
610
|
-
add_error(param_name, message, :too_long, count: length_rules[:maximum])
|
|
611
|
-
end
|
|
612
|
-
|
|
613
|
-
if length_rules[:minimum] && length < length_rules[:minimum]
|
|
614
|
-
message = extract_message(length_rules, :too_short, count: length_rules[:minimum])
|
|
615
|
-
add_error(param_name, message, :too_short, count: length_rules[:minimum])
|
|
616
|
-
end
|
|
617
|
-
|
|
618
|
-
return unless length_rules[:is] && length != length_rules[:is]
|
|
619
|
-
|
|
620
|
-
message = extract_message(length_rules, :wrong_length, count: length_rules[:is])
|
|
621
|
-
add_error(param_name, message, :wrong_length, count: length_rules[:is])
|
|
622
|
-
end
|
|
623
|
-
|
|
624
|
-
def validate_inclusion(param_name, value, rules)
|
|
625
|
-
return unless rules[:inclusion] && value.present?
|
|
626
157
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
return if allowed_values.include?(value)
|
|
630
|
-
|
|
631
|
-
message = extract_message(inclusion_options, :inclusion)
|
|
632
|
-
add_error(param_name, message, :inclusion)
|
|
633
|
-
end
|
|
634
|
-
|
|
635
|
-
def validate_numericality(param_name, value, rules)
|
|
636
|
-
return unless rules[:numericality] && value.present?
|
|
637
|
-
|
|
638
|
-
numeric_rules = rules[:numericality].is_a?(Hash) ? rules[:numericality] : {}
|
|
639
|
-
|
|
640
|
-
unless numeric?(value)
|
|
641
|
-
message = extract_message(numeric_rules, :not_a_number)
|
|
642
|
-
add_error(param_name, message, :not_a_number)
|
|
643
|
-
return
|
|
644
|
-
end
|
|
645
|
-
|
|
646
|
-
numeric_value = coerce_to_numeric(value)
|
|
647
|
-
validate_numeric_constraints(param_name, numeric_value, numeric_rules)
|
|
648
|
-
end
|
|
649
|
-
|
|
650
|
-
# Check if a value is numeric or can be coerced to numeric
|
|
651
|
-
# @param value [Object] the value to check
|
|
652
|
-
# @return [Boolean] true if numeric or numeric string
|
|
653
|
-
def numeric?(value)
|
|
654
|
-
value.is_a?(Numeric) || value.to_s.match?(/\A-?\d+(\.\d+)?\z/)
|
|
655
|
-
end
|
|
656
|
-
|
|
657
|
-
# Coerce a value to numeric, preserving integer precision
|
|
658
|
-
# @param value [Object] the value to coerce
|
|
659
|
-
# @return [Numeric] integer or float depending on input
|
|
660
|
-
def coerce_to_numeric(value)
|
|
661
|
-
return value if value.is_a?(Numeric)
|
|
662
|
-
|
|
663
|
-
str = value.to_s
|
|
664
|
-
# Use to_i for integers to preserve precision, to_f for floats
|
|
665
|
-
str.include?(".") ? str.to_f : str.to_i
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
def validate_numeric_constraints(param_name, value, rules)
|
|
669
|
-
if rules[:greater_than] && value <= rules[:greater_than]
|
|
670
|
-
message = extract_message(rules, :greater_than, count: rules[:greater_than])
|
|
671
|
-
add_error(param_name, message, :greater_than, count: rules[:greater_than])
|
|
672
|
-
end
|
|
673
|
-
|
|
674
|
-
if rules[:greater_than_or_equal_to] && value < rules[:greater_than_or_equal_to]
|
|
675
|
-
message = extract_message(rules, :greater_than_or_equal_to, count: rules[:greater_than_or_equal_to])
|
|
676
|
-
add_error(param_name, message, :greater_than_or_equal_to, count: rules[:greater_than_or_equal_to])
|
|
677
|
-
end
|
|
678
|
-
|
|
679
|
-
if rules[:less_than] && value >= rules[:less_than]
|
|
680
|
-
message = extract_message(rules, :less_than, count: rules[:less_than])
|
|
681
|
-
add_error(param_name, message, :less_than, count: rules[:less_than])
|
|
682
|
-
end
|
|
683
|
-
|
|
684
|
-
if rules[:less_than_or_equal_to] && value > rules[:less_than_or_equal_to]
|
|
685
|
-
message = extract_message(rules, :less_than_or_equal_to, count: rules[:less_than_or_equal_to])
|
|
686
|
-
add_error(param_name, message, :less_than_or_equal_to, count: rules[:less_than_or_equal_to])
|
|
687
|
-
end
|
|
688
|
-
|
|
689
|
-
return unless rules[:equal_to] && value != rules[:equal_to]
|
|
690
|
-
|
|
691
|
-
message = extract_message(rules, :equal_to, count: rules[:equal_to])
|
|
692
|
-
add_error(param_name, message, :equal_to, count: rules[:equal_to])
|
|
693
|
-
end
|
|
694
|
-
|
|
695
|
-
# Extract custom message from validation options
|
|
696
|
-
# @param options [Hash, Boolean] validation options
|
|
697
|
-
# @param error_type [Symbol] the type of error
|
|
698
|
-
# @param interpolations [Hash] values to interpolate into message
|
|
699
|
-
# @return [String, nil] custom message if provided
|
|
700
|
-
def extract_message(options, _error_type, **_interpolations)
|
|
701
|
-
return nil unless options.is_a?(Hash)
|
|
702
|
-
|
|
703
|
-
options[:message]
|
|
704
|
-
end
|
|
705
|
-
|
|
706
|
-
# Add an error with proper formatting based on error mode
|
|
707
|
-
# @param param_name [Symbol] the parameter name
|
|
708
|
-
# @param custom_message [String, nil] custom error message if provided
|
|
709
|
-
# @param error_type [Symbol] the type of validation error
|
|
710
|
-
# @param halt [Boolean] if true, halts validation immediately after adding this error
|
|
711
|
-
# @param interpolations [Hash] values to interpolate into the message
|
|
712
|
-
def add_error(param_name, custom_message, error_type, halt: false, **interpolations)
|
|
713
|
-
if current_config.error_mode == :code
|
|
714
|
-
# Code mode: use custom message or generate code
|
|
715
|
-
code_message = custom_message || error_code_for(error_type, **interpolations)
|
|
716
|
-
errors.add(param_name, code_message, halt: halt)
|
|
717
|
-
elsif custom_message
|
|
718
|
-
# Default mode: use ActiveModel's error messages
|
|
719
|
-
errors.add(param_name, custom_message, halt: halt)
|
|
720
|
-
else
|
|
721
|
-
errors.add(param_name, error_type, halt: halt, **interpolations)
|
|
722
|
-
end
|
|
723
|
-
|
|
724
|
-
# NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
|
|
725
|
-
end
|
|
726
|
-
|
|
727
|
-
# Generate error code for :code mode using constants
|
|
728
|
-
# @param error_type [Symbol] the type of validation error
|
|
729
|
-
# @param interpolations [Hash] values to interpolate into the code
|
|
730
|
-
# @return [String] the error code
|
|
731
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
732
|
-
def error_code_for(error_type, **interpolations)
|
|
733
|
-
case error_type
|
|
734
|
-
when :blank
|
|
735
|
-
ErrorCodes::REQUIRED
|
|
736
|
-
when :not_boolean
|
|
737
|
-
ErrorCodes::MUST_BE_BOOLEAN
|
|
738
|
-
when :invalid
|
|
739
|
-
ErrorCodes::INVALID_FORMAT
|
|
740
|
-
when :too_long
|
|
741
|
-
ErrorCodes.exceeds_max_length(interpolations[:count])
|
|
742
|
-
when :too_short
|
|
743
|
-
ErrorCodes.below_min_length(interpolations[:count])
|
|
744
|
-
when :wrong_length
|
|
745
|
-
ErrorCodes.must_be_length(interpolations[:count])
|
|
746
|
-
when :inclusion
|
|
747
|
-
ErrorCodes::NOT_IN_ALLOWED_VALUES
|
|
748
|
-
when :not_a_number
|
|
749
|
-
ErrorCodes::MUST_BE_A_NUMBER
|
|
750
|
-
when :greater_than
|
|
751
|
-
ErrorCodes.must_be_greater_than(interpolations[:count])
|
|
752
|
-
when :greater_than_or_equal_to
|
|
753
|
-
ErrorCodes.must_be_at_least(interpolations[:count])
|
|
754
|
-
when :less_than
|
|
755
|
-
ErrorCodes.must_be_less_than(interpolations[:count])
|
|
756
|
-
when :less_than_or_equal_to
|
|
757
|
-
ErrorCodes.must_be_at_most(interpolations[:count])
|
|
758
|
-
when :equal_to
|
|
759
|
-
ErrorCodes.must_be_equal_to(interpolations[:count])
|
|
760
|
-
when :invalid_type
|
|
761
|
-
ErrorCodes::INVALID_TYPE
|
|
762
|
-
when :too_large
|
|
763
|
-
ErrorCodes::ARRAY_TOO_LARGE
|
|
764
|
-
when :timeout
|
|
765
|
-
ErrorCodes::REGEX_TIMEOUT
|
|
766
|
-
else
|
|
767
|
-
error_type.to_s.upcase
|
|
768
|
-
end
|
|
769
|
-
end
|
|
770
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
771
|
-
|
|
772
|
-
# Formats errors into the expected structure
|
|
773
|
-
def formatted_errors
|
|
774
|
-
if current_config.error_mode == :code
|
|
775
|
-
# Code mode: return structured error codes
|
|
776
|
-
errors.map do |error|
|
|
777
|
-
# Convert attribute path to uppercase, handling nested paths
|
|
778
|
-
# Example: "attributes.username" -> "ATTRIBUTES.USERNAME"
|
|
779
|
-
# Example: "attributes[0].username" -> "ATTRIBUTES[0]_USERNAME"
|
|
780
|
-
param_name = format_attribute_for_code(error.attribute)
|
|
781
|
-
begin
|
|
782
|
-
message = error.message
|
|
783
|
-
rescue ArgumentError, NoMethodError
|
|
784
|
-
# For anonymous classes or other edge cases, fall back to type
|
|
785
|
-
message = error.type.to_s.upcase
|
|
786
|
-
end
|
|
787
|
-
|
|
788
|
-
{ code: "#{param_name}_#{message}" }
|
|
789
|
-
end
|
|
790
|
-
else
|
|
791
|
-
# Default mode: return ActiveModel-style errors
|
|
792
|
-
errors.map do |error|
|
|
793
|
-
# Build a human-readable message manually to avoid anonymous class issues
|
|
794
|
-
message = build_error_message(error)
|
|
158
|
+
def format_errors_as_default
|
|
159
|
+
errors.map do |err|
|
|
795
160
|
{
|
|
796
|
-
attribute:
|
|
797
|
-
type:
|
|
798
|
-
message: message
|
|
161
|
+
attribute: err.attribute,
|
|
162
|
+
type: err.type,
|
|
163
|
+
message: "#{err.attribute.to_s.humanize} #{err.message}"
|
|
799
164
|
}
|
|
800
165
|
end
|
|
801
166
|
end
|
|
802
|
-
end
|
|
803
167
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
def format_attribute_for_code(attribute)
|
|
808
|
-
# Convert to string and uppercase
|
|
809
|
-
attr_str = attribute.to_s.upcase
|
|
810
|
-
# Replace dots with underscores, but preserve array indices
|
|
811
|
-
# Example: "attributes[0].username" -> "ATTRIBUTES[0]_USERNAME"
|
|
812
|
-
attr_str.gsub(/\.(?![^\[]*\])/, "_")
|
|
813
|
-
end
|
|
814
|
-
|
|
815
|
-
# Build a human-readable error message
|
|
816
|
-
# @param error [ActiveModel::Error] the error object
|
|
817
|
-
# @return [String] the formatted message
|
|
818
|
-
def build_error_message(error)
|
|
819
|
-
# For nested attributes (with dots or brackets), we can't use ActiveModel's message method
|
|
820
|
-
# because it tries to call a method on the class which doesn't exist
|
|
821
|
-
if error.attribute.to_s.include?(".") || error.attribute.to_s.include?("[")
|
|
822
|
-
# Manually build message for nested attributes
|
|
823
|
-
attribute_name = error.attribute.to_s.humanize
|
|
824
|
-
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
825
|
-
"#{attribute_name} #{error_message}"
|
|
826
|
-
elsif error.respond_to?(:message)
|
|
827
|
-
# Try to use ActiveModel's message for simple attributes
|
|
828
|
-
message = error.message
|
|
829
|
-
# If translation is missing, fall back to our default messages
|
|
830
|
-
if message.include?("Translation missing")
|
|
831
|
-
attribute_name = error.attribute.to_s.humanize
|
|
832
|
-
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
833
|
-
"#{attribute_name} #{error_message}"
|
|
834
|
-
else
|
|
835
|
-
message
|
|
168
|
+
def format_errors_as_code
|
|
169
|
+
errors.map do |err|
|
|
170
|
+
{ code: generate_error_code(err.attribute, err.type) }
|
|
836
171
|
end
|
|
837
172
|
end
|
|
838
|
-
rescue ArgumentError, NoMethodError
|
|
839
|
-
# Fallback for anonymous classes or other issues
|
|
840
|
-
attribute_name = error.attribute.to_s.humanize
|
|
841
|
-
error_message = error.options[:message] || default_message_for_type(error.type, error.options)
|
|
842
|
-
"#{attribute_name} #{error_message}"
|
|
843
|
-
end
|
|
844
173
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
"
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
"is invalid"
|
|
858
|
-
when :too_long
|
|
859
|
-
"is too long (maximum is #{options[:count]} characters)"
|
|
860
|
-
when :too_short
|
|
861
|
-
"is too short (minimum is #{options[:count]} characters)"
|
|
862
|
-
when :wrong_length
|
|
863
|
-
"is the wrong length (should be #{options[:count]} characters)"
|
|
864
|
-
when :inclusion
|
|
865
|
-
"is not included in the list"
|
|
866
|
-
when :not_a_number
|
|
867
|
-
"is not a number"
|
|
868
|
-
when :greater_than
|
|
869
|
-
"must be greater than #{options[:count]}"
|
|
870
|
-
when :greater_than_or_equal_to
|
|
871
|
-
"must be greater than or equal to #{options[:count]}"
|
|
872
|
-
when :less_than
|
|
873
|
-
"must be less than #{options[:count]}"
|
|
874
|
-
when :less_than_or_equal_to
|
|
875
|
-
"must be less than or equal to #{options[:count]}"
|
|
876
|
-
when :equal_to
|
|
877
|
-
"must be equal to #{options[:count]}"
|
|
878
|
-
when :invalid_type
|
|
879
|
-
"must be a Hash or Array"
|
|
880
|
-
else
|
|
881
|
-
"is invalid"
|
|
174
|
+
def generate_error_code(attribute, type)
|
|
175
|
+
# Convert attribute to uppercase with underscores
|
|
176
|
+
# Handle nested attributes: user.email → USER_EMAIL, items[0].name → ITEMS[0]_NAME
|
|
177
|
+
code_attribute = attribute.to_s
|
|
178
|
+
.gsub(/\[(\d+)\]\./, '[\\1]_')
|
|
179
|
+
.gsub(".", "_")
|
|
180
|
+
.upcase
|
|
181
|
+
|
|
182
|
+
# Use "IS_REQUIRED" for blank errors, otherwise use type name
|
|
183
|
+
code_type = type == :blank ? "IS_REQUIRED" : type.to_s.upcase
|
|
184
|
+
|
|
185
|
+
"#{code_attribute}_#{code_type}"
|
|
882
186
|
end
|
|
883
187
|
end
|
|
884
|
-
# rubocop:enable Metrics/MethodLength
|
|
885
188
|
end
|
|
886
|
-
# rubocop:enable Metrics/ModuleLength
|
|
887
189
|
end
|
|
888
190
|
end
|