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
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  require_relative 'naming_strategies'
4
5
 
5
6
  module EasyTalk
6
7
  class Configuration
8
+ extend T::Sig
9
+
7
10
  # JSON Schema draft version URIs
8
11
  SCHEMA_VERSIONS = {
9
12
  draft202012: 'https://json-schema.org/draft/2020-12/schema',
@@ -14,9 +17,11 @@ module EasyTalk
14
17
  }.freeze
15
18
 
16
19
  attr_accessor :default_additional_properties, :nilable_is_optional, :auto_validations, :schema_version, :schema_id,
17
- :use_refs, :validation_adapter, :default_error_format, :error_type_base_uri, :include_error_codes
20
+ :use_refs, :validation_adapter, :default_error_format, :error_type_base_uri, :include_error_codes,
21
+ :base_schema_uri, :auto_generate_ids, :prefer_external_refs
18
22
  attr_reader :property_naming_strategy
19
23
 
24
+ sig { void }
20
25
  def initialize
21
26
  @default_additional_properties = false
22
27
  @nilable_is_optional = false
@@ -28,16 +33,21 @@ module EasyTalk
28
33
  @default_error_format = :flat
29
34
  @error_type_base_uri = 'about:blank'
30
35
  @include_error_codes = true
36
+ @base_schema_uri = nil
37
+ @auto_generate_ids = false
38
+ @prefer_external_refs = false
31
39
  self.property_naming_strategy = :identity
32
40
  end
33
41
 
34
42
  # Returns the URI for the configured schema version, or nil if :none
43
+ sig { returns(T.nilable(String)) }
35
44
  def schema_uri
36
45
  return nil if @schema_version == :none
37
46
 
38
47
  SCHEMA_VERSIONS[@schema_version] || @schema_version.to_s
39
48
  end
40
49
 
50
+ sig { params(strategy: T.any(Symbol, T.proc.params(arg0: T.untyped).returns(Symbol))).void }
41
51
  def property_naming_strategy=(strategy)
42
52
  @property_naming_strategy = EasyTalk::NamingStrategies.derive_strategy(strategy)
43
53
  end
@@ -56,17 +66,22 @@ module EasyTalk
56
66
  # EasyTalk.configure do |config|
57
67
  # config.register_type Money, MoneySchemaBuilder
58
68
  # end
69
+ sig { params(type_key: T.any(T::Class[T.anything], String, Symbol), builder_class: T.untyped, collection: T::Boolean).void }
59
70
  def register_type(type_key, builder_class, collection: false)
60
71
  EasyTalk::Builders::Registry.register(type_key, builder_class, collection: collection)
61
72
  end
62
73
  end
63
74
 
64
75
  class << self
76
+ extend T::Sig
77
+
78
+ sig { returns(Configuration) }
65
79
  def configuration
66
80
  @configuration ||= Configuration.new
67
81
  end
68
82
 
69
- def configure
83
+ sig { params(block: T.proc.params(config: Configuration).void).void }
84
+ def configure(&block)
70
85
  yield(configuration)
71
86
  end
72
87
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  module EasyTalk
4
5
  class Error < StandardError; end
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  module EasyTalk
4
5
  # Helper module for generating consistent error messages
5
6
  module ErrorHelper
7
+ extend T::Sig
8
+
6
9
  def self.raise_constraint_error(property_name:, constraint_name:, expected:, got:)
7
10
  message = "Error in property '#{property_name}': Constraint '#{constraint_name}' expects #{expected}, " \
8
11
  "but received #{got.inspect} (#{got.class})."
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module Extensions
5
+ # Class methods for RubyLLM compatibility.
6
+ # These are added to the model class via `extend`.
7
+ module RubyLLMCompatibility
8
+ # Returns a Hash representing the schema in a format compatible with RubyLLM.
9
+ # RubyLLM expects an object that responds to #to_json_schema and returns
10
+ # a hash with :name, :description, and :schema keys.
11
+ #
12
+ # @return [Hash] The RubyLLM-compatible schema representation
13
+ def to_json_schema
14
+ {
15
+ name: name,
16
+ description: schema_definition.schema[:description] || "Schema for #{name}",
17
+ schema: json_schema
18
+ }
19
+ end
20
+ end
21
+
22
+ # Overrides for classes that inherit from RubyLLM::Tool.
23
+ # Only overrides schema-related methods, allowing all other RubyLLM::Tool
24
+ # functionality (halt, call, etc.) to work normally.
25
+ #
26
+ # Usage:
27
+ # class WeatherTool < RubyLLM::Tool
28
+ # include EasyTalk::Model
29
+ #
30
+ # define_schema do
31
+ # description 'Gets current weather'
32
+ # property :latitude, String
33
+ # property :longitude, String
34
+ # end
35
+ #
36
+ # def execute(latitude:, longitude:)
37
+ # # Can use halt() since we inherit from RubyLLM::Tool
38
+ # halt "Weather at #{latitude}, #{longitude}"
39
+ # end
40
+ # end
41
+ module RubyLLMToolOverrides
42
+ # Override to use EasyTalk's schema description.
43
+ #
44
+ # @return [String] The tool description from EasyTalk schema
45
+ def description
46
+ schema_def = self.class.schema_definition
47
+ schema_def.schema[:description] || "Tool: #{self.class.name}"
48
+ end
49
+
50
+ # Override to use EasyTalk's JSON schema for parameters.
51
+ #
52
+ # @return [Hash] The JSON schema for parameters
53
+ def params_schema
54
+ self.class.json_schema
55
+ end
56
+ end
57
+ end
58
+ end
@@ -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'
@@ -11,6 +12,7 @@ require_relative 'builders/object_builder'
11
12
  require_relative 'schema_definition'
