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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -39
  3. data/.yardopts +13 -0
  4. data/CHANGELOG.md +164 -0
  5. data/README.md +442 -1529
  6. data/Rakefile +27 -0
  7. data/docs/.gitignore +1 -0
  8. data/docs/about.markdown +28 -8
  9. data/docs/getting-started.markdown +102 -0
  10. data/docs/index.markdown +51 -4
  11. data/docs/json_schema_compliance.md +169 -0
  12. data/docs/nested-models.markdown +216 -0
  13. data/docs/primitive-schema-rfc.md +894 -0
  14. data/docs/property-types.markdown +212 -0
  15. data/docs/schema-definition.markdown +180 -0
  16. data/lib/easy_talk/builders/base_builder.rb +6 -3
  17. data/lib/easy_talk/builders/boolean_builder.rb +2 -1
  18. data/lib/easy_talk/builders/collection_helpers.rb +4 -0
  19. data/lib/easy_talk/builders/composition_builder.rb +16 -13
  20. data/lib/easy_talk/builders/integer_builder.rb +2 -1
  21. data/lib/easy_talk/builders/null_builder.rb +4 -1
  22. data/lib/easy_talk/builders/number_builder.rb +4 -1
  23. data/lib/easy_talk/builders/object_builder.rb +109 -33
  24. data/lib/easy_talk/builders/registry.rb +182 -0
  25. data/lib/easy_talk/builders/string_builder.rb +3 -1
  26. data/lib/easy_talk/builders/temporal_builder.rb +7 -0
  27. data/lib/easy_talk/builders/tuple_builder.rb +89 -0
  28. data/lib/easy_talk/builders/typed_array_builder.rb +19 -6
  29. data/lib/easy_talk/builders/union_builder.rb +5 -1
  30. data/lib/easy_talk/configuration.rb +47 -2
  31. data/lib/easy_talk/error_formatter/base.rb +100 -0
  32. data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
  33. data/lib/easy_talk/error_formatter/flat.rb +38 -0
  34. data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
  35. data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
  36. data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
  37. data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
  38. data/lib/easy_talk/error_formatter.rb +143 -0
  39. data/lib/easy_talk/errors.rb +3 -0
  40. data/lib/easy_talk/errors_helper.rb +66 -34
  41. data/lib/easy_talk/json_schema_equality.rb +46 -0
  42. data/lib/easy_talk/keywords.rb +0 -1
  43. data/lib/easy_talk/model.rb +148 -89
  44. data/lib/easy_talk/model_helper.rb +17 -0
  45. data/lib/easy_talk/naming_strategies.rb +24 -0
  46. data/lib/easy_talk/property.rb +23 -94
  47. data/lib/easy_talk/ref_helper.rb +33 -0
  48. data/lib/easy_talk/schema.rb +199 -0
  49. data/lib/easy_talk/schema_definition.rb +57 -5
  50. data/lib/easy_talk/schema_methods.rb +111 -0
  51. data/lib/easy_talk/sorbet_extension.rb +1 -0
  52. data/lib/easy_talk/tools/function_builder.rb +1 -1
  53. data/lib/easy_talk/type_introspection.rb +222 -0
  54. data/lib/easy_talk/types/base_composer.rb +2 -1
  55. data/lib/easy_talk/types/composer.rb +4 -0
  56. data/lib/easy_talk/types/tuple.rb +77 -0
  57. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
  58. data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
  59. data/lib/easy_talk/validation_adapters/base.rb +156 -0
  60. data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
  61. data/lib/easy_talk/validation_adapters/registry.rb +87 -0
  62. data/lib/easy_talk/validation_builder.rb +29 -309
  63. data/lib/easy_talk/version.rb +1 -1
  64. data/lib/easy_talk.rb +42 -0
  65. metadata +38 -7
  66. data/docs/404.html +0 -25
  67. data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
  68. data/easy_talk.gemspec +0 -39
@@ -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 builder
130
- args = builder.collection_type? ? [name, type, constraints] : [name, constraints]
131
- builder.new(*args).build
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
- 'object'
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
- # First checks if there's a builder for the class name, then falls back
169
- # to checking if there's a builder for the type's name (if it responds to :name).
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] The builder class for this type, or nil if none matches
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
- TYPE_TO_BUILDER[type.class.name.to_s] ||
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 easytalk_model?(actual_type) && should_use_ref_for_type?(actual_type)
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] = 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
29
+ @property_naming_strategy = EasyTalk.configuration.property_naming_strategy
27
30
  end
28
31
 
29
32
  EasyTalk::KEYWORDS.each do |keyword|
30
- define_method(keyword) do |*values|
31
- @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
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
- 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)
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
@@ -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
@@ -27,7 +27,7 @@ module EasyTalk
27
27
 
28
28
  def generate_function_description(model)
29
29
  if model.respond_to?(:instructions)
30
- raise Instructor::Error, 'The instructions must be a string' unless model.instructions.is_a?(String)
30
+ raise InvalidInstructionsError, 'The instructions must be a string' unless model.instructions.is_a?(String)
31
31
 
32
32
  model.instructions
33
33
  else