easy_talk 3.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -43
  3. data/CHANGELOG.md +89 -0
  4. data/README.md +447 -2115
  5. data/docs/json_schema_compliance.md +140 -26
  6. data/docs/primitive-schema-rfc.md +894 -0
  7. data/lib/easy_talk/builders/base_builder.rb +2 -1
  8. data/lib/easy_talk/builders/boolean_builder.rb +2 -1
  9. data/lib/easy_talk/builders/collection_helpers.rb +4 -0
  10. data/lib/easy_talk/builders/composition_builder.rb +7 -2
  11. data/lib/easy_talk/builders/integer_builder.rb +2 -1
  12. data/lib/easy_talk/builders/null_builder.rb +4 -1
  13. data/lib/easy_talk/builders/number_builder.rb +4 -1
  14. data/lib/easy_talk/builders/object_builder.rb +64 -3
  15. data/lib/easy_talk/builders/registry.rb +15 -1
  16. data/lib/easy_talk/builders/string_builder.rb +3 -1
  17. data/lib/easy_talk/builders/temporal_builder.rb +7 -0
  18. data/lib/easy_talk/builders/tuple_builder.rb +89 -0
  19. data/lib/easy_talk/builders/typed_array_builder.rb +4 -2
  20. data/lib/easy_talk/builders/union_builder.rb +5 -1
  21. data/lib/easy_talk/configuration.rb +17 -2
  22. data/lib/easy_talk/errors.rb +1 -0
  23. data/lib/easy_talk/errors_helper.rb +3 -0
  24. data/lib/easy_talk/json_schema_equality.rb +46 -0
  25. data/lib/easy_talk/keywords.rb +0 -1
  26. data/lib/easy_talk/model.rb +27 -1
  27. data/lib/easy_talk/model_helper.rb +4 -0
  28. data/lib/easy_talk/naming_strategies.rb +4 -0
  29. data/lib/easy_talk/property.rb +7 -0
  30. data/lib/easy_talk/ref_helper.rb +6 -0
  31. data/lib/easy_talk/schema.rb +1 -0
  32. data/lib/easy_talk/schema_definition.rb +52 -6
  33. data/lib/easy_talk/schema_methods.rb +36 -5
  34. data/lib/easy_talk/sorbet_extension.rb +1 -0
  35. data/lib/easy_talk/type_introspection.rb +45 -1
  36. data/lib/easy_talk/types/tuple.rb +77 -0
  37. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +350 -62
  38. data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
  39. data/lib/easy_talk/validation_adapters/base.rb +12 -0
  40. data/lib/easy_talk/validation_adapters/none_adapter.rb +9 -0
  41. data/lib/easy_talk/validation_builder.rb +1 -0
  42. data/lib/easy_talk/version.rb +1 -1
  43. data/lib/easy_talk.rb +1 -0
  44. metadata +13 -4
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ # Implements JSON Schema equality semantics for comparing values.
5
+ #
6
+ # Per JSON Schema specification:
7
+ # - Objects with same keys/values in different order are equal
8
+ # - Numbers that are mathematically equal are equal (1 == 1.0)
9
+ # - Type matters for non-numbers (true != 1, false != 0)
10
+ module JsonSchemaEquality
11
+ # Maximum nesting depth to prevent SystemStackError on deeply nested structures
12
+ MAX_DEPTH = 100
13
+
14
+ class << self
15
+ # Check if an array contains duplicate values using JSON Schema equality.
16
+ # Uses a Set for O(n) performance and early termination on first duplicate.
17
+ def duplicates?(array)
18
+ seen = Set.new
19
+ array.any? { |item| !seen.add?(normalize(item)) }
20
+ end
21
+
22
+ # Normalize a value for JSON Schema equality comparison
23
+ # @param value [Object] The value to normalize
24
+ # @param depth [Integer] Current recursion depth (for stack overflow protection)
25
+ # @raise [ArgumentError] if nesting depth exceeds MAX_DEPTH
26
+ def normalize(value, depth = 0)
27
+ raise ArgumentError, "Nesting depth exceeds maximum of #{MAX_DEPTH}" if depth > MAX_DEPTH
28
+
29
+ case value
30
+ when Hash
31
+ # Convert keys to strings before sorting to handle mixed key types (Symbol/String)
32
+ # and ensure consistent, order-independent comparison (JSON only has string keys)
33
+ value.map { |k, v| [k.to_s, normalize(v, depth + 1)] }.sort
34
+ when Array
35
+ value.map { |item| normalize(item, depth + 1) }
36
+ when Integer, Float
37
+ # Normalize numbers to a canonical form for mathematical equality
38
+ value.to_r
39
+ else
40
+ # Booleans, strings, nil - preserve as-is (type matters)
41
+ value
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -7,7 +7,6 @@ module EasyTalk
7
7
  description
