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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -39
  3. data/.yardopts +13 -0
  4. data/CHANGELOG.md +164 -0
  5. data/README.md +442 -1529
  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 +169 -0
  12. data/docs/nested-models.markdown +216 -0
  13. data/docs/primitive-schema-rfc.md +894 -0
  14. data/docs/property-types.markdown +212 -0
  15. data/docs/schema-definition.markdown +180 -0
  16. data/lib/easy_talk/builders/base_builder.rb +6 -3
  17. data/lib/easy_talk/builders/boolean_builder.rb +2 -1
  18. data/lib/easy_talk/builders/collection_helpers.rb +4 -0
  19. data/lib/easy_talk/builders/composition_builder.rb +16 -13
  20. data/lib/easy_talk/builders/integer_builder.rb +2 -1
  21. data/lib/easy_talk/builders/null_builder.rb +4 -1
  22. data/lib/easy_talk/builders/number_builder.rb +4 -1
  23. data/lib/easy_talk/builders/object_builder.rb +109 -33
  24. data/lib/easy_talk/builders/registry.rb +182 -0
  25. data/lib/easy_talk/builders/string_builder.rb +3 -1
  26. data/lib/easy_talk/builders/temporal_builder.rb +7 -0
  27. data/lib/easy_talk/builders/tuple_builder.rb +89 -0
  28. data/lib/easy_talk/builders/typed_array_builder.rb +19 -6
  29. data/lib/easy_talk/builders/union_builder.rb +5 -1
  30. data/lib/easy_talk/configuration.rb +47 -2
  31. data/lib/easy_talk/error_formatter/base.rb +100 -0
  32. data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
  33. data/lib/easy_talk/error_formatter/flat.rb +38 -0
  34. data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
  35. data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
  36. data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
  37. data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
  38. data/lib/easy_talk/error_formatter.rb +143 -0
  39. data/lib/easy_talk/errors.rb +3 -0
  40. data/lib/easy_talk/errors_helper.rb +66 -34
  41. data/lib/easy_talk/json_schema_equality.rb +46 -0
  42. data/lib/easy_talk/keywords.rb +0 -1
  43. data/lib/easy_talk/model.rb +148 -89
  44. data/lib/easy_talk/model_helper.rb +17 -0
  45. data/lib/easy_talk/naming_strategies.rb +24 -0
  46. data/lib/easy_talk/property.rb +23 -94
  47. data/lib/easy_talk/ref_helper.rb +33 -0
  48. data/lib/easy_talk/schema.rb +199 -0
  49. data/lib/easy_talk/schema_definition.rb +57 -5
  50. data/lib/easy_talk/schema_methods.rb +111 -0
  51. data/lib/easy_talk/sorbet_extension.rb +1 -0
  52. data/lib/easy_talk/tools/function_builder.rb +1 -1
  53. data/lib/easy_talk/type_introspection.rb +222 -0
  54. data/lib/easy_talk/types/base_composer.rb +2 -1
  55. data/lib/easy_talk/types/composer.rb +4 -0
  56. data/lib/easy_talk/types/tuple.rb +77 -0
  57. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
  58. data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
  59. data/lib/easy_talk/validation_adapters/base.rb +156 -0
  60. data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
  61. data/lib/easy_talk/validation_adapters/registry.rb +87 -0
  62. data/lib/easy_talk/validation_builder.rb +29 -309
  63. data/lib/easy_talk/version.rb +1 -1
  64. data/lib/easy_talk.rb +42 -0
  65. metadata +38 -7
  66. data/docs/404.html +0 -25
  67. data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
  68. data/easy_talk.gemspec +0 -39
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'base_builder'
5
+ require_relative '../model_helper'
4
6
 
5
7
  module EasyTalk
6
8
  module Builders
@@ -19,7 +21,12 @@ module EasyTalk
19
21
  # Required by BaseBuilder: recognized schema options for "object" types
