easy_talk 3.2.0 → 3.3.1

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -43
  3. data/CHANGELOG.md +105 -0
  4. data/README.md +510 -2018
  5. data/docs/json_schema_compliance.md +140 -26
  6. data/docs/primitive-schema-rfc.md +894 -0
  7. data/examples/ruby_llm/Gemfile +12 -0
  8. data/examples/ruby_llm/structured_output.rb +47 -0
  9. data/examples/ruby_llm/tools_integration.rb +49 -0
  10. data/lib/easy_talk/builders/base_builder.rb +2 -1
  11. data/lib/easy_talk/builders/boolean_builder.rb +2 -1
  12. data/lib/easy_talk/builders/collection_helpers.rb +4 -0
  13. data/lib/easy_talk/builders/composition_builder.rb +7 -2
  14. data/lib/easy_talk/builders/integer_builder.rb +2 -1
  15. data/lib/easy_talk/builders/null_builder.rb +4 -1
  16. data/lib/easy_talk/builders/number_builder.rb +4 -1
  17. data/lib/easy_talk/builders/object_builder.rb +64 -3
  18. data/lib/easy_talk/builders/registry.rb +15 -1
  19. data/lib/easy_talk/builders/string_builder.rb +3 -1
  20. data/lib/easy_talk/builders/temporal_builder.rb +7 -0
  21. data/lib/easy_talk/builders/tuple_builder.rb +89 -0
  22. data/lib/easy_talk/builders/typed_array_builder.rb +4 -2
  23. data/lib/easy_talk/builders/union_builder.rb +5 -1
  24. data/lib/easy_talk/configuration.rb +17 -2
  25. data/lib/easy_talk/errors.rb +1 -0
  26. data/lib/easy_talk/errors_helper.rb +3 -0
  27. data/lib/easy_talk/extensions/ruby_llm_compatibility.rb +58 -0
  28. data/lib/easy_talk/json_schema_equality.rb +46 -0
  29. data/lib/easy_talk/keywords.rb +0 -1
  30. data/lib/easy_talk/model.rb +42 -1
  31. data/lib/easy_talk/model_helper.rb +4 -0
  32. data/lib/easy_talk/naming_strategies.rb +4 -0
  33. data/lib/easy_talk/property.rb +7 -0
  34. data/lib/easy_talk/ref_helper.rb +6 -0
  35. data/lib/easy_talk/schema.rb +1 -0
  36. data/lib/easy_talk/schema_definition.rb +52 -6
  37. data/lib/easy_talk/schema_methods.rb +36 -5
  38. data/lib/easy_talk/sorbet_extension.rb +1 -0
  39. data/lib/easy_talk/type_introspection.rb +45 -1
  40. data/lib/easy_talk/types/tuple.rb +77 -0
  41. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +350 -62
  42. data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
  43. data/lib/easy_talk/validation_adapters/base.rb +12 -0
  44. data/lib/easy_talk/validation_adapters/none_adapter.rb +9 -0
  45. data/lib/easy_talk/validation_builder.rb +1 -0
  46. data/lib/easy_talk/version.rb +1 -1
  47. data/lib/easy_talk.rb +1 -0
  48. metadata +17 -4
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # EasyTalk from the parent directory
6
+ gem 'easy_talk', path: '../..'
7
+
8
+ # RubyLLM for LLM interactions
9
+ gem 'ruby_llm'
10
+
11
+ # HTTP client for API calls (used in tools example)
12
+ gem 'faraday'
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'ruby_llm'
5
+ require 'easy_talk'
6
+
7
+ # Example: Structured Outputs
8
+ # Demonstrates using EasyTalk models to generate structured JSON responses.
9
+
10
+ RubyLLM.configure do |config|
11
+ config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
12
+ end
13
+
14
+ # 1. Define the Schema using EasyTalk
15
+ class Recipe
16
+ include EasyTalk::Model
17
+
18
+ define_schema do
19
+ description "A simple cooking recipe"
20
+ property :name, String, description: "Name of the dish"
21
+ property :ingredients, T::Array[String], description: "List of ingredients"
22
+ property :prep_time_minutes, Integer, description: "Preparation time in minutes"
23
+ property :steps, T::Array[String], description: "Step by step cooking instructions"
24
+ end
25
+ end
26
+
27
+ puts "--- Structured Output Example ---"
28
+
29
+ # 2. Use the EasyTalk model as the output schema
30
+ # RubyLLM uses the schema to force the LLM to reply with a matching JSON structure.
31
+ # Our compatibility layer ensures 'Recipe' responds to to_json_schema as RubyLLM expects.
32
+ chat = RubyLLM.chat.with_schema(Recipe)
33
+
34
+ puts "User: Give me a simple spaghetti carbonara recipe."
35
+ response = chat.ask "Give me a simple spaghetti carbonara recipe."
36
+
37
+ # 3. Access the structured data
38
+ # RubyLLM returns parsed JSON as a Hash, so we instantiate the model with it
39
+ recipe = Recipe.new(response.content)
40
+
41
+ puts "\nGenerated Recipe:"
42
+ puts "Name: #{recipe.name}"
43
+ puts "Time: #{recipe.prep_time_minutes} mins"
44
+ puts "Ingredients:"
45
+ recipe.ingredients.each { |ing| puts "- #{ing}" }
46
+ puts "Steps:"
47
+ recipe.steps.each_with_index { |step, i| puts "#{i + 1}. #{step}" }
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'ruby_llm'
5
+ require 'easy_talk'
6
+ require 'faraday'
7
+
8
+ # Example: Tools Integration
9
+ # Demonstrates using EasyTalk models as Tools for RubyLLM.
10
+ #
11
+ # To create a tool, inherit from RubyLLM::Tool and include EasyTalk::Model.
12
+ # This gives you:
13
+ # - Full access to RubyLLM::Tool features like halt()
14
+ # - EasyTalk's schema DSL for defining parameters
15
+ # - Automatic integration with RubyLLM's with_tool method
16
+
17
+ RubyLLM.configure do |config|
18
+ config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
19
+ end
20
+
21
+ class Weather < RubyLLM::Tool
22
+ include EasyTalk::Model
23
+
24
+ define_schema do
25
+ description 'Gets current weather for a location'
26
+ property :latitude, String, description: 'Latitude (e.g., 52.5200)'
27
+ property :longitude, String, description: 'Longitude (e.g., 13.4050)'
28
+ end
29
+
30
+ def execute(latitude:, longitude:)
31
+ puts "Executing Weather Tool for #{latitude}, #{longitude}"
32
+ url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}&current=temperature_2m,wind_speed_10m"
33
+ response = Faraday.get(url)
34
+ data = JSON.parse(response.body)
35
+ data.to_s
36
+ rescue StandardError => e
37
+ { error: e.message }
38
+ end
39
+ end
40
+
41
+ puts '--- Tools Integration Example ---'
42
+ puts
43
+
44
+ chat = RubyLLM.chat.with_tool(Weather)
45
+
46
+ puts 'User: What is the weather in Berlin (Lat: 52.52, Long: 13.405)?'
47
+ response = chat.ask 'What is the weather in Berlin (Lat: 52.52, Long: 13.405)?'
48
+
49
+ puts "Assistant: #{response.content}"
@@ -23,7 +23,7 @@ module EasyTalk
23
23
  params(
24
24
  property_name: Symbol,
25
25
  schema: T::Hash[Symbol, T.untyped],
26
- options: T::Hash[Symbol, String],
26
+ options: T::Hash[Symbol, T.untyped],
27
27
  valid_options: T::Hash[Symbol, T.untyped]
28
28
  ).void
