easy_talk 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -39
- data/.yardopts +13 -0
- data/CHANGELOG.md +164 -0
- data/README.md +442 -1529
- data/Rakefile +27 -0
- data/docs/.gitignore +1 -0
- data/docs/about.markdown +28 -8
- data/docs/getting-started.markdown +102 -0
- data/docs/index.markdown +51 -4
- data/docs/json_schema_compliance.md +169 -0
- data/docs/nested-models.markdown +216 -0
- data/docs/primitive-schema-rfc.md +894 -0
- data/docs/property-types.markdown +212 -0
- data/docs/schema-definition.markdown +180 -0
- data/lib/easy_talk/builders/base_builder.rb +6 -3
- 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 +16 -13
- 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 +109 -33
- data/lib/easy_talk/builders/registry.rb +182 -0
- 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 +19 -6
- data/lib/easy_talk/builders/union_builder.rb +5 -1
- data/lib/easy_talk/configuration.rb +47 -2
- data/lib/easy_talk/error_formatter/base.rb +100 -0
- data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
- data/lib/easy_talk/error_formatter/flat.rb +38 -0
- data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
- data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
- data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
- data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
- data/lib/easy_talk/error_formatter.rb +143 -0
- data/lib/easy_talk/errors.rb +3 -0
- data/lib/easy_talk/errors_helper.rb +66 -34
- data/lib/easy_talk/json_schema_equality.rb +46 -0
- data/lib/easy_talk/keywords.rb +0 -1
- data/lib/easy_talk/model.rb +148 -89
- data/lib/easy_talk/model_helper.rb +17 -0
- data/lib/easy_talk/naming_strategies.rb +24 -0
- data/lib/easy_talk/property.rb +23 -94
- data/lib/easy_talk/ref_helper.rb +33 -0
- data/lib/easy_talk/schema.rb +199 -0
- data/lib/easy_talk/schema_definition.rb +57 -5
- data/lib/easy_talk/schema_methods.rb +111 -0
- data/lib/easy_talk/sorbet_extension.rb +1 -0
- data/lib/easy_talk/tools/function_builder.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +222 -0
- data/lib/easy_talk/types/base_composer.rb +2 -1
- data/lib/easy_talk/types/composer.rb +4 -0
- data/lib/easy_talk/types/tuple.rb +77 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
- data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
- data/lib/easy_talk/validation_adapters/base.rb +156 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
- data/lib/easy_talk/validation_adapters/registry.rb +87 -0
- data/lib/easy_talk/validation_builder.rb +29 -309
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +42 -0
- metadata +38 -7
- data/docs/404.html +0 -25
- data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
- data/easy_talk.gemspec +0 -39
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.
|
|
@@ -45,28 +47,6 @@ module EasyTalk
|
|
|
45
47
|
# @return [Hash<Symbol, Object>] Additional constraints applied to the property
|
|
46
48
|
attr_reader :constraints
|
|
47
49
|
|
|
48
|
-
# Mapping of Ruby type names to their corresponding schema builder classes.
|
|
49
|
-
# Each builder knows how to convert a specific Ruby type to JSON Schema.
|
|
50
|
-
#
|
|
51
|
-
# @api private
|
|
52
|
-
TYPE_TO_BUILDER = {
|
|
53
|
-
'String' => Builders::StringBuilder,
|
|
54
|
-
'Integer' => Builders::IntegerBuilder,
|
|
55
|
-
'Float' => Builders::NumberBuilder,
|
|
56
|
-
'BigDecimal' => Builders::NumberBuilder,
|
|
57
|
-
'T::Boolean' => Builders::BooleanBuilder,
|
|
58
|
-
'TrueClass' => Builders::BooleanBuilder,
|
|
59
|
-
'NilClass' => Builders::NullBuilder,
|
|
60
|
-
'Date' => Builders::TemporalBuilder::DateBuilder,
|
|
61
|
-
'DateTime' => Builders::TemporalBuilder::DatetimeBuilder,
|
|
62
|
-
'Time' => Builders::TemporalBuilder::TimeBuilder,
|
|
63
|
-
'anyOf' => Builders::CompositionBuilder::AnyOfBuilder,
|
|
64
|
-
'allOf' => Builders::CompositionBuilder::AllOfBuilder,
|
|
65
|
-
'oneOf' => Builders::CompositionBuilder::OneOfBuilder,
|
|
66
|
-
'T::Types::TypedArray' => Builders::TypedArrayBuilder,
|
|
67
|
-
'T::Types::Union' => Builders::UnionBuilder
|
|
68
|
-
}.freeze
|
|
69
|
-
|
|
70
50
|
# Initializes a new instance of the Property class.
|
|
71
51
|
#
|
|
72
52
|
# @param name [Symbol] The name of the property
|
|
@@ -121,20 +101,24 @@ module EasyTalk
|
|
|
121
101
|
# @example Nested schema with $ref
|
|
122
102
|
# property = Property.new(:shipping_address, Address, ref: true)
|
|
123
103
|
# property.build # => {"$ref"=>"#/$defs/Address", ...constraints}
|
|
104
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
124
105
|
def build
|
|
125
106
|
if nilable_type?
|
|
126
107
|
build_nilable_schema
|
|
127
|
-
elsif should_use_ref?
|
|
128
|
-
build_ref_schema
|
|
129
|
-
elsif
|
|
130
|
-
|
|
131
|
-
|
|
108
|
+
elsif RefHelper.should_use_ref?(type, constraints)
|
|
109
|
+
RefHelper.build_ref_schema(type, constraints)
|
|
110
|
+
elsif (resolved = find_builder_for_type)
|
|
111
|
+
builder_class, is_collection = resolved
|
|
112
|
+
args = is_collection ? [name, type, constraints] : [name, constraints]
|
|
113
|
+
builder_class.new(*args).build
|
|
132
114
|
elsif type.respond_to?(:schema)
|
|
133
115
|
# merge the top-level constraints from *this* property
|
|
134
116
|
# e.g. :title, :description, :default, etc
|
|
135
117
|
type.schema.merge!(constraints)
|
|
136
118
|
else
|
|
137
|
-
|
|
119
|
+
raise UnknownTypeError,
|
|
120
|
+
"Unknown type '#{type.inspect}' for property '#{name}'. " \
|
|
121
|
+
'Register a custom builder with EasyTalk.register_type or use a supported type.'
|
|
138
122
|
end
|
|
139
123
|
end
|
|
140
124
|
|
|
@@ -147,32 +131,24 @@ module EasyTalk
|
|
|
147
131
|
#
|
|
148
132
|
# @see #build
|
|
149
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) }
|
|
150
135
|
def as_json(*_args)
|
|
151
136
|
build.as_json
|
|
152
137
|
end
|
|
153
138
|
|
|
154
|
-
# Returns the builder class associated with the property type.
|
|
155
|
-
#
|
|
156
|
-
# The builder is responsible for converting the Ruby type to a JSON Schema type.
|
|
157
|
-
#
|
|
158
|
-
# @return [Class, nil] The builder class for this property's type, or nil if no dedicated builder exists
|
|
159
|
-
# @see #find_builder_for_type
|
|
160
|
-
def builder
|
|
161
|
-
@builder ||= find_builder_for_type
|
|
162
|
-
end
|
|
163
|
-
|
|
164
139
|
private
|
|
165
140
|
|
|
166
|
-
# Finds the appropriate builder for the current type.
|
|
141
|
+
# Finds the appropriate builder for the current type using the Builders::Registry.
|
|
167
142
|
#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
143
|
+
# The registry is checked for a matching builder based on the type's class name
|
|
144
|
+
# or the type's own name.
|
|
170
145
|
#
|
|
171
|
-
# @return [Class, nil]
|
|
146
|
+
# @return [Array(Class, Boolean), nil] A tuple of [builder_class, is_collection] or nil if none matches
|
|
172
147
|
# @api private
|
|
148
|
+
# @see Builders::Registry.resolve
|
|
149
|
+
sig { returns(T.nilable(T::Array[T.untyped])) }
|
|
173
150
|
def find_builder_for_type
|
|
174
|
-
|
|
175
|
-
(type.respond_to?(:name) ? TYPE_TO_BUILDER[type.name.to_s] : nil)
|
|
151
|
+
Builders::Registry.resolve(type)
|
|
176
152
|
end
|
|
177
153
|
|
|
178
154
|
# Determines if the type is nilable (can be nil).
|
|
@@ -182,6 +158,7 @@ module EasyTalk
|
|
|
182
158
|
#
|
|
183
159
|
# @return [Boolean] true if the type is nilable, false otherwise
|
|
184
160
|
# @api private
|
|
161
|
+
sig { returns(T::Boolean) }
|
|
185
162
|
def nilable_type?
|
|
186
163
|
return false unless type.respond_to?(:types)
|
|
187
164
|
return false unless type.types.all? { |t| t.respond_to?(:raw_type) }
|
|
@@ -196,6 +173,7 @@ module EasyTalk
|
|
|
196
173
|
# @example
|
|
197
174
|
# # For a T.nilable(String) type:
|
|
198
175
|
# {"type"=>["string", "null"]}
|
|
176
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
199
177
|
def build_nilable_schema
|
|
200
178
|
# Extract the non-nil type from the Union
|
|
201
179
|
actual_type = T::Utils::Nilable.get_underlying_type(type)
|
|
@@ -203,7 +181,7 @@ module EasyTalk
|
|
|
203
181
|
return { type: 'null' } unless actual_type
|
|
204
182
|
|
|
205
183
|
# Check if the underlying type is an EasyTalk model that should use $ref
|
|
206
|
-
if
|
|
184
|
+
if RefHelper.should_use_ref_for_type?(actual_type, constraints)
|
|
207
185
|
# Use anyOf with $ref and null type
|
|
208
186
|
ref_constraints = constraints.except(:ref, :optional)
|
|
209
187
|
schema = { anyOf: [{ '$ref': actual_type.ref_template }, { type: 'null' }] }
|
|
@@ -218,54 +196,5 @@ module EasyTalk
|
|
|
218
196
|
type: [non_nil_schema[:type], 'null'].compact
|
|
219
197
|
)
|
|
220
198
|
end
|
|
221
|
-
|
|
222
|
-
# Determines if $ref should be used for the current type.
|
|
223
|
-
#
|
|
224
|
-
# @return [Boolean] true if $ref should be used, false otherwise
|
|
225
|
-
# @api private
|
|
226
|
-
def should_use_ref?
|
|
227
|
-
return false unless easytalk_model?(type)
|
|
228
|
-
|
|
229
|
-
should_use_ref_for_type?(type)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# Determines if $ref should be used for a given type based on constraints and config.
|
|
233
|
-
#
|
|
234
|
-
# @param check_type [Class] The type to check
|
|
235
|
-
# @return [Boolean] true if $ref should be used, false otherwise
|
|
236
|
-
# @api private
|
|
237
|
-
def should_use_ref_for_type?(check_type)
|
|
238
|
-
return false unless easytalk_model?(check_type)
|
|
239
|
-
|
|
240
|
-
# Per-property constraint takes precedence
|
|
241
|
-
return constraints[:ref] if constraints.key?(:ref)
|
|
242
|
-
|
|
243
|
-
# Fall back to global configuration
|
|
244
|
-
EasyTalk.configuration.use_refs
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
# Checks if a type is an EasyTalk model.
|
|
248
|
-
#
|
|
249
|
-
# @param check_type [Object] The type to check
|
|
250
|
-
# @return [Boolean] true if the type is an EasyTalk model
|
|
251
|
-
# @api private
|
|
252
|
-
def easytalk_model?(check_type)
|
|
253
|
-
check_type.is_a?(Class) &&
|
|
254
|
-
check_type.respond_to?(:schema) &&
|
|
255
|
-
check_type.respond_to?(:ref_template) &&
|
|
256
|
-
defined?(EasyTalk::Model) &&
|
|
257
|
-
check_type.include?(EasyTalk::Model)
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
# Builds a $ref schema for an EasyTalk model.
|
|
261
|
-
#
|
|
262
|
-
# @return [Hash] A schema with $ref pointing to the model's definition
|
|
263
|
-
# @api private
|
|
264
|
-
def build_ref_schema
|
|
265
|
-
# Remove ref and optional from constraints as they're not JSON Schema keywords
|
|
266
|
-
ref_constraints = constraints.except(:ref, :optional)
|
|
267
|
-
schema = { '$ref': type.ref_template }
|
|
268
|
-
ref_constraints.empty? ? schema : schema.merge(ref_constraints)
|
|
269
|
-
end
|
|
270
199
|
end
|
|
271
200
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require_relative 'model_helper'
|
|
5
|
+
|
|
6
|
+
module EasyTalk
|
|
7
|
+
module RefHelper
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
|
11
|
+
def self.should_use_ref?(type, constraints)
|
|
12
|
+
ModelHelper.easytalk_model?(type) && should_use_ref_for_type?(type, constraints)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
|
16
|
+
def self.should_use_ref_for_type?(type, constraints)
|
|
17
|
+
return false unless ModelHelper.easytalk_model?(type)
|
|
18
|
+
|
|
19
|
+
# Per-property constraint takes precedence
|
|
20
|
+
if constraints.key?(:ref)
|
|
21
|
+
constraints[:ref]
|
|
22
|
+
else
|
|
23
|
+
EasyTalk.configuration.use_refs
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
28
|
+
def self.build_ref_schema(type, constraints)
|
|
29
|
+
# Remove ref and optional from constraints as they're not JSON Schema keywords
|
|
30
|
+
{ '$ref': type.ref_template }.merge(constraints.except(:ref, :optional))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'active_support'
|
|
6
|
+
require 'active_support/core_ext'
|
|
7
|
+
require 'active_support/time'
|
|
8
|
+
require 'active_support/concern'
|
|
9
|
+
require 'active_support/json'
|
|
10
|
+
require_relative 'builders/object_builder'
|
|
11
|
+
require_relative 'schema_definition'
|
|
12
|
+
|
|
13
|
+
module EasyTalk
|
|
14
|
+
# A lightweight module for schema generation without ActiveModel validations.
|
|
15
|
+
#
|
|
16
|
+
# Use this module when you need JSON Schema generation without the overhead
|
|
17
|
+
# of ActiveModel validations. This is ideal for:
|
|
18
|
+
# - API documentation and OpenAPI spec generation
|
|
19
|
+
# - Schema-first design where validation happens elsewhere
|
|
20
|
+
# - High-performance scenarios where validation overhead is unwanted
|
|
21
|
+
# - Generating schemas for external systems
|
|
22
|
+
#
|
|
23
|
+
# Unlike EasyTalk::Model, this module does NOT include ActiveModel::API or
|
|
24
|
+
# ActiveModel::Validations, so instances will not respond to `valid?` or have
|
|
25
|
+
# validation errors.
|
|
26
|
+
#
|
|
27
|
+
# @example Basic usage
|
|
28
|
+
# class ApiContract
|
|
29
|
+
# include EasyTalk::Schema
|
|
30
|
+
#
|
|
31
|
+
# define_schema do
|
|
32
|
+
# title 'API Contract'
|
|
33
|
+
# property :name, String, min_length: 2
|
|
34
|
+
# property :age, Integer, minimum: 0
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# ApiContract.json_schema # => { "type" => "object", ... }
|
|
39
|
+
# contract = ApiContract.new(name: 'Test', age: 25)
|
|
40
|
+
# contract.name # => 'Test'
|
|
41
|
+
# contract.valid? # => NoMethodError (no ActiveModel)
|
|
42
|
+
#
|
|
43
|
+
# @see EasyTalk::Model For a full-featured module with validations
|
|
44
|
+
#
|
|
45
|
+
module Schema
|
|
46
|
+
def self.included(base)
|
|
47
|
+
base.extend(ClassMethods)
|
|
48
|
+
base.include(InstanceMethods)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Instance methods for schema-only models.
|
|
52
|
+
module InstanceMethods
|
|
53
|
+
# Initialize the schema object with attributes.
|
|
54
|
+
#
|
|
55
|
+
# @param attributes [Hash] The attributes to set
|
|
56
|
+
def initialize(attributes = {})
|
|
57
|
+
@additional_properties = {}
|
|
58
|
+
schema_def = self.class.schema_definition
|
|
59
|
+
|
|
60
|
+
return unless schema_def.respond_to?(:schema) && schema_def.schema.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
(schema_def.schema[:properties] || {}).each do |prop_name, prop_definition|
|
|
63
|
+
value = attributes[prop_name] || attributes[prop_name.to_s]
|
|
64
|
+
|
|
65
|
+
# Handle default values
|
|
66
|
+
if value.nil? && !attributes.key?(prop_name) && !attributes.key?(prop_name.to_s)
|
|
67
|
+
default_value = prop_definition.dig(:constraints, :default)
|
|
68
|
+
value = default_value unless default_value.nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Handle nested EasyTalk::Schema or EasyTalk::Model objects
|
|
72
|
+
defined_type = prop_definition[:type]
|
|
73
|
+
nilable_type = defined_type.respond_to?(:nilable?) && defined_type.nilable?
|
|
74
|
+
defined_type = T::Utils::Nilable.get_underlying_type(defined_type) if nilable_type
|
|
75
|
+
|
|
76
|
+
if defined_type.is_a?(Class) &&
|
|
77
|
+
(defined_type.include?(EasyTalk::Schema) || defined_type.include?(EasyTalk::Model)) &&
|
|
78
|
+
value.is_a?(Hash)
|
|
79
|
+
value = defined_type.new(value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
instance_variable_set("@#{prop_name}", value)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Convert defined properties to a hash.
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash] The properties as a hash
|
|
89
|
+
def to_hash
|
|
90
|
+
properties_to_include = (self.class.schema_definition.schema[:properties] || {}).keys
|
|
91
|
+
return {} if properties_to_include.empty?
|
|
92
|
+
|
|
93
|
+
properties_to_include.each_with_object({}) do |prop, hash|
|
|
94
|
+
hash[prop.to_s] = send(prop)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Convert to JSON-compatible hash including additional properties.
|
|
99
|
+
#
|
|
100
|
+
# @param _options [Hash] JSON options (ignored)
|
|
101
|
+
# @return [Hash] The combined hash
|
|
102
|
+
def as_json(_options = {})
|
|
103
|
+
to_hash.merge(@additional_properties)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Convert to hash including additional properties.
|
|
107
|
+
#
|
|
108
|
+
# @return [Hash] The combined hash
|
|
109
|
+
def to_h
|
|
110
|
+
to_hash.merge(@additional_properties)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Allow comparison with hashes.
|
|
114
|
+
#
|
|
115
|
+
# @param other [Object] The object to compare with
|
|
116
|
+
# @return [Boolean] True if equal
|
|
117
|
+
def ==(other)
|
|
118
|
+
case other
|
|
119
|
+
when Hash
|
|
120
|
+
self_hash = (self.class.schema_definition.schema[:properties] || {}).keys.each_with_object({}) do |prop, hash|
|
|
121
|
+
hash[prop] = send(prop)
|
|
122
|
+
end
|
|
123
|
+
other_normalized = other.transform_keys(&:to_sym)
|
|
124
|
+
self_hash == other_normalized
|
|
125
|
+
else
|
|
126
|
+
super
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Class methods for schema-only models.
|
|
132
|
+
module ClassMethods
|
|
133
|
+
include SchemaMethods
|
|
134
|
+
|
|
135
|
+
# Returns the schema for the model.
|
|
136
|
+
#
|
|
137
|
+
# @return [Hash] The schema for the model.
|
|
138
|
+
def schema
|
|
139
|
+
@schema ||= if defined?(@schema_definition) && @schema_definition
|
|
140
|
+
build_schema(@schema_definition)
|
|
141
|
+
else
|
|
142
|
+
{}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Define the schema for the model using the provided block.
|
|
147
|
+
# Unlike EasyTalk::Model, this does NOT apply any validations.
|
|
148
|
+
#
|
|
149
|
+
# @yield The block to define the schema.
|
|
150
|
+
# @raise [ArgumentError] If the class does not have a name.
|
|
151
|
+
def define_schema(&)
|
|
152
|
+
raise ArgumentError, 'The class must have a name' unless name.present?
|
|
153
|
+
|
|
154
|
+
@schema_definition = SchemaDefinition.new(name)
|
|
155
|
+
@schema_definition.klass = self
|
|
156
|
+
@schema_definition.instance_eval(&)
|
|
157
|
+
|
|
158
|
+
# Define accessors for all properties
|
|
159
|
+
defined_properties = (@schema_definition.schema[:properties] || {}).keys
|
|
160
|
+
attr_accessor(*defined_properties)
|
|
161
|
+
|
|
162
|
+
# NO validations are applied - this is schema-only
|
|
163
|
+
|
|
164
|
+
@schema_definition
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Returns the schema definition for the model.
|
|
168
|
+
#
|
|
169
|
+
# @return [SchemaDefinition] The schema definition.
|
|
170
|
+
def schema_definition
|
|
171
|
+
@schema_definition ||= {}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Check if additional properties are allowed.
|
|
175
|
+
#
|
|
176
|
+
# @return [Boolean] True if additional properties are allowed.
|
|
177
|
+
def additional_properties_allowed?
|
|
178
|
+
@schema_definition&.schema&.fetch(:additional_properties, false)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Returns the property names defined in the schema.
|
|
182
|
+
#
|
|
183
|
+
# @return [Array<Symbol>] Array of property names as symbols.
|
|
184
|
+
def properties
|
|
185
|
+
(@schema_definition&.schema&.dig(:properties) || {}).keys
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# Builds the schema using the provided schema definition.
|
|
191
|
+
#
|
|
192
|
+
# @param schema_definition [SchemaDefinition] The schema definition.
|
|
193
|
+
# @return [Hash] The built schema.
|
|
194
|
+
def build_schema(schema_definition)
|
|
195
|
+
Builders::ObjectBuilder.new(schema_definition).build
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -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,26 +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
|
|
29
|
+
@property_naming_strategy = EasyTalk.configuration.property_naming_strategy
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
EasyTalk::KEYWORDS.each do |keyword|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
32
43
|
end
|
|
33
44
|
end
|
|
34
45
|
|
|
46
|
+
sig { params(subschemas: T.untyped).void }
|
|
35
47
|
def compose(*subschemas)
|
|
36
48
|
@schema[:subschemas] ||= []
|
|
37
49
|
@schema[:subschemas] += subschemas
|
|
38
50
|
end
|
|
39
51
|
|
|
40
|
-
|
|
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)
|
|
41
54
|
validate_property_name(name)
|
|
55
|
+
constraints[:as] ||= @property_naming_strategy.call(name)
|
|
42
56
|
@schema[:properties] ||= {}
|
|
43
57
|
|
|
44
58
|
if block_given?
|
|
@@ -49,6 +63,7 @@ module EasyTalk
|
|
|
49
63
|
@schema[:properties][name] = { type:, constraints: }
|
|
50
64
|
end
|
|
51
65
|
|
|
66
|
+
sig { params(name: T.any(Symbol, String)).void }
|
|
52
67
|
def validate_property_name(name)
|
|
53
68
|
return if name.to_s.match?(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
54
69
|
|
|
@@ -57,11 +72,13 @@ module EasyTalk
|
|
|
57
72
|
raise InvalidPropertyNameError, message
|
|
58
73
|
end
|
|
59
74
|
|
|
75
|
+
sig { returns(T.nilable(T::Boolean)) }
|
|
60
76
|
def optional?
|
|
61
77
|
@schema[:optional]
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
# Helper method for nullable and optional properties
|
|
81
|
+
sig { params(name: Symbol, type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).void }
|
|
65
82
|
def nullable_optional_property(name, type, constraints = {})
|
|
66
83
|
# Ensure type is nilable
|
|
67
84
|
nilable_type = if type.respond_to?(:nilable?) && type.nilable?
|
|
@@ -76,5 +93,40 @@ module EasyTalk
|
|
|
76
93
|
# Call standard property method
|
|
77
94
|
property(name, nilable_type, constraints)
|
|
78
95
|
end
|
|
96
|
+
|
|
97
|
+
sig { params(strategy: T.any(Symbol, T.proc.params(arg0: T.untyped).returns(Symbol))).void }
|
|
98
|
+
def property_naming_strategy(strategy)
|
|
99
|
+
@property_naming_strategy = EasyTalk::NamingStrategies.derive_strategy(strategy)
|
|
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
|
|
79
131
|
end
|
|
80
132
|
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
module EasyTalk
|
|
5
|
+
# Shared methods for JSON Schema generation.
|
|
6
|
+
#
|
|
7
|
+
# This module provides common functionality for building JSON schemas,
|
|
8
|
+
# including $schema and $id resolution. It is included in both
|
|
9
|
+
# EasyTalk::Model and EasyTalk::Schema to avoid code duplication.
|
|
10
|
+
#
|
|
11
|
+
# @note Classes including this module must define:
|
|
12
|
+
# - `name` - The class name (used in ref_template)
|
|
13
|
+
# - `schema` - The built schema hash (used in json_schema)
|
|
14
|
+
# - `@schema_definition` - Instance variable with schema metadata
|
|
15
|
+
#
|
|
16
|
+
module SchemaMethods
|
|
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.
|
|
20
|
+
#
|
|
21
|
+
# @return [String] The reference template for the model.
|
|
22
|
+
def ref_template
|
|
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}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the JSON schema for the model.
|
|
31
|
+
# This is the final output that includes the $schema keyword if configured.
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash] The JSON schema for the model.
|
|
34
|
+
def json_schema
|
|
35
|
+
@json_schema ||= build_json_schema
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Builds the final JSON schema with optional $schema and $id keywords.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash] The JSON schema.
|
|
43
|
+
def build_json_schema
|
|
44
|
+
result = schema.as_json
|
|
45
|
+
schema_uri = resolve_schema_uri
|
|
46
|
+
id_uri = resolve_schema_id
|
|
47
|
+
|
|
48
|
+
prefix = {}
|
|
49
|
+
prefix['$schema'] = schema_uri if schema_uri
|
|
50
|
+
prefix['$id'] = id_uri if id_uri
|
|
51
|
+
|
|
52
|
+
return result if prefix.empty?
|
|
53
|
+
|
|
54
|
+
prefix.merge(result)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Resolves the schema URI from per-model setting or global config.
|
|
58
|
+
#
|
|
59
|
+
# @return [String, nil] The schema URI.
|
|
60
|
+
def resolve_schema_uri
|
|
61
|
+
model_version = @schema_definition&.schema&.dig(:schema_version)
|
|
62
|
+
|
|
63
|
+
if model_version
|
|
64
|
+
return nil if model_version == :none
|
|
65
|
+
|
|
66
|
+
Configuration::SCHEMA_VERSIONS[model_version] || model_version.to_s
|
|
67
|
+
else
|
|
68
|
+
EasyTalk.configuration.schema_uri
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
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)
|
|
77
|
+
#
|
|
78
|
+
# @return [String, nil] The schema ID.
|
|
79
|
+
def resolve_schema_id
|
|
80
|
+
model_id = @schema_definition&.schema&.dig(:schema_id)
|
|
81
|
+
|
|
82
|
+
# Per-model explicit ID takes precedence
|
|
83
|
+
if model_id
|
|
84
|
+
return nil if model_id == :none
|
|
85
|
+
|
|
86
|
+
return model_id.to_s
|
|
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}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -27,7 +27,7 @@ module EasyTalk
|
|
|
27
27
|
|
|
28
28
|
def generate_function_description(model)
|
|
29
29
|
if model.respond_to?(:instructions)
|
|
30
|
-
raise
|
|
30
|
+
raise InvalidInstructionsError, 'The instructions must be a string' unless model.instructions.is_a?(String)
|
|
31
31
|
|
|
32
32
|
model.instructions
|
|
33
33
|
else
|