easy_talk 3.1.0 → 3.2.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 +4 -0
- data/.yardopts +13 -0
- data/CHANGELOG.md +75 -0
- data/README.md +616 -35
- 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 +55 -0
- data/docs/nested-models.markdown +216 -0
- data/docs/property-types.markdown +212 -0
- data/docs/schema-definition.markdown +180 -0
- data/lib/easy_talk/builders/base_builder.rb +4 -2
- data/lib/easy_talk/builders/composition_builder.rb +10 -12
- data/lib/easy_talk/builders/object_builder.rb +45 -30
- data/lib/easy_talk/builders/registry.rb +168 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +15 -4
- data/lib/easy_talk/configuration.rb +31 -1
- 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 +2 -0
- data/lib/easy_talk/errors_helper.rb +63 -34
- data/lib/easy_talk/model.rb +123 -90
- data/lib/easy_talk/model_helper.rb +13 -0
- data/lib/easy_talk/naming_strategies.rb +20 -0
- data/lib/easy_talk/property.rb +16 -94
- data/lib/easy_talk/ref_helper.rb +27 -0
- data/lib/easy_talk/schema.rb +198 -0
- data/lib/easy_talk/schema_definition.rb +7 -1
- data/lib/easy_talk/schema_methods.rb +80 -0
- data/lib/easy_talk/tools/function_builder.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +178 -0
- data/lib/easy_talk/types/base_composer.rb +2 -1
- data/lib/easy_talk/types/composer.rb +4 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +329 -0
- data/lib/easy_talk/validation_adapters/base.rb +144 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +36 -0
- data/lib/easy_talk/validation_adapters/registry.rb +87 -0
- data/lib/easy_talk/validation_builder.rb +28 -309
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +41 -0
- metadata +26 -4
- 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,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module EasyTalk
|
|
6
|
+
module ValidationAdapters
|
|
7
|
+
# ActiveModel validation adapter.
|
|
8
|
+
#
|
|
9
|
+
# This is the default adapter that converts JSON Schema constraints into
|
|
10
|
+
# ActiveModel validations. It provides the same validation behavior as
|
|
11
|
+
# the original EasyTalk::ValidationBuilder.
|
|
12
|
+
#
|
|
13
|
+
# @example Using the ActiveModel adapter (default)
|
|
14
|
+
# class User
|
|
15
|
+
# include EasyTalk::Model
|
|
16
|
+
#
|
|
17
|
+
# define_schema do
|
|
18
|
+
# property :email, String, format: 'email'
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# user = User.new(email: 'invalid')
|
|
23
|
+
# user.valid? # => false
|
|
24
|
+
# user.errors[:email] # => ["must be a valid email address"]
|
|
25
|
+
#
|
|
26
|
+
class ActiveModelAdapter < Base
|
|
27
|
+
# Apply validations based on property type and constraints.
|
|
28
|
+
#
|
|
29
|
+
# @return [void]
|
|
30
|
+
def apply_validations
|
|
31
|
+
# Determine if the type is boolean
|
|
32
|
+
type_class = get_type_class(@type)
|
|
33
|
+
is_boolean = type_class == [TrueClass, FalseClass] ||
|
|
34
|
+
type_class == TrueClass ||
|
|
35
|
+
type_class == FalseClass ||
|
|
36
|
+
TypeIntrospection.boolean_type?(@type)
|
|
37
|
+
|
|
38
|
+
# Determine if the type is an array (empty arrays should be valid)
|
|
39
|
+
is_array = type_class == Array || @type.is_a?(T::Types::TypedArray)
|
|
40
|
+
|
|
41
|
+
# Skip presence validation for booleans, nilable types, and arrays
|
|
42
|
+
# (empty arrays are valid - use min_items constraint if you need non-empty)
|
|
43
|
+
apply_presence_validation unless optional? || is_boolean || nilable_type? || is_array
|
|
44
|
+
|
|
45
|
+
if nilable_type?
|
|
46
|
+
# For nilable types, get the inner type and apply validations to it
|
|
47
|
+
inner_type = extract_inner_type(@type)
|
|
48
|
+
apply_type_validations(inner_type)
|
|
49
|
+
else
|
|
50
|
+
apply_type_validations(@type)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Common validations for most types
|
|
54
|
+
apply_enum_validation if @constraints[:enum]
|
|
55
|
+
apply_const_validation if @constraints[:const]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Apply validations based on the type of the property
|
|
61
|
+
def apply_type_validations(type)
|
|
62
|
+
type_class = get_type_class(type)
|
|
63
|
+
|
|
64
|
+
if type_class == String
|
|
65
|
+
apply_string_validations
|
|
66
|
+
elsif type_class == Integer
|
|
67
|
+
apply_integer_validations
|
|
68
|
+
elsif [Float, BigDecimal].include?(type_class)
|
|
69
|
+
apply_number_validations
|
|
70
|
+
elsif type_class == Array
|
|
71
|
+
apply_array_validations(type)
|
|
72
|
+
elsif type_class == [TrueClass,
|
|
73
|
+
FalseClass] || [TrueClass,
|
|
74
|
+
FalseClass].include?(type_class) || TypeIntrospection.boolean_type?(type)
|
|
75
|
+
apply_boolean_validations
|
|
76
|
+
elsif type_class.is_a?(Object) && type_class.include?(EasyTalk::Model)
|
|
77
|
+
apply_object_validations
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Add presence validation for the property
|
|
82
|
+
def apply_presence_validation
|
|
83
|
+
@klass.validates @property_name, presence: true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Validate string-specific constraints
|
|
87
|
+
def apply_string_validations
|
|
88
|
+
# Handle format constraints
|
|
89
|
+
apply_format_validation(@constraints[:format]) if @constraints[:format]
|
|
90
|
+
|
|
91
|
+
# Handle pattern (regex) constraints
|
|
92
|
+
if @constraints[:pattern]
|
|
93
|
+
@klass.validates @property_name, format: { with: Regexp.new(@constraints[:pattern]) },
|
|
94
|
+
allow_nil: optional?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Handle length constraints
|
|
98
|
+
apply_length_validations
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Apply length validations for strings
|
|
102
|
+
def apply_length_validations
|
|
103
|
+
length_options = {}
|
|
104
|
+
length_options[:minimum] = @constraints[:min_length] if valid_length_constraint?(:min_length)
|
|
105
|
+
length_options[:maximum] = @constraints[:max_length] if valid_length_constraint?(:max_length)
|
|
106
|
+
return unless length_options.any?
|
|
107
|
+
|
|
108
|
+
length_options[:allow_nil] = optional? || nilable_type?
|
|
109
|
+
@klass.validates @property_name, length: length_options
|
|
110
|
+
rescue ArgumentError
|
|
111
|
+
# Silently ignore invalid length constraints
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if a length constraint is valid
|
|
115
|
+
def valid_length_constraint?(key)
|
|
116
|
+
@constraints[key].is_a?(Numeric) && @constraints[key] >= 0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Apply format-specific validations (email, url, etc.)
|
|
120
|
+
def apply_format_validation(format)
|
|
121
|
+
format_configs = {
|
|
122
|
+
'email' => { with: URI::MailTo::EMAIL_REGEXP, message: 'must be a valid email address' },
|
|
123
|
+
'uri' => { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a valid URL' },
|
|
124
|
+
'url' => { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a valid URL' },
|
|
125
|
+
'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,
|
|
126
|
+
message: 'must be a valid UUID' },
|
|
127
|
+
'date' => { with: /\A\d{4}-\d{2}-\d{2}\z/, message: 'must be a valid date in YYYY-MM-DD format' },
|
|
128
|
+
'date-time' => { with: /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/,
|
|
129
|
+
message: 'must be a valid ISO 8601 date-time' },
|
|
130
|
+
'time' => { with: /\A\d{2}:\d{2}:\d{2}(?:\.\d+)?\z/, message: 'must be a valid time in HH:MM:SS format' }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
config = format_configs[format.to_s]
|
|
134
|
+
return unless config
|
|
135
|
+
|
|
136
|
+
config[:allow_nil] = optional? || nilable_type?
|
|
137
|
+
@klass.validates @property_name, format: config
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Validate integer-specific constraints
|
|
141
|
+
def apply_integer_validations
|
|
142
|
+
apply_numeric_validations(only_integer: true)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Validate number-specific constraints
|
|
146
|
+
def apply_number_validations
|
|
147
|
+
apply_numeric_validations(only_integer: false)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Apply numeric validations for integers and floats
|
|
151
|
+
def apply_numeric_validations(only_integer: false)
|
|
152
|
+
options = { only_integer: only_integer }
|
|
153
|
+
|
|
154
|
+
# Add range constraints - only if they are numeric
|
|
155
|
+
options[:greater_than_or_equal_to] = @constraints[:minimum] if @constraints[:minimum].is_a?(Numeric)
|
|
156
|
+
options[:less_than_or_equal_to] = @constraints[:maximum] if @constraints[:maximum].is_a?(Numeric)
|
|
157
|
+
options[:greater_than] = @constraints[:exclusive_minimum] if @constraints[:exclusive_minimum].is_a?(Numeric)
|
|
158
|
+
options[:less_than] = @constraints[:exclusive_maximum] if @constraints[:exclusive_maximum].is_a?(Numeric)
|
|
159
|
+
|
|
160
|
+
@klass.validates @property_name, numericality: options
|
|
161
|
+
|
|
162
|
+
# Add multiple_of validation
|
|
163
|
+
apply_multiple_of_validation if @constraints[:multiple_of]
|
|
164
|
+
rescue ArgumentError
|
|
165
|
+
# Silently ignore invalid numeric constraints
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Apply multiple_of validation for numeric types
|
|
169
|
+
def apply_multiple_of_validation
|
|
170
|
+
prop_name = @property_name
|
|
171
|
+
multiple_of_value = @constraints[:multiple_of]
|
|
172
|
+
@klass.validate do |record|
|
|
173
|
+
value = record.public_send(prop_name)
|
|
174
|
+
record.errors.add(prop_name, "must be a multiple of #{multiple_of_value}") if value && (value % multiple_of_value != 0)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Validate array-specific constraints
|
|
179
|
+
def apply_array_validations(type)
|
|
180
|
+
# Validate array length
|
|
181
|
+
if @constraints[:min_items] || @constraints[:max_items]
|
|
182
|
+
length_options = {}
|
|
183
|
+
length_options[:minimum] = @constraints[:min_items] if @constraints[:min_items]
|
|
184
|
+
length_options[:maximum] = @constraints[:max_items] if @constraints[:max_items]
|
|
185
|
+
|
|
186
|
+
@klass.validates @property_name, length: length_options
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Validate uniqueness within the array
|
|
190
|
+
apply_unique_items_validation if @constraints[:unique_items]
|
|
191
|
+
|
|
192
|
+
# Validate array item types if using T::Array[SomeType]
|
|
193
|
+
apply_array_item_type_validation(type) if type.is_a?(T::Types::TypedArray)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Apply unique items validation for arrays
|
|
197
|
+
def apply_unique_items_validation
|
|
198
|
+
prop_name = @property_name
|
|
199
|
+
@klass.validate do |record|
|
|
200
|
+
value = record.public_send(prop_name)
|
|
201
|
+
record.errors.add(prop_name, 'must contain unique items') if value && value.uniq.length != value.length
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Apply array item type and nested model validation
|
|
206
|
+
def apply_array_item_type_validation(type)
|
|
207
|
+
# Get inner type from T::Types::TypedArray (uses .type, which returns T::Types::Simple)
|
|
208
|
+
inner_type_wrapper = type.type
|
|
209
|
+
inner_type = inner_type_wrapper.respond_to?(:raw_type) ? inner_type_wrapper.raw_type : inner_type_wrapper
|
|
210
|
+
prop_name = @property_name
|
|
211
|
+
is_easy_talk_model = inner_type.is_a?(Class) && inner_type.include?(EasyTalk::Model)
|
|
212
|
+
|
|
213
|
+
@klass.validate do |record|
|
|
214
|
+
value = record.public_send(prop_name)
|
|
215
|
+
next unless value.is_a?(Array)
|
|
216
|
+
|
|
217
|
+
value.each_with_index do |item, index|
|
|
218
|
+
unless item.is_a?(inner_type)
|
|
219
|
+
record.errors.add(prop_name, "item at index #{index} must be a #{inner_type}")
|
|
220
|
+
next
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Recursively validate nested EasyTalk::Model items
|
|
224
|
+
next unless is_easy_talk_model && !item.valid?
|
|
225
|
+
|
|
226
|
+
item.errors.each do |error|
|
|
227
|
+
nested_key = "#{prop_name}[#{index}].#{error.attribute}"
|
|
228
|
+
record.errors.add(nested_key.to_sym, error.message)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Validate boolean-specific constraints
|
|
235
|
+
def apply_boolean_validations
|
|
236
|
+
# For boolean values, validate inclusion in [true, false]
|
|
237
|
+
# If not optional, don't allow nil (equivalent to presence validation for booleans)
|
|
238
|
+
if optional?
|
|
239
|
+
@klass.validates @property_name, inclusion: { in: [true, false] }, allow_nil: true
|
|
240
|
+
else
|
|
241
|
+
@klass.validates @property_name, inclusion: { in: [true, false] }
|
|
242
|
+
# Add custom validation for nil values that provides the "can't be blank" message
|
|
243
|
+
apply_boolean_presence_validation
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Add type validation to ensure the value is actually a boolean
|
|
247
|
+
apply_boolean_type_validation
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Apply presence validation for boolean (nil check with custom message)
|
|
251
|
+
def apply_boolean_presence_validation
|
|
252
|
+
prop_name = @property_name
|
|
253
|
+
@klass.validate do |record|
|
|
254
|
+
value = record.public_send(prop_name)
|
|
255
|
+
record.errors.add(prop_name, "can't be blank") if value.nil?
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Apply type validation for boolean
|
|
260
|
+
def apply_boolean_type_validation
|
|
261
|
+
prop_name = @property_name
|
|
262
|
+
@klass.validate do |record|
|
|
263
|
+
value = record.public_send(prop_name)
|
|
264
|
+
record.errors.add(prop_name, 'must be a boolean') if value && ![true, false].include?(value)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Validate object/hash-specific constraints
|
|
269
|
+
def apply_object_validations
|
|
270
|
+
# Capture necessary variables outside the validation block's scope
|
|
271
|
+
prop_name = @property_name
|
|
272
|
+
expected_type = get_type_class(@type) # Get the raw model class
|
|
273
|
+
|
|
274
|
+
@klass.validate do |record|
|
|
275
|
+
nested_object = record.public_send(prop_name)
|
|
276
|
+
|
|
277
|
+
# Only validate if the nested object is present
|
|
278
|
+
next unless nested_object
|
|
279
|
+
|
|
280
|
+
# Check if the object is of the expected type (e.g., an actual Email instance)
|
|
281
|
+
if nested_object.is_a?(expected_type)
|
|
282
|
+
# Check if this object appears to be empty (created from an empty hash)
|
|
283
|
+
# by checking if all defined properties are nil/blank
|
|
284
|
+
properties = expected_type.schema_definition.schema[:properties] || {}
|
|
285
|
+
all_properties_blank = properties.keys.all? do |property|
|
|
286
|
+
value = nested_object.public_send(property)
|
|
287
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
if all_properties_blank
|
|
291
|
+
# Treat as blank and add a presence error to the parent field
|
|
292
|
+
record.errors.add(prop_name, "can't be blank")
|
|
293
|
+
elsif !nested_object.valid?
|
|
294
|
+
# If it's the correct type and not empty, validate it
|
|
295
|
+
# Merge errors from the nested object into the parent
|
|
296
|
+
nested_object.errors.each do |error|
|
|
297
|
+
# Prefix the attribute name (e.g., 'email.address')
|
|
298
|
+
nested_key = "#{prop_name}.#{error.attribute}"
|
|
299
|
+
record.errors.add(nested_key.to_sym, error.message)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
else
|
|
303
|
+
# If present but not the correct type, add a type error
|
|
304
|
+
record.errors.add(prop_name, "must be a valid #{expected_type.name}")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Apply enum validation for inclusion in a specific list
|
|
310
|
+
def apply_enum_validation
|
|
311
|
+
@klass.validates @property_name, inclusion: {
|
|
312
|
+
in: @constraints[:enum],
|
|
313
|
+
message: "must be one of: #{@constraints[:enum].join(', ')}",
|
|
314
|
+
allow_nil: optional?
|
|
315
|
+
}
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Apply const validation for equality with a specific value
|
|
319
|
+
def apply_const_validation
|
|
320
|
+
const_value = @constraints[:const]
|
|
321
|
+
prop_name = @property_name
|
|
322
|
+
@klass.validate do |record|
|
|
323
|
+
value = record.public_send(prop_name)
|
|
324
|
+
record.errors.add(prop_name, "must be equal to #{const_value}") if !value.nil? && value != const_value
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyTalk
|
|
4
|
+
module ValidationAdapters
|
|
5
|
+
# Abstract base class for validation adapters.
|
|
6
|
+
#
|
|
7
|
+
# Validation adapters are responsible for converting JSON Schema constraints
|
|
8
|
+
# into validation rules for the target validation framework (e.g., ActiveModel,
|
|
9
|
+
# dry-validation, or custom validators).
|
|
10
|
+
#
|
|
11
|
+
# To create a custom adapter, subclass this class and implement the
|
|
12
|
+
# `apply_validations` method.
|
|
13
|
+
#
|
|
14
|
+
# @example Creating a custom adapter
|
|
15
|
+
# class MyCustomAdapter < EasyTalk::ValidationAdapters::Base
|
|
16
|
+
# def apply_validations
|
|
17
|
+
# # Apply custom validations to @klass based on @constraints
|
|
18
|
+
# @klass.validates @property_name, presence: true unless optional?
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example Registering and using a custom adapter
|
|
23
|
+
# EasyTalk::ValidationAdapters::Registry.register(:custom, MyCustomAdapter)
|
|
24
|
+
#
|
|
25
|
+
# class User
|
|
26
|
+
# include EasyTalk::Model
|
|
27
|
+
# define_schema(validations: :custom) do
|
|
28
|
+
# property :name, String
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
class Base
|
|
33
|
+
# Build validations for a property and apply them to the model class.
|
|
34
|
+
# This is the primary interface that adapters must implement.
|
|
35
|
+
#
|
|
36
|
+
# @param klass [Class] The model class to apply validations to
|
|
37
|
+
# @param property_name [Symbol, String] The name of the property
|
|
38
|
+
# @param type [Class, Object] The type of the property (Ruby class or Sorbet type)
|
|
39
|
+
# @param constraints [Hash] The JSON Schema constraints for the property
|
|
40
|
+
# Possible keys: :min_length, :max_length, :minimum, :maximum, :pattern,
|
|
41
|
+
# :format, :enum, :const, :min_items, :max_items, :unique_items, :optional
|
|
42
|
+
# @return [void]
|
|
43
|
+
def self.build_validations(klass, property_name, type, constraints)
|
|
44
|
+
new(klass, property_name, type, constraints).apply_validations
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Initialize a new validation adapter instance.
|
|
48
|
+
#
|
|
49
|
+
# @param klass [Class] The model class to apply validations to
|
|
50
|
+
# @param property_name [Symbol, String] The name of the property
|
|
51
|
+
# @param type [Class, Object] The type of the property
|
|
52
|
+
# @param constraints [Hash] The JSON Schema constraints for the property
|
|
53
|
+
def initialize(klass, property_name, type, constraints)
|
|
54
|
+
@klass = klass
|
|
55
|
+
@property_name = property_name.to_sym
|
|
56
|
+
@type = type
|
|
57
|
+
@constraints = constraints || {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Apply validations based on property type and constraints.
|
|
61
|
+
# Subclasses MUST implement this method.
|
|
62
|
+
#
|
|
63
|
+
# @abstract
|
|
64
|
+
# @return [void]
|
|
65
|
+
# @raise [NotImplementedError] if the subclass does not implement this method
|
|
66
|
+
def apply_validations
|
|
67
|
+
raise NotImplementedError, "#{self.class} must implement #apply_validations"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
protected
|
|
71
|
+
|
|
72
|
+
attr_reader :klass, :property_name, :type, :constraints
|
|
73
|
+
|
|
74
|
+
# Check if a property is optional based on constraints and configuration.
|
|
75
|
+
#
|
|
76
|
+
# A property is considered optional if:
|
|
77
|
+
# - The :optional constraint is explicitly set to true
|
|
78
|
+
# - The type is nilable AND nilable_is_optional configuration is true
|
|
79
|
+
#
|
|
80
|
+
# @return [Boolean] true if the property is optional
|
|
81
|
+
def optional?
|
|
82
|
+
@constraints[:optional] == true ||
|
|
83
|
+
(@type.respond_to?(:nilable?) && @type.nilable? && EasyTalk.configuration.nilable_is_optional)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check if the type is nilable (e.g., T.nilable(String)).
|
|
87
|
+
#
|
|
88
|
+
# @param t [Class, Object] The type to check (defaults to @type)
|
|
89
|
+
# @return [Boolean] true if the type is nilable
|
|
90
|
+
def nilable_type?(type_to_check = @type)
|
|
91
|
+
type_to_check.respond_to?(:nilable?) && type_to_check.nilable?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Extract the inner type from a complex type like T.nilable(String) or T.nilable(T::Array[Model]).
|
|
95
|
+
#
|
|
96
|
+
# @param type_to_unwrap [Class, Object] The type to unwrap (defaults to @type)
|
|
97
|
+
# @return [Class, Object] The inner type, or the original type if not wrapped
|
|
98
|
+
def extract_inner_type(type_to_unwrap = @type)
|
|
99
|
+
if type_to_unwrap.respond_to?(:unwrap_nilable)
|
|
100
|
+
unwrapped = type_to_unwrap.unwrap_nilable
|
|
101
|
+
# Return TypedArray directly (for T.nilable(T::Array[Model]))
|
|
102
|
+
return unwrapped if unwrapped.is_a?(T::Types::TypedArray)
|
|
103
|
+
# Return raw_type for simple types (for T.nilable(String))
|
|
104
|
+
return unwrapped.raw_type if unwrapped.respond_to?(:raw_type)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if type_to_unwrap.respond_to?(:types)
|
|
108
|
+
# For union types, find the non-nil type
|
|
109
|
+
# Prefer TypedArray if present, otherwise find type with raw_type
|
|
110
|
+
type_to_unwrap.types.find { |t| t.is_a?(T::Types::TypedArray) } ||
|
|
111
|
+
type_to_unwrap.types.find { |t| t.respond_to?(:raw_type) && t.raw_type != NilClass }
|
|
112
|
+
else
|
|
113
|
+
type_to_unwrap
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Determine the actual class for a type, handling Sorbet types.
|
|
118
|
+
#
|
|
119
|
+
# @param type_to_resolve [Class, Object] The type to resolve
|
|
120
|
+
# @return [Class, Array<Class>] The resolved class or classes
|
|
121
|
+
def get_type_class(type_to_resolve)
|
|
122
|
+
if type_to_resolve.is_a?(Class)
|
|
123
|
+
type_to_resolve
|
|
124
|
+
elsif type_to_resolve.respond_to?(:raw_type)
|
|
125
|
+
type_to_resolve.raw_type
|
|
126
|
+
elsif type_to_resolve.is_a?(T::Types::TypedArray)
|
|
127
|
+
Array
|
|
128
|
+
elsif type_to_resolve.is_a?(Symbol) || type_to_resolve.is_a?(String)
|
|
129
|
+
begin
|
|
130
|
+
type_to_resolve.to_s.classify.constantize
|
|
131
|
+
rescue StandardError
|
|
132
|
+
String
|
|
133
|
+
end
|
|
134
|
+
elsif TypeIntrospection.boolean_type?(type_to_resolve)
|
|
135
|
+
[TrueClass, FalseClass]
|
|
136
|
+
elsif nilable_type?(type_to_resolve)
|
|
137
|
+
extract_inner_type(type_to_resolve)
|
|
138
|
+
else
|
|
139
|
+
String
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyTalk
|
|
4
|
+
module ValidationAdapters
|
|
5
|
+
# No-op validation adapter.
|
|
6
|
+
#
|
|
7
|
+
# This adapter does not apply any validations. Use it when you want
|
|
8
|
+
# schema generation without any validation side effects.
|
|
9
|
+
#
|
|
10
|
+
# @example Disabling validations for a model
|
|
11
|
+
# class ApiContract
|
|
12
|
+
# include EasyTalk::Model
|
|
13
|
+
#
|
|
14
|
+
# define_schema(validations: :none) do
|
|
15
|
+
# property :name, String, min_length: 5
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# contract = ApiContract.new(name: 'X')
|
|
20
|
+
# contract.valid? # => true (no validations applied)
|
|
21
|
+
#
|
|
22
|
+
# @example Using globally
|
|
23
|
+
# EasyTalk.configure do |config|
|
|
24
|
+
# config.validation_adapter = :none
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
class NoneAdapter < Base
|
|
28
|
+
# Apply no validations (no-op).
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
def apply_validations
|
|
32
|
+
# Intentionally empty - no validations applied
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyTalk
|
|
4
|
+
module ValidationAdapters
|
|
5
|
+
# Registry for validation adapters.
|
|
6
|
+
#
|
|
7
|
+
# The registry allows adapters to be registered with symbolic names and
|
|
8
|
+
# resolved from various input types (symbols, classes, or nil for default).
|
|
9
|
+
#
|
|
10
|
+
# @example Registering an adapter
|
|
11
|
+
# EasyTalk::ValidationAdapters::Registry.register(:custom, MyCustomAdapter)
|
|
12
|
+
#
|
|
13
|
+
# @example Resolving an adapter
|
|
14
|
+
# adapter = EasyTalk::ValidationAdapters::Registry.resolve(:active_model)
|
|
15
|
+
# adapter.build_validations(klass, :name, String, {})
|
|
16
|
+
#
|
|
17
|
+
class Registry
|
|
18
|
+
class << self
|
|
19
|
+
# Get the hash of registered adapters.
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash{Symbol => Class}] The registered adapters
|
|
22
|
+
def adapters
|
|
23
|
+
@adapters ||= {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register an adapter with a symbolic name.
|
|
27
|
+
#
|
|
28
|
+
# @param name [Symbol, String] The adapter identifier
|
|
29
|
+
# @param adapter_class [Class] The adapter class (must respond to .build_validations)
|
|
30
|
+
# @raise [ArgumentError] if the adapter does not respond to .build_validations
|
|
31
|
+
# @return [void]
|
|
32
|
+
def register(name, adapter_class)
|
|
33
|
+
raise ArgumentError, "Adapter must respond to .build_validations" unless adapter_class.respond_to?(:build_validations)
|
|
34
|
+
|
|
35
|
+
adapters[name.to_sym] = adapter_class
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Resolve an adapter from various input types.
|
|
39
|
+
#
|
|
40
|
+
# @param adapter [Symbol, Class, nil] The adapter identifier or class
|
|
41
|
+
# - nil: returns the default :active_model adapter
|
|
42
|
+
# - Symbol: looks up the adapter by name in the registry
|
|
43
|
+
# - Class: returns the class directly (assumes it implements the adapter interface)
|
|
44
|
+
# @return [Class] The adapter class
|
|
45
|
+
# @raise [ArgumentError] if the adapter symbol is not registered or type is invalid
|
|
46
|
+
def resolve(adapter)
|
|
47
|
+
case adapter
|
|
48
|
+
when nil
|
|
49
|
+
adapters[:active_model] || raise(ArgumentError, "No default adapter registered")
|
|
50
|
+
when Symbol
|
|
51
|
+
adapters[adapter] || raise(ArgumentError, "Unknown validation adapter: #{adapter.inspect}")
|
|
52
|
+
when Class
|
|
53
|
+
adapter
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "Invalid adapter type: #{adapter.class}. Expected Symbol, Class, or nil."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if an adapter is registered with the given name.
|
|
60
|
+
#
|
|
61
|
+
# @param name [Symbol, String] The adapter name to check
|
|
62
|
+
# @return [Boolean] true if the adapter is registered
|
|
63
|
+
def registered?(name)
|
|
64
|
+
adapters.key?(name.to_sym)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Reset the registry (useful for testing).
|
|
68
|
+
# Re-registers the default adapters after clearing.
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
def reset!
|
|
72
|
+
@adapters = nil
|
|
73
|
+
register_default_adapters
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Register the default validation adapters.
|
|
77
|
+
# This is called during gem initialization and after reset!
|
|
78
|
+
#
|
|
79
|
+
# @return [void]
|
|
80
|
+
def register_default_adapters
|
|
81
|
+
register(:active_model, ActiveModelAdapter)
|
|
82
|
+
register(:none, NoneAdapter)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|