29
29
  end
@@ -59,6 +59,7 @@ module EasyTalk
59
59
  end
60
60
  end
61
61
 
62
+ sig { returns(T::Boolean) }
62
63
  def self.collection_type?
63
64
  false
64
65
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'base_builder'
4
5
 
@@ -14,7 +15,7 @@ module EasyTalk
14
15
  default: { type: T::Boolean, key: :default }
15
16
  }.freeze
16
17
 
17
- sig { params(name: Symbol, constraints: Hash).void }
18
+ sig { params(name: Symbol, constraints: T::Hash[Symbol, T.untyped]).void }
18
19
  def initialize(name, constraints = {})
19
20
  super(name, { type: 'boolean' }, constraints, VALID_OPTIONS)
20
21
  end
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  module EasyTalk
4
5
  module Builders
5
6
  # Base builder class for array-type properties.
6
7
  module CollectionHelpers
8
+ extend T::Sig
9
+
10
+ sig { returns(T::Boolean) }
7
11
  def collection_type?
8
12
  true
9
13
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'collection_helpers'
4
5
  require_relative '../ref_helper'
@@ -16,7 +17,7 @@ module EasyTalk
16
17
  'OneOfBuilder' => 'oneOf'
17
18
  }.freeze
18
19
 
19
- sig { params(name: Symbol, type: T.untyped, constraints: Hash).void }
20
+ sig { params(name: Symbol, type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).void }
20
21
  # Initializes a new instance of the CompositionBuilder class.
21
22
  #
22
23
  # @param name [Symbol] The name of the composition.
@@ -32,7 +33,8 @@ module EasyTalk
32
33
 
33
34
  # Builds the composed JSON schema.
34
35
  #
