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.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -43
- data/CHANGELOG.md +105 -0
- data/README.md +510 -2018
- data/docs/json_schema_compliance.md +140 -26
- data/docs/primitive-schema-rfc.md +894 -0
- data/examples/ruby_llm/Gemfile +12 -0
- data/examples/ruby_llm/structured_output.rb +47 -0
- data/examples/ruby_llm/tools_integration.rb +49 -0
- data/lib/easy_talk/builders/base_builder.rb +2 -1
- data/lib/easy_talk/builders/boolean_builder.rb +2 -1
- data/lib/easy_talk/builders/collection_helpers.rb +4 -0
- data/lib/easy_talk/builders/composition_builder.rb +7 -2
- data/lib/easy_talk/builders/integer_builder.rb +2 -1
- data/lib/easy_talk/builders/null_builder.rb +4 -1
- data/lib/easy_talk/builders/number_builder.rb +4 -1
- data/lib/easy_talk/builders/object_builder.rb +64 -3
- data/lib/easy_talk/builders/registry.rb +15 -1
- data/lib/easy_talk/builders/string_builder.rb +3 -1
- data/lib/easy_talk/builders/temporal_builder.rb +7 -0
- data/lib/easy_talk/builders/tuple_builder.rb +89 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +4 -2
- data/lib/easy_talk/builders/union_builder.rb +5 -1
- data/lib/easy_talk/configuration.rb +17 -2
- data/lib/easy_talk/errors.rb +1 -0
- data/lib/easy_talk/errors_helper.rb +3 -0
- data/lib/easy_talk/extensions/ruby_llm_compatibility.rb +58 -0
- data/lib/easy_talk/json_schema_equality.rb +46 -0
- data/lib/easy_talk/keywords.rb +0 -1
- data/lib/easy_talk/model.rb +42 -1
- data/lib/easy_talk/model_helper.rb +4 -0
- data/lib/easy_talk/naming_strategies.rb +4 -0
- data/lib/easy_talk/property.rb +7 -0
- data/lib/easy_talk/ref_helper.rb +6 -0
- data/lib/easy_talk/schema.rb +1 -0
- data/lib/easy_talk/schema_definition.rb +52 -6
- data/lib/easy_talk/schema_methods.rb +36 -5
- data/lib/easy_talk/sorbet_extension.rb +1 -0
- data/lib/easy_talk/type_introspection.rb +45 -1
- data/lib/easy_talk/types/tuple.rb +77 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +350 -62
- data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
- data/lib/easy_talk/validation_adapters/base.rb +12 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +9 -0
- data/lib/easy_talk/validation_builder.rb +1 -0
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +1 -0
- 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
|
-
|
|
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
|
data/lib/easy_talk/errors.rb
CHANGED
|
@@ -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
|
data/lib/easy_talk/keywords.rb
CHANGED
data/lib/easy_talk/model.rb
CHANGED
|
@@ -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,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
|
data/lib/easy_talk/property.rb
CHANGED
|
@@ -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)
|
data/lib/easy_talk/ref_helper.rb
CHANGED
|
@@ -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))
|
data/lib/easy_talk/schema.rb
CHANGED
|
@@ -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] =
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|