20
22
  VALID_OPTIONS = {
21
23
  properties: { type: T::Hash[T.any(Symbol, String), T.untyped], key: :properties },
22
- additional_properties: { type: T::Boolean, key: :additionalProperties },
24
+ additional_properties: { type: T.any(T::Boolean, Class, T::Hash[Symbol, T.untyped]), key: :additionalProperties },
25
+ pattern_properties: { type: T::Hash[String, T.untyped], key: :patternProperties },
26
+ min_properties: { type: Integer, key: :minProperties },
27
+ max_properties: { type: Integer, key: :maxProperties },
28
+ dependencies: { type: T::Hash[String, T.any(T::Array[String], T::Hash[String, T.untyped])], key: :dependencies },
29
+ dependent_required: { type: T::Hash[String, T::Array[String]], key: :dependentRequired },
23
30
  subschemas: { type: T::Array[T.untyped], key: :subschemas },
24
31
  required: { type: T::Array[T.any(Symbol, String)], key: :required },
25
32
  defs: { type: T.untyped, key: :$defs },
@@ -33,8 +40,8 @@ module EasyTalk
33
40
  def initialize(schema_definition)
34
41
  # Keep a reference to the original schema definition
35
42
  @schema_definition = schema_definition
36
- # Duplicate the raw schema hash so we can mutate it safely
37
- @original_schema = schema_definition.schema.dup
43
+ # Deep duplicate the raw schema hash so we can mutate it safely
44
+ @original_schema = deep_dup(schema_definition.schema)
38
45
 
39
46
  # We'll collect required property names in this Set
40
47
  @required_properties = Set.new
@@ -54,8 +61,34 @@ module EasyTalk
54
61
  )
55
62
  end
56
63
 
64
+ # Override build to add additionalProperties after BaseBuilder validation
65
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
66
+ def build
67
+ result = super
68
+ process_additional_properties(result)
69
+ result
70
+ end
71
+
57
72
  private
58
73
 
74
+ ##
75
+ # Deep duplicates a hash, including nested hashes.
76
+ # This prevents mutations from leaking back to the original schema.
77
+ #
78
+ def deep_dup(obj)
79
+ case obj
80
+ when Hash
81
+ obj.transform_values { |v| deep_dup(v) }
82
+ when Array
83
+ obj.map { |v| deep_dup(v) }
84
+ when Class, Module
85
+ # Don't duplicate Class or Module objects - they represent types
86
+ obj
87
+ else
88
+ obj.duplicable? ? obj.dup : obj
89
+ end
90
+ end
91
+
59
92
  ##
60
93
  # Main aggregator: merges the top-level schema keys (like :properties, :subschemas)
61
94
  # into a single hash that we'll feed to BaseBuilder.
@@ -80,8 +113,9 @@ module EasyTalk
80
113
  # Populate the final "required" array from @required_properties
81
114
  merged[:required] = @required_properties.to_a if @required_properties.any?
82
115
 
83
- # Add additionalProperties: false by default if not explicitly set
84
- merged[:additional_properties] = false unless merged.key?(:additional_properties)
116
+ # Process additionalProperties separately (don't let BaseBuilder validate it)
117
+ # Extract the value, process it, and we'll add it back after BaseBuilder runs
118
+ @additional_properties_value = merged.delete(:additional_properties)
85
119
 
86
120
  # Prune empty or nil values so we don't produce stuff like "properties": {} unnecessarily
87
121
  merged.reject! { |_k, v| v.nil? || v == {} || v == [] }
@@ -89,6 +123,52 @@ module EasyTalk
89
123
  merged
90
124
  end
91
125
 
126
+ ##
127
+ # Process additionalProperties to handle schema objects.
128
+ # Converts type classes or constraint hashes into proper JSON Schema.
129
+ # Called from build() method with the final schema hash.
130
+ #
131
+ def process_additional_properties(schema_hash)
132
+ value = @additional_properties_value
133
+
134
+ # If not set, use config default
135
+ if value.nil?
136
+ schema_hash[:additionalProperties] = EasyTalk.configuration.default_additional_properties
137
+ return
138
+ end
139
+
140
+ # Boolean: pass through as-is
141
+ if value.is_a?(TrueClass) || value.is_a?(FalseClass)
142
+ schema_hash[:additionalProperties] = value
143
+ return
144
+ end
145
+
146
+ # Class type: build schema
147
+ if value.is_a?(Class)
148
+ schema_hash[:additionalProperties] = build_additional_properties_schema(value, {})
149
+ return
150
+ end
151
+
152
+ # Hash with type + constraints: build schema with constraints
153
+ return unless value.is_a?(Hash)
154
+
155
+ type = value[:type] || value['type']
156
+ constraints = value.except(:type, 'type')
157
+ schema_hash[:additionalProperties] = build_additional_properties_schema(type, constraints)
158
+ end
159
+
160
+ ##
161
+ # Builds a JSON Schema for additionalProperties from a type and constraints.
162
+ # Uses the Property builder to generate the schema.
163
+ #
164
+ def build_additional_properties_schema(type, constraints)
165
+ return {} unless type
166
+
167
+ # Use Property builder to generate schema for the type
168
+ property = EasyTalk::Property.new(:_additional, type, constraints)
169
+ property.as_json
170
+ end
171
+
92
172
  ##
