easy_talk 3.1.0 → 3.3.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/.rubocop.yml +15 -39
- data/.yardopts +13 -0
- data/CHANGELOG.md +164 -0
- data/README.md +442 -1529
- data/Rakefile +27 -0
- data/docs/.gitignore +1 -0
- data/docs/about.markdown +28 -8
- data/docs/getting-started.markdown +102 -0
- data/docs/index.markdown +51 -4
- data/docs/json_schema_compliance.md +169 -0
- data/docs/nested-models.markdown +216 -0
- data/docs/primitive-schema-rfc.md +894 -0
- data/docs/property-types.markdown +212 -0
- data/docs/schema-definition.markdown +180 -0
- data/lib/easy_talk/builders/base_builder.rb +6 -3
- data/lib/easy_talk/builders/boolean_builder.rb +2 -1
- data/lib/easy_talk/builders/collection_helpers.rb +4 -0
- data/lib/easy_talk/builders/composition_builder.rb +16 -13
- data/lib/easy_talk/builders/integer_builder.rb +2 -1
- data/lib/easy_talk/builders/null_builder.rb +4 -1
- data/lib/easy_talk/builders/number_builder.rb +4 -1
- data/lib/easy_talk/builders/object_builder.rb +109 -33
- data/lib/easy_talk/builders/registry.rb +182 -0
- data/lib/easy_talk/builders/string_builder.rb +3 -1
- data/lib/easy_talk/builders/temporal_builder.rb +7 -0
- data/lib/easy_talk/builders/tuple_builder.rb +89 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +19 -6
- data/lib/easy_talk/builders/union_builder.rb +5 -1
- data/lib/easy_talk/configuration.rb +47 -2
- data/lib/easy_talk/error_formatter/base.rb +100 -0
- data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
- data/lib/easy_talk/error_formatter/flat.rb +38 -0
- data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
- data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
- data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
- data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
- data/lib/easy_talk/error_formatter.rb +143 -0
- data/lib/easy_talk/errors.rb +3 -0
- data/lib/easy_talk/errors_helper.rb +66 -34
- data/lib/easy_talk/json_schema_equality.rb +46 -0
- data/lib/easy_talk/keywords.rb +0 -1
- data/lib/easy_talk/model.rb +148 -89
- data/lib/easy_talk/model_helper.rb +17 -0
- data/lib/easy_talk/naming_strategies.rb +24 -0
- data/lib/easy_talk/property.rb +23 -94
- data/lib/easy_talk/ref_helper.rb +33 -0
- data/lib/easy_talk/schema.rb +199 -0
- data/lib/easy_talk/schema_definition.rb +57 -5
- data/lib/easy_talk/schema_methods.rb +111 -0
- data/lib/easy_talk/sorbet_extension.rb +1 -0
- data/lib/easy_talk/tools/function_builder.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +222 -0
- data/lib/easy_talk/types/base_composer.rb +2 -1
- data/lib/easy_talk/types/composer.rb +4 -0
- data/lib/easy_talk/types/tuple.rb +77 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
- data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
- data/lib/easy_talk/validation_adapters/base.rb +156 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
- data/lib/easy_talk/validation_adapters/registry.rb +87 -0
- data/lib/easy_talk/validation_builder.rb +29 -309
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +42 -0
- metadata +38 -7
- data/docs/404.html +0 -25
- data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
- data/easy_talk.gemspec +0 -39
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require_relative 'active_model_schema_validation'
|
|
5
|
+
require 'easy_talk/json_schema_equality'
|
|
6
|
+
|
|
7
|
+
module EasyTalk
|
|
8
|
+
module ValidationAdapters
|
|
9
|
+
# ActiveModel validation adapter.
|
|
10
|
+
#
|
|
11
|
+
# This is the default adapter that converts JSON Schema constraints into
|
|
12
|
+
# ActiveModel validations. It provides the same validation behavior as
|
|
13
|
+
# the original EasyTalk::ValidationBuilder.
|
|
14
|
+
#
|
|
15
|
+
# @example Using the ActiveModel adapter (default)
|
|
16
|
+
# class User
|
|
17
|
+
# include EasyTalk::Model
|
|
18
|
+
#
|
|
19
|
+
# define_schema do
|
|
20
|
+
# property :email, String, format: 'email'
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# user = User.new(email: 'invalid')
|
|
25
|
+
# user.valid? # => false
|
|
26
|
+
# user.errors[:email] # => ["must be a valid email address"]
|
|
27
|
+
#
|
|
28
|
+
class ActiveModelAdapter < Base
|
|
29
|
+
# Helper class methods for tuple validation (defined at class level for use in validate blocks)
|
|
30
|
+
# Resolve a Sorbet type to a Ruby class for type checking
|
|
31
|
+
def self.resolve_tuple_type_class(type)
|
|
32
|
+
# Handle T.untyped - any value is valid
|
|
33
|
+
return :untyped if type.is_a?(T::Types::Untyped) || type == T.untyped
|
|
34
|
+
|
|
35
|
+
# Handle union types (T.any, T.nilable)
|
|
36
|
+
return type.types.flat_map { |t| resolve_tuple_type_class(t) } if type.is_a?(T::Types::Union)
|
|
37
|
+
|
|
38
|
+
if type.respond_to?(:raw_type)
|
|
39
|
+
type.raw_type
|
|
40
|
+
elsif type == T::Boolean
|
|
41
|
+
[TrueClass, FalseClass]
|
|
42
|
+
elsif type.is_a?(Class)
|
|
43
|
+
type
|
|
44
|
+
else
|
|
45
|
+
type
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if a value matches a type class (supports arrays for union types like Boolean)
|
|
50
|
+
def self.type_matches?(value, type_class)
|
|
51
|
+
# :untyped means any value is valid (from empty schema {} in JSON Schema)
|
|
52
|
+
return true if type_class == :untyped
|
|
53
|
+
|
|
54
|
+
if type_class.is_a?(Array)
|
|
55
|
+
type_class.any? { |tc| value.is_a?(tc) }
|
|
56
|
+
else
|
|
57
|
+
value.is_a?(type_class)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Generate a human-readable type name for error messages
|
|
62
|
+
def self.type_name_for_error(type_class)
|
|
63
|
+
return 'unknown' if type_class.nil?
|
|
64
|
+
|
|
65
|
+
if type_class.is_a?(Array)
|
|
66
|
+
type_class.map { |tc| tc.respond_to?(:name) ? tc.name : tc.to_s }.join(' or ')
|
|
67
|
+
elsif type_class.respond_to?(:name) && type_class.name
|
|
68
|
+
type_class.name
|
|
69
|
+
else
|
|
70
|
+
type_class.to_s
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Regex-based format validators
|
|
75
|
+
REGEX_FORMAT_CONFIGS = {
|
|
76
|
+
'email' => { with: /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/, message: 'must be a valid email address' },
|
|
77
|
+
'uuid' => { with: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i,
|
|
78
|
+
message: 'must be a valid UUID' }
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
# Formats that require parsing validation (not just regex)
|
|
82
|
+
PARSING_FORMATS = %w[date date-time time uri url].freeze
|
|
83
|
+
|
|
84
|
+
# Build schema-level validations for object-level constraints.
|
|
85
|
+
# Delegates to ActiveModelSchemaValidation module.
|
|
86
|
+
#
|
|
87
|
+
# @param klass [Class] The model class to apply validations to
|
|
88
|
+
# @param schema [Hash] The full schema hash containing schema-level constraints
|
|
89
|
+
# @return [void]
|
|
90
|
+
def self.build_schema_validations(klass, schema)
|
|
91
|
+
ActiveModelSchemaValidation.apply(klass, schema)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Apply validations based on property type and constraints.
|
|
95
|
+
#
|
|
96
|
+
# @return [void]
|
|
97
|
+
def apply_validations
|
|
98
|
+
context = build_validation_context
|
|
99
|
+
|
|
100
|
+
apply_presence_validation unless context.skip_presence_validation?
|
|
101
|
+
apply_array_presence_validation if context.array_requires_presence_validation?
|
|
102
|
+
|
|
103
|
+
apply_type_validations(context)
|
|
104
|
+
|
|
105
|
+
apply_enum_validation if @constraints[:enum]
|
|
106
|
+
apply_const_validation if @constraints[:const]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Build ValidationContext with pre-computed values from the adapter
|
|
110
|
+
def build_validation_context
|
|
111
|
+
is_optional = optional?
|
|
112
|
+
is_nilable = nilable_type?(@type)
|
|
113
|
+
validation_type = is_nilable ? extract_inner_type(@type) : @type
|
|
114
|
+
validation_type ||= @type
|
|
115
|
+
type_class = get_type_class(validation_type)
|
|
116
|
+
|
|
117
|
+
ValidationContext.new(
|
|
118
|
+
klass: @klass,
|
|
119
|
+
property_name: @property_name.to_sym,
|
|
120
|
+
constraints: @constraints || {},
|
|
121
|
+
validation_type: validation_type,
|
|
122
|
+
type_class: type_class,
|
|
123
|
+
optional: is_optional,
|
|
124
|
+
nilable: is_nilable
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
private :build_validation_context
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Apply validations based on the type of the property
|
|
132
|
+
def apply_type_validations(context)
|
|
133
|
+
if context.tuple_type?
|
|
134
|
+
apply_tuple_type_validations(context.validation_type)
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
type_class = context.type_class
|
|
139
|
+
|
|
140
|
+
if type_class == String
|
|
141
|
+
apply_string_validations
|
|
142
|
+
elsif type_class == Integer
|
|
143
|
+
apply_integer_validations
|
|
144
|
+
elsif [Float, BigDecimal].include?(type_class)
|
|
145
|
+
apply_number_validations
|
|
146
|
+
elsif type_class == Array
|
|
147
|
+
apply_array_validations(context.validation_type)
|
|
148
|
+
elsif context.boolean?
|
|
149
|
+
apply_boolean_validations
|
|
150
|
+
elsif context.model_class
|
|
151
|
+
apply_object_validations(context.model_class)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Add presence validation for the property
|
|
156
|
+
def apply_presence_validation
|
|
157
|
+
@klass.validates @property_name, presence: true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Validate string-specific constraints
|
|
161
|
+
def apply_string_validations
|
|
162
|
+
# Handle format constraints
|
|
163
|
+
apply_format_validation(@constraints[:format]) if @constraints[:format]
|
|
164
|
+
|
|
165
|
+
# Handle pattern (regex) constraints
|
|
166
|
+
apply_pattern_validation if @constraints[:pattern]
|
|
167
|
+
|
|
168
|
+
# Handle length constraints
|
|
169
|
+
apply_length_validations
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Apply pattern validation (only for string values per JSON Schema spec)
|
|
173
|
+
def apply_pattern_validation
|
|
174
|
+
property_name = @property_name
|
|
175
|
+
|
|
176
|
+
@klass.validates property_name,
|
|
177
|
+
format: { with: Regexp.new(@constraints[:pattern]) },
|
|
178
|
+
if: -> { public_send(property_name).is_a?(String) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Apply length validations for strings
|
|
182
|
+
def apply_length_validations
|
|
183
|
+
length_options = {}
|
|
184
|
+
length_options[:minimum] = @constraints[:min_length] if valid_length_constraint?(:min_length)
|
|
185
|
+
length_options[:maximum] = @constraints[:max_length] if valid_length_constraint?(:max_length)
|
|
186
|
+
return unless length_options.any?
|
|
187
|
+
|
|
188
|
+
length_options[:allow_nil] = optional? || nilable_type?
|
|
189
|
+
@klass.validates @property_name, length: length_options
|
|
190
|
+
rescue ArgumentError
|
|
191
|
+
# Silently ignore invalid length constraints
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Check if a length constraint is valid
|
|
195
|
+
def valid_length_constraint?(key)
|
|
196
|
+
@constraints[key].is_a?(Numeric) && @constraints[key] >= 0
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Apply format-specific validations (email, url, etc.)
|
|
200
|
+
# Per JSON Schema spec, format validation only applies to string values
|
|
201
|
+
def apply_format_validation(format)
|
|
202
|
+
format_str = format.to_s
|
|
203
|
+
|
|
204
|
+
# Handle parsing-based formats (date, date-time, time)
|
|
205
|
+
if PARSING_FORMATS.include?(format_str)
|
|
206
|
+
apply_parsing_format_validation(format_str)
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Handle regex-based formats
|
|
211
|
+
config = REGEX_FORMAT_CONFIGS[format_str]
|
|
212
|
+
return unless config
|
|
213
|
+
|
|
214
|
+
property_name = @property_name
|
|
215
|
+
# Per JSON Schema spec, format validation only applies when value is a string
|
|
216
|
+
@klass.validates property_name, format: config, if: -> { public_send(property_name).is_a?(String) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Apply parsing-based format validations for date, date-time, and time
|
|
220
|
+
def apply_parsing_format_validation(format)
|
|
221
|
+
case format
|
|
222
|
+
when 'date' then apply_date_format_validation
|
|
223
|
+
when 'date-time' then apply_datetime_format_validation
|
|
224
|
+
when 'time' then apply_time_format_validation
|
|
225
|
+
when 'uri', 'url' then apply_uri_format_validation
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def apply_uri_format_validation
|
|
230
|
+
prop_name = @property_name
|
|
231
|
+
@klass.validate do |record|
|
|
232
|
+
value = record.public_send(prop_name)
|
|
233
|
+
next unless value.is_a?(String)
|
|
234
|
+
|
|
235
|
+
begin
|
|
236
|
+
uri = URI.parse(value)
|
|
237
|
+
record.errors.add(prop_name, 'must be a valid URL') unless uri.absolute?
|
|
238
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
239
|
+
record.errors.add(prop_name, 'must be a valid URL')
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def apply_date_format_validation
|
|
245
|
+
prop_name = @property_name
|
|
246
|
+
@klass.validate do |record|
|
|
247
|
+
value = record.public_send(prop_name)
|
|
248
|
+
next if value.blank? || !value.is_a?(String)
|
|
249
|
+
|
|
250
|
+
Date.iso8601(value)
|
|
251
|
+
rescue Date::Error
|
|
252
|
+
record.errors.add(prop_name, 'must be a valid date in YYYY-MM-DD format')
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def apply_datetime_format_validation
|
|
257
|
+
prop_name = @property_name
|
|
258
|
+
@klass.validate do |record|
|
|
259
|
+
value = record.public_send(prop_name)
|
|
260
|
+
next if value.blank? || !value.is_a?(String)
|
|
261
|
+
|
|
262
|
+
DateTime.iso8601(value)
|
|
263
|
+
rescue Date::Error
|
|
264
|
+
record.errors.add(prop_name, 'must be a valid ISO 8601 date-time')
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def apply_time_format_validation
|
|
269
|
+
prop_name = @property_name
|
|
270
|
+
@klass.validate do |record|
|
|
271
|
+
value = record.public_send(prop_name)
|
|
272
|
+
next if value.blank? || !value.is_a?(String)
|
|
273
|
+
|
|
274
|
+
Time.parse(value)
|
|
275
|
+
record.errors.add(prop_name, 'must be a valid time in HH:MM:SS format') unless value.match?(/\A\d{2}:\d{2}:\d{2}/)
|
|
276
|
+
rescue ArgumentError
|
|
277
|
+
record.errors.add(prop_name, 'must be a valid time in HH:MM:SS format')
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Validate integer-specific constraints
|
|
282
|
+
def apply_integer_validations
|
|
283
|
+
apply_numeric_validations(only_integer: true)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Validate number-specific constraints
|
|
287
|
+
def apply_number_validations
|
|
288
|
+
apply_numeric_validations(only_integer: false)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Apply numeric validations for integers and floats
|
|
292
|
+
def apply_numeric_validations(only_integer: false)
|
|
293
|
+
options = { only_integer: only_integer }
|
|
294
|
+
|
|
295
|
+
# Add range constraints - only if they are numeric
|
|
296
|
+
options[:greater_than_or_equal_to] = @constraints[:minimum] if @constraints[:minimum].is_a?(Numeric)
|
|
297
|
+
options[:less_than_or_equal_to] = @constraints[:maximum] if @constraints[:maximum].is_a?(Numeric)
|
|
298
|
+
options[:greater_than] = @constraints[:exclusive_minimum] if @constraints[:exclusive_minimum].is_a?(Numeric)
|
|
299
|
+
options[:less_than] = @constraints[:exclusive_maximum] if @constraints[:exclusive_maximum].is_a?(Numeric)
|
|
300
|
+
|
|
301
|
+
@klass.validates @property_name, numericality: options
|
|
302
|
+
|
|
303
|
+
# Add multiple_of validation
|
|
304
|
+
apply_multiple_of_validation if @constraints[:multiple_of]
|
|
305
|
+
rescue ArgumentError
|
|
306
|
+
# Silently ignore invalid numeric constraints
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Apply multiple_of validation for numeric types
|
|
310
|
+
def apply_multiple_of_validation
|
|
311
|
+
prop_name = @property_name
|
|
312
|
+
multiple_of_value = @constraints[:multiple_of]
|
|
313
|
+
@klass.validate do |record|
|
|
314
|
+
value = record.public_send(prop_name)
|
|
315
|
+
record.errors.add(prop_name, "must be a multiple of #{multiple_of_value}") if value && (value % multiple_of_value != 0)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Validate array-specific constraints
|
|
320
|
+
def apply_array_validations(type)
|
|
321
|
+
apply_array_length_validation
|
|
322
|
+
|
|
323
|
+
# Validate uniqueness within the array
|
|
324
|
+
apply_unique_items_validation if @constraints[:unique_items]
|
|
325
|
+
|
|
326
|
+
# Check if this is a tuple (items constraint is an array of types)
|
|
327
|
+
if @constraints[:items].is_a?(::Array)
|
|
328
|
+
apply_tuple_validations(@constraints[:items], @constraints[:additional_items])
|
|
329
|
+
elsif type.is_a?(T::Types::TypedArray)
|
|
330
|
+
# Validate array item types if using T::Array[SomeType]
|
|
331
|
+
apply_array_item_type_validation(type)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Apply validations for T::Tuple[...] types
|
|
336
|
+
def apply_tuple_type_validations(tuple_type)
|
|
337
|
+
# Apply standard array constraints (min_items, max_items, unique_items)
|
|
338
|
+
apply_array_length_validation
|
|
339
|
+
|
|
340
|
+
apply_unique_items_validation if @constraints[:unique_items]
|
|
341
|
+
|
|
342
|
+
# Extract tuple types from the Tuple type
|
|
343
|
+
item_types = tuple_type.types
|
|
344
|
+
|
|
345
|
+
# Get additional_items from constraints
|
|
346
|
+
additional_items = @constraints[:additional_items]
|
|
347
|
+
|
|
348
|
+
apply_tuple_validations(item_types, additional_items)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Apply tuple validation for arrays with positional type constraints
|
|
352
|
+
def apply_tuple_validations(item_types, additional_items)
|
|
353
|
+
prop_name = @property_name
|
|
354
|
+
# Pre-resolve type classes for use in validate block
|
|
355
|
+
resolved_item_types = item_types.map { |t| self.class.resolve_tuple_type_class(t) }
|
|
356
|
+
resolved_additional_type = additional_items && ![true, false].include?(additional_items) ? self.class.resolve_tuple_type_class(additional_items) : nil
|
|
357
|
+
|
|
358
|
+
@klass.validate do |record|
|
|
359
|
+
value = record.public_send(prop_name)
|
|
360
|
+
next unless value.is_a?(Array)
|
|
361
|
+
|
|
362
|
+
# Validate positional items
|
|
363
|
+
resolved_item_types.each_with_index do |type_class, index|
|
|
364
|
+
next if index >= value.length # Item not present (may be valid depending on minItems)
|
|
365
|
+
|
|
366
|
+
item = value[index]
|
|
367
|
+
next if ActiveModelAdapter.type_matches?(item, type_class)
|
|
368
|
+
|
|
369
|
+
type_name = ActiveModelAdapter.type_name_for_error(type_class)
|
|
370
|
+
record.errors.add(prop_name, "item at index #{index} must be a #{type_name}")
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Validate additional items constraint
|
|
374
|
+
next unless value.length > resolved_item_types.length
|
|
375
|
+
|
|
376
|
+
case additional_items
|
|
377
|
+
when false
|
|
378
|
+
record.errors.add(prop_name, "must have at most #{resolved_item_types.length} items")
|
|
379
|
+
when nil, true
|
|
380
|
+
# Any additional items allowed
|
|
381
|
+
else
|
|
382
|
+
# additional_items is a type - validate extra items against it
|
|
383
|
+
value[resolved_item_types.length..].each_with_index do |item, offset|
|
|
384
|
+
index = resolved_item_types.length + offset
|
|
385
|
+
next if ActiveModelAdapter.type_matches?(item, resolved_additional_type)
|
|
386
|
+
|
|
387
|
+
type_name = ActiveModelAdapter.type_name_for_error(resolved_additional_type)
|
|
388
|
+
record.errors.add(prop_name, "item at index #{index} must be a #{type_name}")
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Apply unique items validation for arrays using JSON Schema equality semantics
|
|
395
|
+
def apply_unique_items_validation
|
|
396
|
+
prop_name = @property_name
|
|
397
|
+
@klass.validate do |record|
|
|
398
|
+
value = record.public_send(prop_name)
|
|
399
|
+
next unless value.is_a?(Array)
|
|
400
|
+
|
|
401
|
+
record.errors.add(prop_name, 'must contain unique items') if JsonSchemaEquality.duplicates?(value)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Apply array item type and nested model validation
|
|
406
|
+
def apply_array_item_type_validation(type)
|
|
407
|
+
# Get inner type from T::Types::TypedArray (uses .type, which returns T::Types::Simple)
|
|
408
|
+
inner_type_wrapper = type.type
|
|
409
|
+
inner_type = inner_type_wrapper.respond_to?(:raw_type) ? inner_type_wrapper.raw_type : inner_type_wrapper
|
|
410
|
+
prop_name = @property_name
|
|
411
|
+
is_easy_talk_model = inner_type.is_a?(Class) && inner_type.include?(EasyTalk::Model)
|
|
412
|
+
|
|
413
|
+
@klass.validate do |record|
|
|
414
|
+
value = record.public_send(prop_name)
|
|
415
|
+
next unless value.is_a?(Array)
|
|
416
|
+
|
|
417
|
+
value.each_with_index do |item, index|
|
|
418
|
+
unless item.is_a?(inner_type)
|
|
419
|
+
record.errors.add(prop_name, "item at index #{index} must be a #{inner_type}")
|
|
420
|
+
next
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Recursively validate nested EasyTalk::Model items
|
|
424
|
+
next unless is_easy_talk_model && !item.valid?
|
|
425
|
+
|
|
426
|
+
item.errors.each do |error|
|
|
427
|
+
nested_key = "#{prop_name}[#{index}].#{error.attribute}"
|
|
428
|
+
record.errors.add(nested_key.to_sym, error.message)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Validate boolean-specific constraints
|
|
435
|
+
def apply_boolean_validations
|
|
436
|
+
# For boolean values, validate inclusion in [true, false]
|
|
437
|
+
# If not optional, don't allow nil (equivalent to presence validation for booleans)
|
|
438
|
+
if optional?
|
|
439
|
+
@klass.validates @property_name, inclusion: { in: [true, false] }, allow_nil: true
|
|
440
|
+
else
|
|
441
|
+
@klass.validates @property_name, inclusion: { in: [true, false] }
|
|
442
|
+
# Add custom validation for nil values that provides the "can't be blank" message
|
|
443
|
+
apply_boolean_presence_validation
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Add type validation to ensure the value is actually a boolean
|
|
447
|
+
apply_boolean_type_validation
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Apply presence validation for boolean (nil check with custom message)
|
|
451
|
+
def apply_boolean_presence_validation
|
|
452
|
+
prop_name = @property_name
|
|
453
|
+
@klass.validate do |record|
|
|
454
|
+
value = record.public_send(prop_name)
|
|
455
|
+
record.errors.add(prop_name, :blank) if value.nil?
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Apply presence validation for arrays (nil check, but allow empty arrays)
|
|
460
|
+
def apply_array_presence_validation
|
|
461
|
+
prop_name = @property_name
|
|
462
|
+
@klass.validate do |record|
|
|
463
|
+
value = record.public_send(prop_name)
|
|
464
|
+
record.errors.add(prop_name, :blank) if value.nil?
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Apply type validation for boolean
|
|
469
|
+
def apply_boolean_type_validation
|
|
470
|
+
prop_name = @property_name
|
|
471
|
+
@klass.validate do |record|
|
|
472
|
+
value = record.public_send(prop_name)
|
|
473
|
+
record.errors.add(prop_name, 'must be a boolean') if value && ![true, false].include?(value)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Validate object/hash-specific constraints
|
|
478
|
+
def apply_object_validations(expected_type)
|
|
479
|
+
# Capture necessary variables outside the validation block's scope
|
|
480
|
+
prop_name = @property_name
|
|
481
|
+
|
|
482
|
+
@klass.validate do |record|
|
|
483
|
+
nested_object = record.public_send(prop_name)
|
|
484
|
+
|
|
485
|
+
# Only validate if the nested object is present
|
|
486
|
+
next unless nested_object
|
|
487
|
+
|
|
488
|
+
# Check if the object is of the expected type (e.g., an actual Email instance)
|
|
489
|
+
if nested_object.is_a?(expected_type)
|
|
490
|
+
# Check if this object appears to be empty (created from an empty hash)
|
|
491
|
+
# by checking if all defined properties are nil/blank
|
|
492
|
+
properties = expected_type.schema_definition.schema[:properties] || {}
|
|
493
|
+
all_properties_blank = properties.keys.all? do |property|
|
|
494
|
+
value = nested_object.public_send(property)
|
|
495
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
if all_properties_blank
|
|
499
|
+
# Treat as blank and add a presence error to the parent field
|
|
500
|
+
record.errors.add(prop_name, "can't be blank")
|
|
501
|
+
elsif !nested_object.valid?
|
|
502
|
+
# If it's the correct type and not empty, validate it
|
|
503
|
+
# Merge errors from the nested object into the parent
|
|
504
|
+
nested_object.errors.each do |error|
|
|
505
|
+
# Prefix the attribute name (e.g., 'email.address')
|
|
506
|
+
nested_key = "#{prop_name}.#{error.attribute}"
|
|
507
|
+
record.errors.add(nested_key.to_sym, error.message)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
else
|
|
511
|
+
# If present but not the correct type, add a type error
|
|
512
|
+
record.errors.add(prop_name, "must be a valid #{expected_type.name}")
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Apply enum validation for inclusion in a specific list
|
|
518
|
+
def apply_enum_validation
|
|
519
|
+
@klass.validates @property_name, inclusion: {
|
|
520
|
+
in: @constraints[:enum],
|
|
521
|
+
message: "must be one of: #{@constraints[:enum].join(', ')}",
|
|
522
|
+
allow_nil: optional?
|
|
523
|
+
}
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Apply const validation for equality with a specific value
|
|
527
|
+
def apply_const_validation
|
|
528
|
+
const_value = @constraints[:const]
|
|
529
|
+
prop_name = @property_name
|
|
530
|
+
@klass.validate do |record|
|
|
531
|
+
value = record.public_send(prop_name)
|
|
532
|
+
record.errors.add(prop_name, "must be equal to #{const_value}") if !value.nil? && value != const_value
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Plain data object holding pre-computed validation context.
|
|
537
|
+
# All values are computed by ActiveModelAdapter and passed in,
|
|
538
|
+
# avoiding tight coupling to adapter internals.
|
|
539
|
+
class ValidationContext
|
|
540
|
+
attr_reader :klass, :property_name, :constraints, :validation_type, :type_class, :model_class
|
|
541
|
+
|
|
542
|
+
def initialize(attrs)
|
|
543
|
+
@klass = attrs[:klass]
|
|
544
|
+
@property_name = attrs[:property_name]
|
|
545
|
+
@constraints = attrs[:constraints]
|
|
546
|
+
@validation_type = attrs[:validation_type]
|
|
547
|
+
@type_class = attrs[:type_class]
|
|
548
|
+
@optional = attrs[:optional]
|
|
549
|
+
@nilable = attrs[:nilable]
|
|
550
|
+
|
|
551
|
+
# Derive additional flags from the pre-computed values
|
|
552
|
+
@boolean_type = boolean_type_from_context?
|
|
553
|
+
@array_type = array_type_from_context?
|
|
554
|
+
@tuple_type = tuple_type_from_context?
|
|
555
|
+
@model_class = model_class_from_context
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def skip_presence_validation?
|
|
559
|
+
@optional || @boolean_type || @nilable || @array_type
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def array_requires_presence_validation?
|
|
563
|
+
@array_type && !@nilable
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def tuple_type?
|
|
567
|
+
@tuple_type
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def boolean?
|
|
571
|
+
@boolean_type
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
private
|
|
575
|
+
|
|
576
|
+
def boolean_type_from_context?
|
|
577
|
+
TypeIntrospection.boolean_type?(@validation_type) ||
|
|
578
|
+
@type_class == TrueClass ||
|
|
579
|
+
@type_class == FalseClass ||
|
|
580
|
+
TypeIntrospection.boolean_union_type?(@type_class)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def array_type_from_context?
|
|
584
|
+
return false if @validation_type.nil?
|
|
585
|
+
|
|
586
|
+
@validation_type == Array ||
|
|
587
|
+
TypeIntrospection.typed_array?(@validation_type) ||
|
|
588
|
+
tuple_type_from_context?
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def tuple_type_from_context?
|
|
592
|
+
defined?(EasyTalk::Types::Tuple) && @validation_type.is_a?(EasyTalk::Types::Tuple)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def model_class_from_context
|
|
596
|
+
return unless @type_class.is_a?(Class)
|
|
597
|
+
|
|
598
|
+
@type_class.include?(EasyTalk::Model) ? @type_class : nil
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Shared min_items/max_items handling for array-like validations
|
|
603
|
+
def apply_array_length_validation
|
|
604
|
+
length_options = {
|
|
605
|
+
minimum: @constraints[:min_items],
|
|
606
|
+
maximum: @constraints[:max_items]
|
|
607
|
+
}.compact
|
|
608
|
+
|
|
609
|
+
return if length_options.empty?
|
|
610
|
+
|
|
611
|
+
@klass.validates @property_name, length: length_options
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
private_constant :ValidationContext
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
end
|