35
- # @return [void]
36
+ # @return [Hash] The composed JSON schema.
37
+ sig { returns(T::Hash[Symbol, T.untyped]) }
36
38
  def build
37
39
  @context[@name.to_sym] = {
38
40
  type: 'object',
@@ -43,6 +45,7 @@ module EasyTalk
43
45
  # Returns the composer keyword based on the composer type.
44
46
  #
45
47
  # @return [String] The composer keyword.
48
+ sig { returns(T.nilable(String)) }
46
49
  def composer_keyword
47
50
  COMPOSER_TO_KEYWORD[@composer_type]
48
51
  end
@@ -50,6 +53,7 @@ module EasyTalk
50
53
  # Returns an array of schemas for the composed JSON schema.
51
54
  #
52
55
  # @return [Array<Hash>] The array of schemas.
56
+ sig { returns(T::Array[T.untyped]) }
53
57
  def schemas
54
58
  items.map do |type|
55
59
  if EasyTalk::RefHelper.should_use_ref?(type, @constraints)
@@ -66,6 +70,7 @@ module EasyTalk
66
70
  # Returns the items of the type.
67
71
  #
68
72
  # @return [T.untyped] The items of the type.
73
+ sig { returns(T.untyped) }
69
74
  def items
70
75
  @type.items
71
76
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'base_builder'
4
5
 
@@ -20,7 +21,7 @@ module EasyTalk
20
21
  }.freeze
21
22
 
22
23
  # Initializes a new instance of the IntegerBuilder class.
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: 'integer' }, constraints, VALID_OPTIONS)
26
27
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'base_builder'
4
5
 
@@ -6,8 +7,10 @@ module EasyTalk
6
7
  module Builders
7
8
  # builder class for Null properties.
8
9
  class NullBuilder < BaseBuilder
10
+ extend T::Sig
11
+
9
12
  # Initializes a new instance of the NullBuilder class.
10
- sig { params(name: Symbol, _constraints: Hash).void }
13
+ sig { params(name: Symbol, _constraints: T::Hash[Symbol, T.untyped]).void }
11
14
  def initialize(name, _constraints = {})
12
15
  super(name, { type: 'null' }, {}, {})
13
16
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'base_builder'
4
5
 
@@ -6,6 +7,8 @@ module EasyTalk
6
7
  module Builders
7
8
  # Builder class for number properties.
8
9
  class NumberBuilder < BaseBuilder
10
+ extend T::Sig
11
+
9
12
  VALID_OPTIONS = {
10
13
  multiple_of: { type: T.any(Integer, Float), key: :multipleOf },
11
14
  minimum: { type: T.any(Integer, Float), key: :minimum },
@@ -18,7 +21,7 @@ module EasyTalk
18
21
  }.freeze
19
22
 
20
23
  # Initializes a new instance of the NumberBuilder class.
21
- sig { params(name: Symbol, constraints: Hash).void }
24
+ sig { params(name: Symbol, constraints: T::Hash[Symbol, T.untyped]).void }
22
25
  def initialize(name, constraints = {})
23
26
  super(name, { type: 'number' }, constraints, VALID_OPTIONS)
24
27
  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_relative '../model_helper'
@@ -20,7 +21,12 @@ module EasyTalk
20
21
  # Required by BaseBuilder: recognized schema options for "object" types
21
22
  VALID_OPTIONS = {
22
23
  properties: { type: T::Hash[T.any(Symbol, String), T.untyped], key: :properties },
23
- 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 },
24
30
  subschemas: { type: T::Array[T.untyped], key: :subschemas },
25
31
  required: { type: T::Array[T.any(Symbol, String)], key: :required },
26
32
  defs: { type: T.untyped, key: :$defs },
@@ -55,6 +61,14 @@ module EasyTalk
55
61
  )
56
62
  end
57
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
+
58
72
  private
59
73
 
60
74
  ##
@@ -99,8 +113,9 @@ module EasyTalk
99
113
  # Populate the final "required" array from @required_properties
100
114
  merged[:required] = @required_properties.to_a if @required_properties.any?
101
115
 
102
- # Add additionalProperties: false by default if not explicitly set
103
- 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)
104
119
 
105
120
  # Prune empty or nil values so we don't produce stuff like "properties": {} unnecessarily
106
121
  merged.reject! { |_k, v| v.nil? || v == {} || v == [] }
@@ -108,6 +123,52 @@ module EasyTalk
108
123
  merged
109
124
  end
110
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
+
111
172
  ##
112
173
  # Given the property definitions hash, produce a new hash of
113
174
  # { property_name => [Property or nested schema builder result] }.
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  module EasyTalk
4
5
  module Builders
@@ -22,9 +23,12 @@ module EasyTalk
22
23
  #