8
8
  type
9
9
  title
10
- property
11
10
  required
12
11
  items
13
12
  additional_items
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require 'json'
4
5
  require 'active_support'
@@ -220,6 +221,9 @@ module EasyTalk
220
221
  # Track which properties have had validations applied
221
222
  @validated_properties ||= Set.new
222
223
 
224
+ # Initialize mutex eagerly for thread-safe schema-level validation application
225
+ @schema_level_validation_lock = Mutex.new
226
+
223
227
  # Apply validations using the adapter system
224
228
  apply_schema_validations
225
229
 
@@ -273,6 +277,26 @@ module EasyTalk
273
277
  adapter.build_validations(self, prop_name, prop_def[:type], prop_def[:constraints])
274
278
  @validated_properties.add(prop_name)
275
279
  end
280
+
281
+ # Apply schema-level validations (min_properties, max_properties, dependent_required)
282
+ apply_schema_level_validations(adapter)
283
+ end
284
+
285
+ # Apply schema-level validations for object-level constraints.
286
+ # Uses double-checked locking for thread safety.
287
+ # The mutex is initialized eagerly in define_schema.
288
+ #
289
+ # @param adapter [Class] The validation adapter class
290
+ # @return [void]
291
+ def apply_schema_level_validations(adapter)
292
+ return if @schema_level_validations_applied
293
+
294
+ @schema_level_validation_lock.synchronize do
295
+ return if @schema_level_validations_applied
296
+
297
+ adapter.build_schema_validations(self, @schema_definition.schema)
298
+ @schema_level_validations_applied = true
299
+ end
276
300
  end
277
301
 
278
302
  public
@@ -285,7 +309,9 @@ module EasyTalk
285
309
  end
286
310
 
287
311
  def additional_properties_allowed?
288
- @schema_definition&.schema&.fetch(:additional_properties, false)
312
+ ap = @schema_definition&.schema&.fetch(:additional_properties, false)
313
+ # Allow if true, or if it's a schema object (Class or Hash with type)
314
+ ap == true || ap.is_a?(Class) || ap.is_a?(Hash)
289
315
  end
290
316
 
291
317
  # Returns the property names defined in the schema
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  module EasyTalk
4
5
  module ModelHelper
6
+ extend T::Sig
7
+
8
+ sig { params(type: T.untyped).returns(T::Boolean) }
5
9
  def self.easytalk_model?(type)
6
10
  type.is_a?(Class) &&
7
11
  type.respond_to?(:schema) &&
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  module EasyTalk
4
5
  module NamingStrategies
6
+ extend T::Sig
7
+
5
8
  IDENTITY = lambda(&:to_sym)
6
9
  SNAKE_CASE = ->(property_name) { property_name.to_s.underscore.to_sym }
7
10
  CAMEL_CASE = ->(property_name) { property_name.to_s.tr('-', '_').camelize(:lower).to_sym }
8
11
  PASCAL_CASE = ->(property_name) { property_name.to_s.tr('-', '_').camelize.to_sym }
9
12
 
13
+ sig { params(strategy: T.any(Symbol, T.proc.params(arg0: T.untyped).returns(Symbol))).returns(T.proc.params(arg0: T.untyped).returns(Symbol)) }
10
14
  def self.derive_strategy(strategy)