93
173
  # Given the property definitions hash, produce a new hash of
94
174
  # { property_name => [Property or nested schema builder result] }.
@@ -99,16 +179,18 @@ module EasyTalk
99
179
  # Cache with a key based on property name and its full configuration
100
180
  @properties_cache ||= {}
101
181
 
102
- properties_hash.each_with_object({}) do |(prop_name, prop_options), result|
103
- cache_key = [prop_name, prop_options].hash
182
+ properties_hash.each_with_object({}) do |(original_name, prop_options), result|
183
+ # Use :as constraint for property name without mutating original constraints
184
+ property_name = (prop_options[:constraints][:as] || original_name).to_sym
185
+ cache_key = [property_name, prop_options].hash
104
186
 
105
187
  # Use cache if the exact property and configuration have been processed before
106
188
  @properties_cache[cache_key] ||= begin
107
- mark_required_unless_optional(prop_name, prop_options)
108
- build_property(prop_name, prop_options)
189
+ mark_required_unless_optional(property_name, prop_options)
190
+ build_property(property_name, prop_options)
109
191
  end
110
192
 
111
- result[prop_name] = @properties_cache[cache_key]
193
+ result[property_name] = @properties_cache[cache_key]
112
194
  end
113
195
  end
114
196
 
@@ -145,8 +227,8 @@ module EasyTalk
145
227
 
146
228
  # Memoize so we only build each property once
147
229
  @property_cache[prop_name] ||= begin
148
- # Remove optional constraints from the property
149
- constraints = prop_options[:constraints].except(:optional)
230
+ # Remove internal constraints that shouldn't be passed to Property
231
+ constraints = prop_options[:constraints].except(:optional, :as)
150
232
  prop_type = prop_options[:type]
151
233
 
152
234
  # Track models that will use $ref for later $defs generation
@@ -165,10 +247,11 @@ module EasyTalk
165
247
  # Check if this type should use $ref
166
248
  if should_collect_ref?(prop_type, constraints)
167
249
  @ref_models.add(prop_type)
250
+ elsif prop_type.is_a?(EasyTalk::Types::Composer)
251
+ collect_ref_models(prop_type.items, constraints)
168
252
  # Handle typed arrays with EasyTalk model items
169
- elsif typed_array_with_model?(prop_type)
170
- inner_type = prop_type.type.raw_type
171
- @ref_models.add(inner_type) if should_collect_ref?(inner_type, constraints)
253
+ elsif typed_array?(prop_type)
254
+ extract_inner_types(prop_type).each { |inner_type| collect_ref_models(inner_type, constraints) }
172
255
  # Handle nilable types
173
256
  elsif nilable_with_model?(prop_type)
174
257
  actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
@@ -180,7 +263,7 @@ module EasyTalk
180
263
  # Determines if a type should be collected for $ref based on config and constraints.
181
264
  #
182
265
  def should_collect_ref?(check_type, constraints)
183
- return false unless easytalk_model?(check_type)
266
+ return false unless ModelHelper.easytalk_model?(check_type)
184
267
 
185
268
  # Per-property constraint takes precedence
186
269
  return constraints[:ref] if constraints.key?(:ref)
@@ -189,25 +272,18 @@ module EasyTalk
189
272
  EasyTalk.configuration.use_refs
190
273
  end
191
274
 
192
- ##
193
- # Checks if a type is an EasyTalk model.
194
- #
195
- def easytalk_model?(check_type)
196
- check_type.is_a?(Class) &&
197
- check_type.respond_to?(:schema) &&
198
- check_type.respond_to?(:ref_template) &&
199
- defined?(EasyTalk::Model) &&
200
- check_type.include?(EasyTalk::Model)
275
+ def typed_array?(prop_type)
276
+ prop_type.is_a?(T::Types::TypedArray)
201
277
  end
202
278
 
203
- ##
204
- # Checks if type is a typed array containing an EasyTalk model.
205
- #
206
- def typed_array_with_model?(prop_type)
207
- return false unless prop_type.is_a?(T::Types::TypedArray)
279
+ def extract_inner_types(prop_type)
280
+ return [] unless typed_array?(prop_type)
208
281
 
