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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/.yardopts +13 -0
  4. data/CHANGELOG.md +75 -0
  5. data/README.md +616 -35
  6. data/Rakefile +27 -0
  7. data/docs/.gitignore +1 -0
  8. data/docs/about.markdown +28 -8
  9. data/docs/getting-started.markdown +102 -0
  10. data/docs/index.markdown +51 -4
  11. data/docs/json_schema_compliance.md +55 -0
  12. data/docs/nested-models.markdown +216 -0
  13. data/docs/property-types.markdown +212 -0
  14. data/docs/schema-definition.markdown +180 -0
  15. data/lib/easy_talk/builders/base_builder.rb +4 -2
  16. data/lib/easy_talk/builders/composition_builder.rb +10 -12
  17. data/lib/easy_talk/builders/object_builder.rb +45 -30
  18. data/lib/easy_talk/builders/registry.rb +168 -0
  19. data/lib/easy_talk/builders/typed_array_builder.rb +15 -4
  20. data/lib/easy_talk/configuration.rb +31 -1
  21. data/lib/easy_talk/error_formatter/base.rb +100 -0
  22. data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
  23. data/lib/easy_talk/error_formatter/flat.rb +38 -0
  24. data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
  25. data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
  26. data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
  27. data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
  28. data/lib/easy_talk/error_formatter.rb +143 -0
  29. data/lib/easy_talk/errors.rb +2 -0
  30. data/lib/easy_talk/errors_helper.rb +63 -34
  31. data/lib/easy_talk/model.rb +123 -90
  32. data/lib/easy_talk/model_helper.rb +13 -0
  33. data/lib/easy_talk/naming_strategies.rb +20 -0
  34. data/lib/easy_talk/property.rb +16 -94
  35. data/lib/easy_talk/ref_helper.rb +27 -0
  36. data/lib/easy_talk/schema.rb +198 -0
  37. data/lib/easy_talk/schema_definition.rb +7 -1
  38. data/lib/easy_talk/schema_methods.rb +80 -0
  39. data/lib/easy_talk/tools/function_builder.rb +1 -1
  40. data/lib/easy_talk/type_introspection.rb +178 -0
  41. data/lib/easy_talk/types/base_composer.rb +2 -1
  42. data/lib/easy_talk/types/composer.rb +4 -0
  43. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +329 -0
  44. data/lib/easy_talk/validation_adapters/base.rb +144 -0
  45. data/lib/easy_talk/validation_adapters/none_adapter.rb +36 -0
  46. data/lib/easy_talk/validation_adapters/registry.rb +87 -0
  47. data/lib/easy_talk/validation_builder.rb +28 -309
  48. data/lib/easy_talk/version.rb +1 -1
  49. data/lib/easy_talk.rb +41 -0
  50. metadata +26 -4
  51. data/docs/404.html +0 -25
  52. data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
  53. data/easy_talk.gemspec +0 -39
@@ -16,6 +16,10 @@ module EasyTalk
16
16
  self.class.name
17
17
  end
18
18
 
19
+ def to_s
20
+ name.to_s
21
+ end
22
+
19
23
  # Represents a composition type that allows all of the specified types.
20
24
  class AllOf < Composer
21
25
  def self.name
@@ -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