11
15
  if strategy.is_a?(Symbol)
12
16
  "EasyTalk::NamingStrategies::#{strategy.to_s.upcase}".constantize
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require 'json'
4
5
  require_relative 'builders/integer_builder'
@@ -9,6 +10,7 @@ require_relative 'builders/string_builder'
9
10
  require_relative 'builders/temporal_builder'
10
11
  require_relative 'builders/composition_builder'
11
12
  require_relative 'builders/typed_array_builder'
13
+ require_relative 'builders/tuple_builder'
12
14
  require_relative 'builders/union_builder'
13
15
 
14
16
  # EasyTalk module provides a DSL for building JSON Schema definitions.
@@ -99,6 +101,7 @@ module EasyTalk
99
101
  # @example Nested schema with $ref
100
102
  # property = Property.new(:shipping_address, Address, ref: true)
101
103
  # property.build # => {"$ref"=>"#/$defs/Address", ...constraints}
104
+ sig { returns(T::Hash[Symbol, T.untyped]) }
102
105
  def build
103
106
  if nilable_type?
104
107
  build_nilable_schema
@@ -128,6 +131,7 @@ module EasyTalk
128
131
  #
129
132
  # @see #build
130
133
  # @see https://ruby-doc.org/stdlib-2.7.2/libdoc/json/rdoc/JSON.html#as_json-method
134
+ sig { params(_args: T.untyped).returns(T.untyped) }
131
135
  def as_json(*_args)
132
136
  build.as_json
133
137
  end
@@ -142,6 +146,7 @@ module EasyTalk
142
146
  # @return [Array(Class, Boolean), nil] A tuple of [builder_class, is_collection] or nil if none matches
143
147
  # @api private
144
148
  # @see Builders::Registry.resolve
149
+ sig { returns(T.nilable(T::Array[T.untyped])) }
145
150
  def find_builder_for_type
146
151
  Builders::Registry.resolve(type)
147
152
  end
@@ -153,6 +158,7 @@ module EasyTalk
153
158
  #
154
159
  # @return [Boolean] true if the type is nilable, false otherwise
155
160
  # @api private
161
+ sig { returns(T::Boolean) }
156
162
  def nilable_type?
157
163
  return false unless type.respond_to?(:types)
158
164
  return false unless type.types.all? { |t| t.respond_to?(:raw_type) }
@@ -167,6 +173,7 @@ module EasyTalk
167
173
  # @example
168
174
  # # For a T.nilable(String) type:
169
175
  # {"type"=>["string", "null"]}
176
+ sig { returns(T::Hash[Symbol, T.untyped]) }
170
177
  def build_nilable_schema
171
178
  # Extract the non-nil type from the Union
172
179
  actual_type = T::Utils::Nilable.get_underlying_type(type)
@@ -1,13 +1,18 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'model_helper'
4
5
 
5
6
  module EasyTalk
6
7
  module RefHelper
8
+ extend T::Sig
9
+
10
+ sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
7
11
  def self.should_use_ref?(type, constraints)
8
12
  ModelHelper.easytalk_model?(type) && should_use_ref_for_type?(type, constraints)
9
13
  end
10
14
 
15
+ sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
11
16
  def self.should_use_ref_for_type?(type, constraints)
12
17
  return false unless ModelHelper.easytalk_model?(type)
13
18
 
@@ -19,6 +24,7 @@ module EasyTalk
19
24
  end
20
25
  end
21
26
 
27
+ sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
22
28
  def self.build_ref_schema(type, constraints)
23
29
  # Remove ref and optional from constraints as they're not JSON Schema keywords
24
30
  { '$ref': type.ref_template }.merge(constraints.except(:ref, :optional))
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require 'json'
4
5
  require 'active_support'
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'keywords'
4
5
  require_relative 'types/composer'
@@ -19,28 +20,39 @@ module EasyTalk
19
20
  attr_reader :name, :schema
20
21
  attr_accessor :klass # Add accessor for the model class
21
22
 