23
24
  class Registry
24
25
  class << self
26
+ extend T::Sig
27
+
25
28
  # Get the hash of registered type builders.
26
29
  #
27
30
  # @return [Hash{String => Hash}] The registered builders with metadata
31
+ sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) }
28
32
  def registry
29
33
  @registry ||= {}
30
34
  end
@@ -43,6 +47,7 @@ module EasyTalk
43
47
  #
44
48
  # @example Register a collection type
45
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 }
46
51
  def register(type_key, builder_class, collection: false)
47
52
  raise ArgumentError, 'Builder must respond to .new' unless builder_class.respond_to?(:new)
48
53
 
@@ -63,6 +68,7 @@ module EasyTalk
63
68
  # @example
64
69
  # builder_class, is_collection = Registry.resolve(String)
65
70
  # # => [StringBuilder, false]
71
+ sig { params(type: T.untyped).returns(T.nilable(T::Array[T.untyped])) }
66
72
  def resolve(type)
67
73
  entry = find_registration(type)
68
74
  return nil unless entry
@@ -74,6 +80,7 @@ module EasyTalk
74
80
  #
75
81
  # @param type_key [Class, String, Symbol] The type to check
76
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) }
77
84
  def registered?(type_key)
78
85
  registry.key?(normalize_key(type_key))
79
86
  end
@@ -82,6 +89,7 @@ module EasyTalk
82
89
  #
83
90
  # @param type_key [Class, String, Symbol] The type to unregister
84
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])) }
85
93
  def unregister(type_key)
86
94
  registry.delete(normalize_key(type_key))
87
95
  end
@@ -89,6 +97,7 @@ module EasyTalk
89
97
  # Get a list of all registered type keys.
90
98
  #
91
99
  # @return [Array<String>] The registered type keys
100
+ sig { returns(T::Array[String]) }
92
101
  def registered_types
93
102
  registry.keys
94
103
  end
@@ -96,6 +105,7 @@ module EasyTalk
96
105
  # Reset the registry to empty state and re-register built-in types.
97
106
  #
98
107
  # @return [void]
108
+ sig { void }
99
109
  def reset!
100
110
  @registry = nil
101
111
  register_built_in_types
@@ -105,6 +115,7 @@ module EasyTalk
105
115
  # This is called during gem initialization and after reset!
106
116
  #
107
117
  # @return [void]
118
+ sig { void }
108
119
  def register_built_in_types
109
120
  register(String, Builders::StringBuilder)
110
121
  register(Integer, Builders::IntegerBuilder)
@@ -116,9 +127,10 @@ module EasyTalk
116
127
  register(Date, Builders::TemporalBuilder::DateBuilder)
117
128
  register(DateTime, Builders::TemporalBuilder::DatetimeBuilder)
118
129
  register(Time, Builders::TemporalBuilder::TimeBuilder)
119
- register('anyOf', Builders::CompositionBuilder::AnyOfBuilder, collection: true)
120
130
  register('allOf', Builders::CompositionBuilder::AllOfBuilder, collection: true)
131
+ register('anyOf', Builders::CompositionBuilder::AnyOfBuilder, collection: true)
121
132
  register('oneOf', Builders::CompositionBuilder::OneOfBuilder, collection: true)
133
+ register('EasyTalk::Types::Tuple', Builders::TupleBuilder, collection: true)
122
134
  register('T::Types::TypedArray', Builders::TypedArrayBuilder, collection: true)
123
135
  register('T::Types::Union', Builders::UnionBuilder, collection: true)
124
136
  end
@@ -129,6 +141,7 @@ module EasyTalk
129
141
  #
130
142
  # @param type_key [Class, String, Symbol] The type key to normalize
131
143
  # @return [String] The normalized key
144
+ sig { params(type_key: T.any(T::Class[T.anything], String, Symbol)).returns(String) }
132
145
  def normalize_key(type_key)
133
146
  case type_key
134
147
  when Class
@@ -146,6 +159,7 @@ module EasyTalk
146
159
  #
147
160
  # @param type [Object] The type to find
148
161
  # @return [Hash, nil] The registration entry or nil
162
+ sig { params(type: T.untyped).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
149
163
  def find_registration(type)
150
164
  # Strategy 1: Check type.class.name (for Sorbet types like T::Types::TypedArray)
151
165
  class_name = type.class.name.to_s
@@ -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,7 +21,7 @@ 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
@@ -49,6 +50,7 @@ module EasyTalk
49
50
  end
50
51
  end
51
52
 
53
+ sig { returns(T.untyped) }
52
54
  def inner_type
53
55
  return unless type.is_a?(T::Types::TypedArray)
54
56
 
@@ -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