209
- inner_type = prop_type.type.raw_type
210
- easytalk_model?(inner_type)
282
+ if prop_type.type.is_a?(EasyTalk::Types::Composer)
283
+ prop_type.type.items
284
+ else
285
+ [prop_type.type.raw_type]
286
+ end
211
287
  end
212
288
 
213
289
  ##
@@ -219,7 +295,7 @@ module EasyTalk
219
295
  return false unless prop_type.types.any? { |t| t.raw_type == NilClass }
220
296
 
221
297
  actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
222
- easytalk_model?(actual_type)
298
+ ModelHelper.easytalk_model?(actual_type)
223
299
  end
224
300
 
225
301
  ##
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module EasyTalk
5
+ module Builders
6
+ # Registry for type-to-builder mappings.
7
+ #
8
+ # The registry allows custom types to be registered with their corresponding
9
+ # schema builder classes at runtime, without modifying the gem's source code.
10
+ #
11
+ # Custom registrations take priority over built-in types, allowing users to
12
+ # override default behavior when needed.
13
+ #
14
+ # @example Registering a custom type
15
+ # EasyTalk::Builders::Registry.register(Money, MoneySchemaBuilder)
16
+ #
17
+ # @example Registering a collection type
18
+ # EasyTalk::Builders::Registry.register(CustomArray, CustomArrayBuilder, collection: true)
19
+ #
20
+ # @example Resolving a builder for a type
21
+ # builder_class, is_collection = EasyTalk::Builders::Registry.resolve(Money)
22
+ # builder_class.new(name, constraints).build
23
+ #
24
+ class Registry
25
+ class << self
26
+ extend T::Sig
27
+
28
+ # Get the hash of registered type builders.
29
+ #
30
+ # @return [Hash{String => Hash}] The registered builders with metadata
31
+ sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) }
32
+ def registry
33
+ @registry ||= {}
34
+ end
35
+
36
+ # Register a type with its corresponding builder class.
37
+ #
38
+ # @param type_key [Class, String, Symbol] The type identifier
39
+ # @param builder_class [Class] The builder class (must respond to .new)
40
+ # @param collection [Boolean] Whether this is a collection type builder
41
+ # Collection builders receive (name, type, constraints) instead of (name, constraints)
42
+ # @raise [ArgumentError] if the builder does not respond to .new
43
+ # @return [void]
44
+ #
45
+ # @example Register a simple type
46
+ # Registry.register(Money, MoneySchemaBuilder)
47
+ #
48
+ # @example Register a collection type
49
+ # Registry.register(CustomArray, CustomArrayBuilder, collection: true)
50
+ sig { params(type_key: T.any(T::Class[T.anything], String, Symbol), builder_class: T.untyped, collection: T::Boolean).void }
51
+ def register(type_key, builder_class, collection: false)
52
+ raise ArgumentError, 'Builder must respond to .new' unless builder_class.respond_to?(:new)
53
+
54
+ key = normalize_key(type_key)
55
+ registry[key] = { builder: builder_class, collection: collection }
56
+ end
57
+
58
+ # Resolve a builder for the given type.
59
+ #
60
+ # Resolution order:
61
+ # 1. Check type.class.name (e.g., "T::Types::TypedArray")
62
+ # 2. Check type.name if type responds to :name (e.g., "String")
63
+ # 3. Check type itself if it's a Class (e.g., String class)
64
+ #
65
+ # @param type [Object] The type to find a builder for
66
+ # @return [Array(Class, Boolean), nil] A tuple of [builder_class, is_collection] or nil if not found
67
+ #
68
+ # @example
69
+ # builder_class, is_collection = Registry.resolve(String)
70
+ # # => [StringBuilder, false]
71
+ sig { params(type: T.untyped).returns(T.nilable(T::Array[T.untyped])) }
72
+ def resolve(type)
73
+ entry = find_registration(type)
74
+ return nil unless entry
75
+
76
+ [entry[:builder], entry[:collection]]
77
+ end
78
+
79
+ # Check if a type is registered.
80
+ #
81
+ # @param type_key [Class, String, Symbol] The type to check
82
+ # @return [Boolean] true if the type is registered
83
+ sig { params(type_key: T.any(T::Class[T.anything], String, Symbol)).returns(T::Boolean) }
84
+ def registered?(type_key)
85
+ registry.key?(normalize_key(type_key))
86
+ end
87
+
88
+ # Unregister a type.
89
+ #
90
+ # @param type_key [Class, String, Symbol] The type to unregister
91
+ # @return [Hash, nil] The removed registration or nil if not found
92
+ sig { params(type_key: T.any(T::Class[T.anything], String, Symbol)).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
93
+ def unregister(type_key)
94
+ registry.delete(normalize_key(type_key))
95
+ end
96
+
97
+ # Get a list of all registered type keys.
98
+ #
99
+ # @return [Array<String>] The registered type keys
100
+ sig { returns(T::Array[String]) }
101
+ def registered_types
102
+ registry.keys
103
+ end
104
+
105
+ # Reset the registry to empty state and re-register built-in types.
106
+ #
107
+ # @return [void]
108
+ sig { void }
109
+ def reset!
110
+ @registry = nil
111
+ register_built_in_types
112
+ end
113
+
114
+ # Register all built-in type builders.
115
+ # This is called during gem initialization and after reset!
116
+ #
117
+ # @return [void]
118
+ sig { void }
119
+ def register_built_in_types
120
+ register(String, Builders::StringBuilder)
121
+ register(Integer, Builders::IntegerBuilder)
122
+ register(Float, Builders::NumberBuilder)
123
+ register(BigDecimal, Builders::NumberBuilder)
124
+ register('T::Boolean', Builders::BooleanBuilder)
125
+ register(TrueClass, Builders::BooleanBuilder)
126
+ register(NilClass, Builders::NullBuilder)
127
+ register(Date, Builders::TemporalBuilder::DateBuilder)
128
+ register(DateTime, Builders::TemporalBuilder::DatetimeBuilder)
129
+ register(Time, Builders::TemporalBuilder::TimeBuilder)
130
+ register('allOf', Builders::CompositionBuilder::AllOfBuilder, collection: true)
131
+ register('anyOf', Builders::CompositionBuilder::AnyOfBuilder, collection: true)
132
+ register('oneOf', Builders::CompositionBuilder::OneOfBuilder, collection: true)
133
+ register('EasyTalk::Types::Tuple', Builders::TupleBuilder, collection: true)
134
+ register('T::Types::TypedArray', Builders::TypedArrayBuilder, collection: true)
135
+ register('T::Types::Union', Builders::UnionBuilder, collection: true)
136
+ end
137
+
138
+ private
139
+
140
+ # Normalize a type key to a canonical string form.
141
+ #
142
+ # @param type_key [Class, String, Symbol] The type key to normalize
143
+ # @return [String] The normalized key
144
+ sig { params(type_key: T.any(T::Class[T.anything], String, Symbol)).returns(String) }
145
+ def normalize_key(type_key)
146
+ case type_key
147
+ when Class
148
+ type_key.name.to_s
149
+ when Symbol
150
+ type_key.to_s
151
+ else
152
+ type_key.to_s
153
+ end
154
+ end
155
+
156
+ # Find a registration for the given type.
157
+ #
158
+ # Tries multiple resolution strategies in order.
159
+ #
160
+ # @param type [Object] The type to find
161
+ # @return [Hash, nil] The registration entry or nil
162
+ sig { params(type: T.untyped).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
163
+ def find_registration(type)
164
+ # Strategy 1: Check type.class.name (for Sorbet types like T::Types::TypedArray)
165
+ class_name = type.class.name.to_s
166
+ return registry[class_name] if registry.key?(class_name)
167
+
168
+ # Strategy 2: Check type.name (for types that respond to :name, like "String")
169
+ if type.respond_to?(:name)
170
+ type_name = type.name.to_s
171
+ return registry[type_name] if registry.key?(type_name)
172
+ end
173
+
174
+ # Strategy 3: Check the type itself if it's a Class
175
+ return registry[type.name.to_s] if type.is_a?(Class) && registry.key?(type.name.to_s)
176
+
177
+ nil
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'base_builder'
4
5
  require 'js_regex' # Compile the ruby regex to JS regex
@@ -20,11 +21,12 @@ module EasyTalk
20
21
  default: { type: String, key: :default }
21
22
  }.freeze
22
23
 
23
- sig { params(name: Symbol, constraints: Hash).void }
24
+ sig { params(name: Symbol, constraints: T::Hash[Symbol, T.untyped]).void }
24
25
  def initialize(name, constraints = {})
25
26
  super(name, { type: 'string' }, constraints, VALID_OPTIONS)
26
27
  end
27
28
 
29
+ sig { returns(T::Hash[Symbol, T.untyped]) }
28
30
  def build
29
31
  super.tap do |schema|
30
32
  pattern = schema[:pattern]
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'string_builder'
4
5
 
@@ -6,11 +7,14 @@ module EasyTalk
6
7
  module Builders
7
8
  # Builder class for temporal properties (date, datetime, time).
8
9
  class TemporalBuilder < StringBuilder
10
+ extend T::Sig
11
+
9
12
  # Initializes a new instance of the TemporalBuilder class.
10
13
  #
11
14
  # @param property_name [Symbol] The name of the property.
12
15
  # @param options [Hash] The options for the builder.
13
16
  # @param format [String] The format of the temporal property (date, date-time, time).
17
+ sig { params(property_name: Symbol, options: T::Hash[Symbol, T.untyped], format: T.nilable(String)).void }
14
18
  def initialize(property_name, options = {}, format = nil)
15
19
  super(property_name, options)
16
20
  @format = format
@@ -26,6 +30,7 @@ module EasyTalk
26
30
 
27
31
  # Builder class for date properties.
28
32
  class DateBuilder < TemporalBuilder
33
+ sig { params(property_name: Symbol, options: T::Hash[Symbol, T.untyped]).void }
29
34
  def initialize(property_name, options = {})
30
35
  super(property_name, options, 'date')
31
36
  end
@@ -33,6 +38,7 @@ module EasyTalk
33
38
 
34
39
  # Builder class for datetime properties.
35
40
  class DatetimeBuilder < TemporalBuilder
41
+ sig { params(property_name: Symbol, options: T::Hash[Symbol, T.untyped]).void }
36
42
  def initialize(property_name, options = {})
37
43
  super(property_name, options, 'date-time')
38
44
  end
@@ -40,6 +46,7 @@ module EasyTalk
40
46
 
41
47
  # Builder class for time properties.
42
48
  class TimeBuilder < TemporalBuilder
49
+ sig { params(property_name: Symbol, options: T::Hash[Symbol, T.untyped]).void }
43
50
  def initialize(property_name, options = {})
44
51
  super(property_name, options, 'time')
45
52
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module EasyTalk
5
+ module Builders
6
+ # Builder class for tuple array properties (T::Tuple[Type1, Type2, ...]).
7
+ #
8
+ # Tuples are arrays with positional type validation where each index
9
+ # has a specific expected type.
10
+ #
11
+ # @example Basic tuple
12
+ # property :coordinates, T::Tuple[Float, Float]
13
+ #
14
+ # @example Tuple with additional items constraint
15
+ # property :record, T::Tuple[String, Integer], additional_items: false
16
+ #
17
+ class TupleBuilder < BaseBuilder
18
+ extend T::Sig
19
+
20
+ # NOTE: additional_items is handled separately in build() since it can be a type
21
+ VALID_OPTIONS = {
22
+ min_items: { type: Integer, key: :minItems },
23
+ max_items: { type: Integer, key: :maxItems },
24
+ unique_items: { type: T::Boolean, key: :uniqueItems }
25
+ }.freeze
26
+
27
+ attr_reader :type
28
+
29
+ sig { params(name: Symbol, type: Types::Tuple, constraints: T::Hash[Symbol, T.untyped]).void }
30
+ def initialize(name, type, constraints = {})
31
+ @name = name
32
+ @type = type
33
+ # Work on a copy to avoid mutating the original constraints hash
34
+ local_constraints = constraints.dup
35
+ # Extract additional_items before passing to super (it's handled separately in build)
36
+ @additional_items_constraint = local_constraints.delete(:additional_items)
37
+ super(name, { type: 'array' }, local_constraints, VALID_OPTIONS)
38
+ end
39
+
40
+ sig { returns(T::Boolean) }
41
+ def self.collection_type?
42
+ true
43
+ end
44
+
45
+ # Builds the tuple schema with positional items.
46
+ #
47
+ # @return [Hash] The built schema.
48
+ sig { returns(T::Hash[Symbol, T.untyped]) }
49
+ def build
50
+ schema = super
51
+
52
+ # Build items array from tuple types
53
+ schema[:items] = build_items
54
+
55
+ # Handle additional_items constraint
56
+ schema[:additionalItems] = build_additional_items_schema(@additional_items_constraint) unless @additional_items_constraint.nil?
57
+
58
+ schema
59
+ end
60
+
61
+ private
62
+
63
+ # Builds the items array from tuple types.
64
+ #
65
+ # @return [Array<Hash>] Array of schemas for each position
66
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
67
+ def build_items
68
+ type.types.map.with_index do |item_type, index|
69
+ Property.new(:"#{@name}_item_#{index}", item_type, {}).build
70
+ end
71
+ end
72
+
73
+ # Builds the additionalItems schema value.
74
+ #
75
+ # @param value [Boolean, Class] The additional_items constraint
76
+ # @return [Boolean, Hash] The schema value for additionalItems
77
+ sig { params(value: T.untyped).returns(T.any(T::Boolean, T::Hash[Symbol, T.untyped])) }
78
+ def build_additional_items_schema(value)
79
+ case value
80
+ when true, false
81
+ value
82
+ else
83
+ # It's a type - build a schema for it
84
+ Property.new(:"#{@name}_additional", value, {}).build
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'collection_helpers'
4
5
 
5
6
  module EasyTalk
6
7
  module Builders
7
- # Builder class for array properties.
8
+ # Builder class for homogeneous array properties (T::Array[Type]).
8
9
  class TypedArrayBuilder < BaseBuilder
9
10
  extend CollectionHelpers
10
11
  extend T::Sig
@@ -20,16 +21,23 @@ module EasyTalk
20
21
 
21
22
  attr_reader :type
22
23
 
23
- sig { params(name: Symbol, type: T.untyped, constraints: Hash).void }
24
+ sig { params(name: Symbol, type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).void }
24
25
  def initialize(name, type, constraints = {})
25
26
  @name = name
26
27
  @type = type
28
+ @valid_options = deep_dup_options(VALID_OPTIONS)
27
29
  update_option_types
28
- super(name, { type: 'array' }, constraints, VALID_OPTIONS)
30
+ super(name, { type: 'array' }, constraints, @valid_options)
29
31
  end
30
32
 
31
33
  private
32
34
 
35
+ # Creates a copy of options with duped nested hashes to avoid mutating the constant.
36
+ sig { params(options: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
37
+ def deep_dup_options(options)
38
+ options.transform_values(&:dup)
39
+ end
40
+
33
41
  # Modifies the schema to include the `items` property.
34
42
  #
35
43
  # @return [Hash] The built schema.
@@ -42,17 +50,22 @@ module EasyTalk
42
50
  end
43
51
  end
44
52
 
53
+ sig { returns(T.untyped) }
45
54
  def inner_type
46
55
  return unless type.is_a?(T::Types::TypedArray)
47
56
 
48
- type.type.raw_type
57
+ if type.type.is_a?(EasyTalk::Types::Composer)
58
+ type.type
59
+ else
60
+ type.type.raw_type
61
+ end
49
62
  end
50
63
 
51
64
  sig { void }
52
65
  # Updates the option types for the array builder.
53
66
  def update_option_types
54
- VALID_OPTIONS[:enum][:type] = T::Array[inner_type]
55
- VALID_OPTIONS[:const][:type] = T::Array[inner_type]
67
+ @valid_options[:enum][:type] = T::Array[inner_type]
68
+ @valid_options[:const][:type] = T::Array[inner_type]
56
69
  end
57
70
  end
58
71
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'collection_helpers'
4
5
 
@@ -9,7 +10,7 @@ module EasyTalk
9
10
  extend CollectionHelpers
10
11
  extend T::Sig
11
12
 
12
- sig { params(name: Symbol, type: T.untyped, constraints: T.untyped).void }
13
+ sig { params(name: Symbol, type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).void }
13
14
  def initialize(name, type, constraints)
14
15
  @name = name
15
16
  @type = type
@@ -17,18 +18,21 @@ module EasyTalk
17
18
  @context = {}
18
19
  end
19
20
 
21
+ sig { returns(T::Hash[Symbol, T.untyped]) }
20
22
  def build
21
23
  @context[@name] = {
22
24
  'anyOf' => schemas
23
25
  }
24
26
  end
25
27
 
28
+ sig { returns(T::Array[T.untyped]) }
26
29
  def schemas
27
30
  types.map do |type|
28
31
  Property.new(@name, type, @constraints).build
29
32
  end
30
33
  end
31
34
 
35
+ sig { returns(T.untyped) }
32
36
  def types
33
37
  @type.types
34
38
  end