23
+ sig { params(name: String, schema: T::Hash[Symbol, T.untyped]).void }
22
24
  def initialize(name, schema = {})
23
- @schema = schema
24
- @schema[:additional_properties] = false unless schema.key?(:additional_properties)
25
+ @schema = schema.dup
26
+ @schema[:additional_properties] = EasyTalk.configuration.default_additional_properties unless @schema.key?(:additional_properties)
25
27
  @name = name
26
28
  @klass = nil # Initialize klass to nil
27
29
  @property_naming_strategy = EasyTalk.configuration.property_naming_strategy
28
30
  end
29
31
 
30
32
  EasyTalk::KEYWORDS.each do |keyword|
31
- define_method(keyword) do |*values|
32
- @schema[keyword] = values.size > 1 ? values : values.first
33
+ if keyword == :additional_properties
34
+ # Special handling for additional_properties to support type + constraints syntax
35
+ define_method(keyword) do |*args|
36
+ value = parse_additional_properties_args(args)
37
+ @schema[keyword] = value
38
+ end
39
+ else
40
+ define_method(keyword) do |*values|
41
+ @schema[keyword] = values.size > 1 ? values : values.first
42
+ end
33
43
  end
34
44
  end
35
45
 
46
+ sig { params(subschemas: T.untyped).void }
36
47
  def compose(*subschemas)
37
48
  @schema[:subschemas] ||= []
38
49
  @schema[:subschemas] += subschemas
39
50
  end
40
51
 
41
- def property(name, type, constraints = {}, &)
52
+ sig { params(name: T.any(Symbol, String), type: T.untyped, constraints: T::Hash[Symbol, T.untyped], block: T.nilable(T.proc.void)).void }
53
+ def property(name, type, constraints = {}, &block)
54
+ validate_property_name(name)
42
55
  constraints[:as] ||= @property_naming_strategy.call(name)
43
- validate_property_name(constraints[:as])
44
56
  @schema[:properties] ||= {}
45
57
 
46
58
  if block_given?
@@ -51,6 +63,7 @@ module EasyTalk
51
63
  @schema[:properties][name] = { type:, constraints: }
52
64
  end
53
65
 
66
+ sig { params(name: T.any(Symbol, String)).void }
54
67
  def validate_property_name(name)
55
68
  return if name.to_s.match?(/^[A-Za-z_][A-Za-z0-9_]*$/)
56
69
 
@@ -59,11 +72,13 @@ module EasyTalk
59
72
  raise InvalidPropertyNameError, message
60
73
  end
61
74
 
75
+ sig { returns(T.nilable(T::Boolean)) }
62
76
  def optional?
63
77
  @schema[:optional]
64
78
  end
65
79
 
66
80
  # Helper method for nullable and optional properties
81
+ sig { params(name: Symbol, type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).void }
67
82
  def nullable_optional_property(name, type, constraints = {})
68
83
  # Ensure type is nilable
69
84
  nilable_type = if type.respond_to?(:nilable?) && type.nilable?
@@ -79,8 +94,39 @@ module EasyTalk
79
94
  property(name, nilable_type, constraints)
80
95
  end
81
96
 
97
+ sig { params(strategy: T.any(Symbol, T.proc.params(arg0: T.untyped).returns(Symbol))).void }
82
98
  def property_naming_strategy(strategy)
83
99
  @property_naming_strategy = EasyTalk::NamingStrategies.derive_strategy(strategy)
84
100
  end
