easy_talk 3.3.0 → 3.3.2
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/CHANGELOG.md +41 -0
- data/README.md +191 -31
- data/easy_talk.gemspec +42 -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/composition_builder.rb +3 -0
- data/lib/easy_talk/builders/null_builder.rb +5 -3
- data/lib/easy_talk/builders/object_builder.rb +5 -31
- data/lib/easy_talk/builders/union_builder.rb +3 -0
- data/lib/easy_talk/errors_helper.rb +4 -4
- data/lib/easy_talk/extensions/ruby_llm_compatibility.rb +58 -0
- data/lib/easy_talk/model.rb +33 -167
- data/lib/easy_talk/property.rb +2 -3
- data/lib/easy_talk/schema.rb +19 -129
- data/lib/easy_talk/schema_base.rb +181 -0
- data/lib/easy_talk/schema_definition.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +9 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +37 -37
- data/lib/easy_talk/validation_adapters/base.rb +7 -39
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +23 -1
- metadata +7 -1
|
@@ -121,7 +121,7 @@ module EasyTalk
|
|
|
121
121
|
if args.size >= 1 && args.first.is_a?(Class)
|
|
122
122
|
type = args.first
|
|
123
123
|
# Merge all hash arguments as constraints
|
|
124
|
-
constraints = args[1..].
|
|
124
|
+
constraints = args[1..].grep(Hash).reduce({}, :merge)
|
|
125
125
|
return { type:, **constraints }
|
|
126
126
|
end
|
|
127
127
|
|
|
@@ -183,6 +183,11 @@ module EasyTalk
|
|
|
183
183
|
return Array if type.is_a?(T::Types::TypedArray) || type.is_a?(EasyTalk::Types::Tuple)
|
|
184
184
|
return [TrueClass, FalseClass] if boolean_type?(type)
|
|
185
185
|
|
|
186
|
+
if type.is_a?(Symbol) || type.is_a?(String)
|
|
187
|
+
klass = type.to_s.classify.safe_constantize
|
|
188
|
+
return klass if klass
|
|
189
|
+
end
|
|
190
|
+
|
|
186
191
|
if nilable_type?(type)
|
|
187
192
|
inner = extract_inner_type(type)
|
|
188
193
|
return get_type_class(inner) if inner && inner != type
|
|
@@ -208,6 +213,10 @@ module EasyTalk
|
|
|
208
213
|
end
|
|
209
214
|
|
|
210
215
|
if type.respond_to?(:types)
|
|
216
|
+
# Prefer TypedArray if present in the union (for T.nilable(T::Array[...]) via .types path)
|
|
217
|
+
typed_arr = type.types.find { |t| t.is_a?(T::Types::TypedArray) }
|
|
218
|
+
return typed_arr if typed_arr
|
|
219
|
+
|
|
211
220
|
non_nil = type.types.find do |t|
|
|
212
221
|
raw = t.respond_to?(:raw_type) ? t.raw_type : t
|
|
213
222
|
raw != NilClass
|
|
@@ -128,6 +128,12 @@ module EasyTalk
|
|
|
128
128
|
|
|
129
129
|
private
|
|
130
130
|
|
|
131
|
+
# Returns true if nil should be allowed by ActiveModel validators.
|
|
132
|
+
# This is the case when the property is optional or declared T.nilable.
|
|
133
|
+
def allow_nil?
|
|
134
|
+
optional? || nilable_type?
|
|
135
|
+
end
|
|
136
|
+
|
|
131
137
|
# Apply validations based on the type of the property
|
|
132
138
|
def apply_type_validations(context)
|
|
133
139
|
if context.tuple_type?
|
|
@@ -185,7 +191,7 @@ module EasyTalk
|
|
|
185
191
|
length_options[:maximum] = @constraints[:max_length] if valid_length_constraint?(:max_length)
|
|
186
192
|
return unless length_options.any?
|
|
187
193
|
|
|
188
|
-
length_options[:allow_nil] =
|
|
194
|
+
length_options[:allow_nil] = allow_nil?
|
|
189
195
|
@klass.validates @property_name, length: length_options
|
|
190
196
|
rescue ArgumentError
|
|
191
197
|
# Silently ignore invalid length constraints
|
|
@@ -297,6 +303,7 @@ module EasyTalk
|
|
|
297
303
|
options[:less_than_or_equal_to] = @constraints[:maximum] if @constraints[:maximum].is_a?(Numeric)
|
|
298
304
|
options[:greater_than] = @constraints[:exclusive_minimum] if @constraints[:exclusive_minimum].is_a?(Numeric)
|
|
299
305
|
options[:less_than] = @constraints[:exclusive_maximum] if @constraints[:exclusive_maximum].is_a?(Numeric)
|
|
306
|
+
options[:allow_nil] = true if allow_nil?
|
|
300
307
|
|
|
301
308
|
@klass.validates @property_name, numericality: options
|
|
302
309
|
|
|
@@ -407,16 +414,23 @@ module EasyTalk
|
|
|
407
414
|
# Get inner type from T::Types::TypedArray (uses .type, which returns T::Types::Simple)
|
|
408
415
|
inner_type_wrapper = type.type
|
|
409
416
|
inner_type = inner_type_wrapper.respond_to?(:raw_type) ? inner_type_wrapper.raw_type : inner_type_wrapper
|
|
417
|
+
is_boolean = TypeIntrospection.boolean_type?(inner_type)
|
|
410
418
|
prop_name = @property_name
|
|
411
|
-
is_easy_talk_model = inner_type.is_a?(Class) && inner_type.include?(EasyTalk::Model)
|
|
419
|
+
is_easy_talk_model = !is_boolean && inner_type.is_a?(Class) && inner_type.include?(EasyTalk::Model)
|
|
412
420
|
|
|
413
421
|
@klass.validate do |record|
|
|
414
422
|
value = record.public_send(prop_name)
|
|
415
423
|
next unless value.is_a?(Array)
|
|
416
424
|
|
|
417
425
|
value.each_with_index do |item, index|
|
|
418
|
-
|
|
419
|
-
|
|
426
|
+
type_match = if is_boolean
|
|
427
|
+
[true, false].include?(item)
|
|
428
|
+
else
|
|
429
|
+
item.is_a?(inner_type)
|
|
430
|
+
end
|
|
431
|
+
unless type_match
|
|
432
|
+
type_label = is_boolean ? 'Boolean' : inner_type.to_s
|
|
433
|
+
record.errors.add(prop_name, "item at index #{index} must be a #{type_label}")
|
|
420
434
|
next
|
|
421
435
|
end
|
|
422
436
|
|
|
@@ -433,15 +447,11 @@ module EasyTalk
|
|
|
433
447
|
|
|
434
448
|
# Validate boolean-specific constraints
|
|
435
449
|
def apply_boolean_validations
|
|
436
|
-
#
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
@klass.validates @property_name, inclusion: { in: [true, false] }
|
|
442
|
-
# Add custom validation for nil values that provides the "can't be blank" message
|
|
443
|
-
apply_boolean_presence_validation
|
|
444
|
-
end
|
|
450
|
+
# allow_nil? covers both optional and T.nilable — nil is explicitly permitted in both cases.
|
|
451
|
+
@klass.validates @property_name, inclusion: { in: [true, false] }, allow_nil: allow_nil?
|
|
452
|
+
|
|
453
|
+
# For required non-nilable booleans, add a custom nil check with the "can't be blank" message.
|
|
454
|
+
apply_boolean_presence_validation unless allow_nil?
|
|
445
455
|
|
|
446
456
|
# Add type validation to ensure the value is actually a boolean
|
|
447
457
|
apply_boolean_type_validation
|
|
@@ -482,33 +492,22 @@ module EasyTalk
|
|
|
482
492
|
@klass.validate do |record|
|
|
483
493
|
nested_object = record.public_send(prop_name)
|
|
484
494
|
|
|
485
|
-
#
|
|
495
|
+
# Nil is handled by the outer presence validation — nothing to do here.
|
|
486
496
|
next unless nested_object
|
|
487
497
|
|
|
488
|
-
# Check if the object is of the expected type (e.g., an actual Email instance)
|
|
489
498
|
if nested_object.is_a?(expected_type)
|
|
490
|
-
#
|
|
491
|
-
#
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
# Treat as blank and add a presence error to the parent field
|
|
500
|
-
record.errors.add(prop_name, "can't be blank")
|
|
501
|
-
elsif !nested_object.valid?
|
|
502
|
-
# If it's the correct type and not empty, validate it
|
|
503
|
-
# Merge errors from the nested object into the parent
|
|
504
|
-
nested_object.errors.each do |error|
|
|
505
|
-
# Prefix the attribute name (e.g., 'email.address')
|
|
506
|
-
nested_key = "#{prop_name}.#{error.attribute}"
|
|
507
|
-
record.errors.add(nested_key.to_sym, error.message)
|
|
508
|
-
end
|
|
499
|
+
# Delegate entirely to the nested model's own validity.
|
|
500
|
+
# The outer presence validation already handles the nil case, so there
|
|
501
|
+
# is no need for a secondary "blank" heuristic here.
|
|
502
|
+
next if nested_object.valid?
|
|
503
|
+
|
|
504
|
+
# Propagate nested errors into the parent with a dotted-path prefix.
|
|
505
|
+
nested_object.errors.each do |error|
|
|
506
|
+
nested_key = "#{prop_name}.#{error.attribute}"
|
|
507
|
+
record.errors.add(nested_key.to_sym, error.message)
|
|
509
508
|
end
|
|
510
509
|
else
|
|
511
|
-
#
|
|
510
|
+
# Value is present but is not the expected type.
|
|
512
511
|
record.errors.add(prop_name, "must be a valid #{expected_type.name}")
|
|
513
512
|
end
|
|
514
513
|
end
|
|
@@ -519,7 +518,7 @@ module EasyTalk
|
|
|
519
518
|
@klass.validates @property_name, inclusion: {
|
|
520
519
|
in: @constraints[:enum],
|
|
521
520
|
message: "must be one of: #{@constraints[:enum].join(', ')}",
|
|
522
|
-
allow_nil:
|
|
521
|
+
allow_nil: allow_nil?
|
|
523
522
|
}
|
|
524
523
|
end
|
|
525
524
|
|
|
@@ -560,7 +559,7 @@ module EasyTalk
|
|
|
560
559
|
end
|
|
561
560
|
|
|
562
561
|
def array_requires_presence_validation?
|
|
563
|
-
@array_type && !@nilable
|
|
562
|
+
@array_type && !@nilable && !@optional
|
|
564
563
|
end
|
|
565
564
|
|
|
566
565
|
def tuple_type?
|
|
@@ -608,6 +607,7 @@ module EasyTalk
|
|
|
608
607
|
|
|
609
608
|
return if length_options.empty?
|
|
610
609
|
|
|
610
|
+
length_options[:allow_nil] = true if allow_nil?
|
|
611
611
|
@klass.validates @property_name, length: length_options
|
|
612
612
|
end
|
|
613
613
|
|
|
@@ -94,62 +94,30 @@ module EasyTalk
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
# Check if the type is nilable (e.g., T.nilable(String)).
|
|
97
|
+
# Delegates to TypeIntrospection.
|
|
97
98
|
#
|
|
98
|
-
# @param
|
|
99
|
+
# @param type_to_check [Class, Object] The type to check (defaults to @type)
|
|
99
100
|
# @return [Boolean] true if the type is nilable
|
|
100
101
|
def nilable_type?(type_to_check = @type)
|
|
101
|
-
|
|
102
|
+
TypeIntrospection.nilable_type?(type_to_check)
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
# Extract the inner type from a complex type like T.nilable(String) or T.nilable(T::Array[Model]).
|
|
106
|
+
# Delegates to TypeIntrospection.
|
|
105
107
|
#
|
|
106
108
|
# @param type_to_unwrap [Class, Object] The type to unwrap (defaults to @type)
|
|
107
109
|
# @return [Class, Object] The inner type, or the original type if not wrapped
|
|
108
110
|
def extract_inner_type(type_to_unwrap = @type)
|
|
109
|
-
|
|
110
|
-
unwrapped = type_to_unwrap.unwrap_nilable
|
|
111
|
-
# Return TypedArray directly (for T.nilable(T::Array[Model]))
|
|
112
|
-
return unwrapped if unwrapped.is_a?(T::Types::TypedArray)
|
|
113
|
-
# Return raw_type for simple types (for T.nilable(String))
|
|
114
|
-
return unwrapped.raw_type if unwrapped.respond_to?(:raw_type)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
if type_to_unwrap.respond_to?(:types)
|
|
118
|
-
# For union types, find the non-nil type
|
|
119
|
-
# Prefer TypedArray if present, otherwise find type with raw_type
|
|
120
|
-
type_to_unwrap.types.find { |t| t.is_a?(T::Types::TypedArray) } ||
|
|
121
|
-
type_to_unwrap.types.find { |t| t.respond_to?(:raw_type) && t.raw_type != NilClass }
|
|
122
|
-
else
|
|
123
|
-
type_to_unwrap
|
|
124
|
-
end
|
|
111
|
+
TypeIntrospection.extract_inner_type(type_to_unwrap)
|
|
125
112
|
end
|
|
126
113
|
|
|
127
114
|
# Determine the actual class for a type, handling Sorbet types.
|
|
115
|
+
# Delegates to TypeIntrospection.
|
|
128
116
|
#
|
|
129
117
|
# @param type_to_resolve [Class, Object] The type to resolve
|
|
130
118
|
# @return [Class, Array<Class>] The resolved class or classes
|
|
131
119
|
def get_type_class(type_to_resolve)
|
|
132
|
-
|
|
133
|
-
type_to_resolve
|
|
134
|
-
elsif type_to_resolve.respond_to?(:raw_type)
|
|
135
|
-
type_to_resolve.raw_type
|
|
136
|
-
elsif type_to_resolve.is_a?(T::Types::TypedArray)
|
|
137
|
-
Array
|
|
138
|
-
elsif type_to_resolve.is_a?(EasyTalk::Types::Tuple)
|
|
139
|
-
Array
|
|
140
|
-
elsif type_to_resolve.is_a?(Symbol) || type_to_resolve.is_a?(String)
|
|
141
|
-
begin
|
|
142
|
-
type_to_resolve.to_s.classify.constantize
|
|
143
|
-
rescue StandardError
|
|
144
|
-
String
|
|
145
|
-
end
|
|
146
|
-
elsif TypeIntrospection.boolean_type?(type_to_resolve)
|
|
147
|
-
[TrueClass, FalseClass]
|
|
148
|
-
elsif nilable_type?(type_to_resolve)
|
|
149
|
-
extract_inner_type(type_to_resolve)
|
|
150
|
-
else
|
|
151
|
-
String
|
|
152
|
-
end
|
|
120
|
+
TypeIntrospection.get_type_class(type_to_resolve)
|
|
153
121
|
end
|
|
154
122
|
end
|
|
155
123
|
end
|
data/lib/easy_talk/version.rb
CHANGED
data/lib/easy_talk.rb
CHANGED
|
@@ -21,9 +21,10 @@ module EasyTalk
|
|
|
21
21
|
# Builder registry for pluggable type support
|
|
22
22
|
require 'easy_talk/builders/registry'
|
|
23
23
|
|
|
24
|
+
require 'easy_talk/property'
|
|
25
|
+
require 'easy_talk/schema_base'
|
|
24
26
|
require 'easy_talk/model'
|
|
25
27
|
require 'easy_talk/schema'
|
|
26
|
-
require 'easy_talk/property'
|
|
27
28
|
require 'easy_talk/schema_definition'
|
|
28
29
|
require 'easy_talk/validation_builder'
|
|
29
30
|
require 'easy_talk/error_formatter'
|
|
@@ -57,6 +58,8 @@ module EasyTalk
|
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
def self.assert_valid_property_options(property_name, options, *valid_keys)
|
|
61
|
+
return if options.nil?
|
|
62
|
+
|
|
60
63
|
valid_keys.flatten!
|
|
61
64
|
options.each_key do |k|
|
|
62
65
|
next if valid_keys.include?(k)
|
|
@@ -68,4 +71,23 @@ module EasyTalk
|
|
|
68
71
|
def self.configure_nilable_behavior(nilable_is_optional = false)
|
|
69
72
|
configuration.nilable_is_optional = nilable_is_optional
|
|
70
73
|
end
|
|
74
|
+
|
|
75
|
+
# Deep duplicates a value, recursing into Hashes and Arrays.
|
|
76
|
+
# Class and Module objects are returned as-is since they represent types
|
|
77
|
+
# and cannot (and should not) be duplicated.
|
|
78
|
+
#
|
|
79
|
+
# @param obj [Object] The value to deep duplicate.
|
|
80
|
+
# @return [Object] A deep copy of obj.
|
|
81
|
+
def self.deep_dup(obj)
|
|
82
|
+
case obj
|
|
83
|
+
when Hash
|
|
84
|
+
obj.transform_values { |v| deep_dup(v) }
|
|
85
|
+
when Array
|
|
86
|
+
obj.map { |v| deep_dup(v) }
|
|
87
|
+
when Class, Module
|
|
88
|
+
obj
|
|
89
|
+
else
|
|
90
|
+
obj.duplicable? ? obj.dup : obj
|
|
91
|
+
end
|
|
92
|
+
end
|
|
71
93
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: easy_talk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.3.
|
|
4
|
+
version: 3.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergio Bayona
|
|
@@ -106,6 +106,10 @@ files:
|
|
|
106
106
|
- docs/primitive-schema-rfc.md
|
|
107
107
|
- docs/property-types.markdown
|
|
108
108
|
- docs/schema-definition.markdown
|
|
109
|
+
- easy_talk.gemspec
|
|
110
|
+
- examples/ruby_llm/Gemfile
|
|
111
|
+
- examples/ruby_llm/structured_output.rb
|
|
112
|
+
- examples/ruby_llm/tools_integration.rb
|
|
109
113
|
- lib/easy_talk.rb
|
|
110
114
|
- lib/easy_talk/builders/base_builder.rb
|
|
111
115
|
- lib/easy_talk/builders/boolean_builder.rb
|
|
@@ -132,6 +136,7 @@ files:
|
|
|
132
136
|
- lib/easy_talk/error_formatter/rfc7807.rb
|
|
133
137
|
- lib/easy_talk/errors.rb
|
|
134
138
|
- lib/easy_talk/errors_helper.rb
|
|
139
|
+
- lib/easy_talk/extensions/ruby_llm_compatibility.rb
|
|
135
140
|
- lib/easy_talk/json_schema_equality.rb
|
|
136
141
|
- lib/easy_talk/keywords.rb
|
|
137
142
|
- lib/easy_talk/model.rb
|
|
@@ -140,6 +145,7 @@ files:
|
|
|
140
145
|
- lib/easy_talk/property.rb
|
|
141
146
|
- lib/easy_talk/ref_helper.rb
|
|
142
147
|
- lib/easy_talk/schema.rb
|
|
148
|
+
- lib/easy_talk/schema_base.rb
|
|
143
149
|
- lib/easy_talk/schema_definition.rb
|
|
144
150
|
- lib/easy_talk/schema_methods.rb
|
|
145
151
|
- lib/easy_talk/sorbet_extension.rb
|