12
13
  require_relative 'validation_builder'
13
14
  require_relative 'error_formatter'
15
+ require_relative 'extensions/ruby_llm_compatibility'
14
16
 
15
17
  module EasyTalk
16
18
  # The `Model` module is a mixin that provides functionality for defining and accessing the schema of a model.
@@ -37,12 +39,18 @@ module EasyTalk
37
39
  module Model
38
40
  def self.included(base)
39
41
  base.extend(ClassMethods)
42
+ base.extend(EasyTalk::Extensions::RubyLLMCompatibility) # Add class-level methods
40
43
 
41
44
  base.include ActiveModel::API
42
45
  base.include ActiveModel::Validations
43
46
  base.extend ActiveModel::Callbacks
44
47
  base.include(InstanceMethods)
45
48
  base.include(ErrorFormatter::InstanceMethods)
49
+
50
+ # If inheriting from RubyLLM::Tool, override schema methods to use EasyTalk's schema
51
+ return unless defined?(RubyLLM::Tool) && base < RubyLLM::Tool
52
+
53
+ base.include(EasyTalk::Extensions::RubyLLMToolOverrides)
46
54
  end
47
55
 
48
56
  # Instance methods mixed into models that include EasyTalk::Model
@@ -149,6 +157,14 @@ module EasyTalk
149
157
  to_hash.merge(@additional_properties)
150
158
  end
151
159
 
160
+ # Returns a Hash representing the schema in a format compatible with RubyLLM.
161
+ # Delegates to the class method. Required for RubyLLM's with_schema method.
162
+ #
163
+ # @return [Hash] The RubyLLM-compatible schema representation
164
+ def to_json_schema
165
+ self.class.to_json_schema
166
+ end
167
+
152
168
  # Allow comparison with hashes
153
169
  def ==(other)
154
170
  case other
@@ -220,6 +236,9 @@ module EasyTalk
220
236
  # Track which properties have had validations applied
221
237
  @validated_properties ||= Set.new
222
238
 
239
+ # Initialize mutex eagerly for thread-safe schema-level validation application
240
+ @schema_level_validation_lock = Mutex.new
241
+
223
242
  # Apply validations using the adapter system
224
243
  apply_schema_validations
225
244
 
@@ -273,6 +292,26 @@ module EasyTalk
273
292
  adapter.build_validations(self, prop_name, prop_def[:type], prop_def[:constraints])
274
293
  @validated_properties.add(prop_name)
275
294
  end
295
+
296
+ # Apply schema-level validations (min_properties, max_properties, dependent_required)
297
+ apply_schema_level_validations(adapter)
298
+ end
299
+
300
+ # Apply schema-level validations for object-level constraints.
301
+ # Uses double-checked locking for thread safety.
302
+ # The mutex is initialized eagerly in define_schema.
303
+ #
304
+ # @param adapter [Class] The validation adapter class
305
+ # @return [void]
306
+ def apply_schema_level_validations(adapter)
307
+ return if @schema_level_validations_applied
308
+
309
+ @schema_level_validation_lock.synchronize do
310
+ return if @schema_level_validations_applied
311
+
312
+ adapter.build_schema_validations(self, @schema_definition.schema)
313
+ @schema_level_validations_applied = true
314
+ end
276
315
  end
277
316
 
278
317
  public
@@ -285,7 +324,9 @@ module EasyTalk
285
324
  end
286
325
 
287
326
  def additional_properties_allowed?
288
- @schema_definition&.schema&.fetch(:additional_properties, false)
327
+ ap = @schema_definition&.schema&.fetch(:additional_properties, false)
328
+ # Allow if true, or if it's a schema object (Class or Hash with type)
329
+ ap == true || ap.is_a?(Class) || ap.is_a?(Hash)
289
330
  end
290
331
 
291
332
  # 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