101
+
102
+ private
103
+
104
+ # Parses arguments for additional_properties to support multiple syntaxes:
105
+ # - additional_properties true/false (boolean)
106
+ # - additional_properties String (type class)
107
+ # - additional_properties Integer, minimum: 0, maximum: 100 (type + constraints)
108
+ sig { params(args: T::Array[T.untyped]).returns(T.untyped) }
109
+ def parse_additional_properties_args(args)
110
+ return args.first if args.empty?
111
+
112
+ # Single boolean argument
113
+ first_arg = args.first
114
+ return first_arg if args.size == 1 && (first_arg.is_a?(TrueClass) || first_arg.is_a?(FalseClass))
115
+
116
+ # Single type class (String, Integer, custom model, etc.)
117
+ return first_arg if args.size == 1 && first_arg.is_a?(Class)
118
+
119
+ # Type + constraints: additional_properties Integer, minimum: 0, maximum: 100
120
+ # args = [Integer, { minimum: 0, maximum: 100 }] or [Integer, minimum: 0, maximum: 100]
121
+ if args.size >= 1 && args.first.is_a?(Class)
122
+ type = args.first
123
+ # Merge all hash arguments as constraints
124
+ constraints = args[1..].select { |arg| arg.is_a?(Hash) }.reduce({}, :merge)
125
+ return { type:, **constraints }
126
+ end
127
+
128
+ # Fallback: return as-is
129
+ args.first
130
+ end
85
131
  end
86
132
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  module EasyTalk
4
5
  # Shared methods for JSON Schema generation.
@@ -14,10 +15,16 @@ module EasyTalk
14
15
  #
15
16
  module SchemaMethods
16
17
  # Returns the reference template for the model.
18
+ # When prefer_external_refs is enabled and the model has a schema ID,
19
+ # returns the external $id URI. Otherwise, returns the local $defs reference.
17
20
  #
18
21
  # @return [String] The reference template for the model.
19
22
  def ref_template
20
- "#/$defs/#{name}"
23
+ config = EasyTalk.configuration
24
+
25
+ # Use external ref when configured and $id available, otherwise fall back to local $defs
26
+ schema_id = resolve_schema_id if config.prefer_external_refs
27
+ schema_id || "#/$defs/#{name}"
21
28
  end
22
29
 
23
30
  # Returns the JSON schema for the model.
@@ -62,19 +69,43 @@ module EasyTalk
62
69
  end
63
70
  end
64
71
 
65
- # Resolves the schema ID from per-model setting or global config.
72
+ # Resolves the schema ID from per-model setting, auto-generation, or global config.
73
+ # Precedence order:
74
+ # 1. Per-model explicit schema_id (highest priority)
75
+ # 2. Auto-generated from base_schema_uri (middle priority)
76
+ # 3. Global schema_id (lowest priority)
66
77
  #
67
78
  # @return [String, nil] The schema ID.
68
79
  def resolve_schema_id
69
80
  model_id = @schema_definition&.schema&.dig(:schema_id)
70
81
 
82
+ # Per-model explicit ID takes precedence
71
83
  if model_id
72
84
  return nil if model_id == :none
73
85
 
74
- model_id.to_s
75
- else
76
- EasyTalk.configuration.schema_id
86
+ return model_id.to_s
77
87
  end
88
+
89
+ # Auto-generate from base_schema_uri if enabled
90
+ config = EasyTalk.configuration
91
+ return generate_schema_id(config.base_schema_uri, name) if config.auto_generate_ids && config.base_schema_uri && name
92
+
93
+ # Fall back to global schema_id
94
+ config.schema_id
95
+ end
96
+
97
+ # Generates a schema ID from the base URI and model name.
98
+ # Normalizes the base URI and converts the model name to underscore case.
99
+ #
100
+ # @param base_uri [String] The base URI for schema IDs.
101
+ # @param model_name [String] The model class name.
102
+ # @return [String] The generated schema ID.
103
+ def generate_schema_id(base_uri, model_name)
104
+ # Normalize base URI (remove trailing slash)
105
+ base = base_uri.to_s.chomp('/')
106
+ # Convert model name to lowercase with underscores for URI segment
107
+ segment = model_name.to_s.underscore
108
+ "#{base}/#{segment}"
78
109
  end
79
110
  end
80
111
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  # This module provides additional functionality for working with Sorbet types.
4
5
  module SorbetExtension
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require 'bigdecimal'
4
5
 
@@ -31,6 +32,8 @@ module EasyTalk
31
32
  }.freeze
32
33
 
33
34
  class << self
35
+ extend T::Sig
36
+
34
37
  # Check if type represents a boolean (T::Boolean or TrueClass/FalseClass).
35
38
  #
36
39
  # @param type [Object] The type to check
@@ -41,6 +44,7 @@ module EasyTalk
41
44
  # boolean_type?(TrueClass) # => true
42
45
  # boolean_type?(FalseClass) # => true
43
46
  # boolean_type?(String) # => false
47
+ sig { params(type: T.untyped).returns(T::Boolean) }
44
48
  def boolean_type?(type)
45
49
  return false if type.nil?
46
50
  return true if [TrueClass, FalseClass].include?(type)
@@ -58,6 +62,23 @@ module EasyTalk
58
62
  false
59
63
  end
60
64
 
65
+ # Check if a resolved type class represents a boolean union ([TrueClass, FalseClass]).
66
+ #
67
+ # This is useful when checking resolved type classes rather than raw Sorbet types.
68
+ # The internal representation of T::Boolean resolves to [TrueClass, FalseClass].
69
+ #
70
+ # @param type_class [Object] The resolved type class to check
71
+ # @return [Boolean] true if the type class is a boolean union array
72
+ #
73
+ # @example
74
+ # boolean_union_type?([TrueClass, FalseClass]) # => true
75
+ # boolean_union_type?(TrueClass) # => false
76
+ # boolean_union_type?(String) # => false
77
+ sig { params(type_class: T.untyped).returns(T::Boolean) }
78
+ def boolean_union_type?(type_class)
79
+ type_class.is_a?(Array) && type_class.sort_by(&:name) == [FalseClass, TrueClass].sort_by(&:name)
80
+ end
81
+
61
82
  # Check if type is a typed array (T::Array[...]).
62
83
  #
63
84
  # @param type [Object] The type to check
@@ -66,12 +87,30 @@ module EasyTalk
66
87
  # @example
67
88
  # typed_array?(T::Array[String]) # => true
68
89
  # typed_array?(Array) # => false
90
+ sig { params(type: T.untyped).returns(T::Boolean) }
69
91
  def typed_array?(type)
70
92
  return false if type.nil?
71
93
 
72
94
  type.is_a?(T::Types::TypedArray)
73
95
  end
74
96
 
97
+ # Check if type is any array type (plain Array, T::Array[...], or T::Tuple[...]).
98
+ #
99
+ # @param type [Object] The type to check
100
+ # @return [Boolean] true if the type is an array type
101
+ #
102
+ # @example
103
+ # array_type?(Array) # => true
104
+ # array_type?(T::Array[String]) # => true
105
+ # array_type?(T::Tuple[String, Integer]) # => true
106
+ # array_type?(String) # => false
107
+ sig { params(type: T.untyped).returns(T::Boolean) }
108
+ def array_type?(type)
109
+ return false if type.nil?
110
+
111
+ type == Array || type.is_a?(T::Types::TypedArray) || type.is_a?(EasyTalk::Types::Tuple)
112
+ end
113
+
75
114
  # Check if type is nilable (T.nilable(...)).
76
115
  #
77
116
  # @param type [Object] The type to check
@@ -80,6 +119,7 @@ module EasyTalk
80
119
  # @example
81
120
  # nilable_type?(T.nilable(String)) # => true
82
121
  # nilable_type?(String) # => false
122
+ sig { params(type: T.untyped).returns(T::Boolean) }
83
123
  def nilable_type?(type)
84
124
  return false if type.nil?
85
125
 
@@ -90,6 +130,7 @@ module EasyTalk
90
130
  #
91
131
  # @param type [Object] The type to check
92
132
  # @return [Boolean] true if the type is a primitive
133
+ sig { params(type: T.untyped).returns(T::Boolean) }
93
134
  def primitive_type?(type)
94
135
  return false if type.nil?
95
136
 
@@ -111,6 +152,7 @@ module EasyTalk
111
152
  # json_schema_type(Float) # => 'number'
112
153
  # json_schema_type(BigDecimal) # => 'number'
113
154
  # json_schema_type(String) # => 'string'
155
+ sig { params(type: T.untyped).returns(String) }
114
156
  def json_schema_type(type)
115
157
  return 'object' if type.nil?
116
158
  return 'boolean' if boolean_type?(type)
@@ -133,11 +175,12 @@ module EasyTalk
133
175
  # get_type_class(String) # => String
134
176
  # get_type_class(T::Boolean) # => [TrueClass, FalseClass]
135
177
  # get_type_class(T::Array[String]) # => Array
178
+ sig { params(type: T.untyped).returns(T.untyped) }
136
179
  def get_type_class(type)
137
180
  return nil if type.nil?
138
181
  return type if type.is_a?(Class)
139
182
  return type.raw_type if type.respond_to?(:raw_type)
140
- return Array if typed_array?(type)
183
+ return Array if type.is_a?(T::Types::TypedArray) || type.is_a?(EasyTalk::Types::Tuple)
141
184
  return [TrueClass, FalseClass] if boolean_type?(type)
142
185
 
143
186
  if nilable_type?(type)
@@ -155,6 +198,7 @@ module EasyTalk
155
198
  #
156
199
  # @example
157
200
  # extract_inner_type(T.nilable(String)) # => String
201
+ sig { params(type: T.untyped).returns(T.untyped) }
158
202
  def extract_inner_type(type)
159
203
  return type if type.nil?
160
204
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module Types
5
+ # Represents a tuple type for arrays with positional type validation.
6
+ #
7
+ # A tuple is an array where each position has a specific type. This class
8
+ # stores the types for each position.
9
+ #
10
+ # @example Basic tuple
11
+ # T::Tuple[String, Integer] # First item must be String, second must be Integer
12
+ #
13
+ # @example With additional_items constraint
14
+ # property :flags, T::Tuple[T::Boolean, T::Boolean], additional_items: false
15
+ #
16
+ class Tuple
17
+ extend T::Sig
18
+
19
+ # @return [Array<Object>] The types for each position in the tuple
20
+ sig { returns(T::Array[T.untyped]) }
21
+ attr_reader :types
22
+
23
+ # Creates a new Tuple instance with the given positional types.
24
+ #
25
+ # @param types [Array] The types for each position in the tuple
26
+ # @raise [ArgumentError] if types is empty or contains nil values
27
+ sig { params(types: T.untyped).void }
28
+ def initialize(*types)
29
+ raise ArgumentError, 'Tuple requires at least one type' if types.empty?
30
+ raise ArgumentError, 'Tuple types cannot be nil' if types.any?(&:nil?)
31
+
32
+ @types = types.freeze
33
+ end
34
+
35
+ # Returns a string representation of the tuple type.
36
+ #
37
+ # @return [String] A human-readable representation
38
+ sig { returns(String) }
39
+ def to_s
40
+ type_names = @types.map { |t| (t.respond_to?(:name) && t.name) || t.to_s }
41
+ "T::Tuple[#{type_names.join(', ')}]"
42
+ end
43
+
44
+ # Returns the name of this type (used by Property for error messages).
45
+ #
46
+ # @return [String] The type name
47
+ sig { returns(String) }
48
+ def name
49
+ to_s
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # Add T::Tuple module for bracket syntax
56
+ module T
57
+ # Provides tuple type syntax: T::Tuple[Type1, Type2, ...]
58
+ #
59
+ # Creates a tuple type that validates array elements by position.
60
+ #
61
+ # @example Basic usage
62
+ # property :coordinates, T::Tuple[Float, Float]
63
+ # property :record, T::Tuple[String, Integer, T::Boolean]
64
+ #
65
+ # @example With additional_items constraint
66
+ # property :flags, T::Tuple[T::Boolean, T::Boolean], additional_items: false
67
+ #
68
+ module Tuple
69
+ # Creates a new Tuple type with the given positional types.
70
+ #
71
+ # @param types [Array] The types for each position
72
+ # @return [EasyTalk::Types::Tuple] A new Tuple instance
73
+ def self.[](*types)
74
+ EasyTalk::Types::Tuple.new(*types)
75
+ end
76
+ end
77
+ end