easy_talk 0.2.2 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4dbef0c74efa9996294e1aa20f5beef89d7d418df8be196cd794ece88c5ec67
4
- data.tar.gz: 79b1c90879f26967e94e56317fff93e39bc1735e41a5c5975a3a922e556c2291
3
+ metadata.gz: fa39fe0359df9334a186807b3e67679b752806db59eb9b03829ec875c6382818
4
+ data.tar.gz: 150814754a0604fc0149bf042c73d798fe042935ffde11b52c2254ac765c05e8
5
5
  SHA512:
6
- metadata.gz: 053a079d3b233f70bd62f63708c008b2da133d2210dfdde206708a588387ee844cab5e8409a5ac7afc635ccb909dbfd82b952e6608ec79333781f2278b9c56ce
7
- data.tar.gz: 9af64bd32362f6518c2576aa793047b4252e47b18a92e0edfae9b937c92d56c63ba0582146ddc86823c0cacd98ead9e4ecb83f01624e22ad93cf58d36b42c4d0
6
+ metadata.gz: ea9d64a999260983afac690850ae5095b4e2d00583feb1a6dd4baa0a0cb377a82566dae1245e1b767e5ff79549f28e60209b43fa3d765a8b51c46ee6969425bd
7
+ data.tar.gz: 20e3bea29ad389126937924f431f8729be43b172f8f868ae5fc0189d729e1d19642a9fa0e74a34322e1acafd2b74c6b2fad20bb8f081c7ea504364a2daaf3d99
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [1.0.0] - 2024-06-01
2
+ - Use `Hash` instead of `:object` for inline object schema definition.
3
+ example:
4
+ ```ruby
5
+ property :email, Hash do
6
+ property :address, :string
7
+ property :verified, :boolean
8
+ end
9
+ ```
10
+ - Loosen up the gemspec version requirement. Makes it flexible to use the library with future versions of Rails (i.e 8.*).
11
+ - Removed JSONScheemer gem dependency.
12
+ - The library does not validate by default anymore. Validating an instance requires that you explicitly define ActiveModel validations in your EasyTalk model. See: https://github.com/sergiobayona/easy_talk/blob/main/spec/easy_talk/activemodel_integration_spec.rb.
13
+ - Internal improvements to `EasyTalk::ObjectBuilder` class. No changes to the public API.
14
+ - Expanded the test suite.
15
+
1
16
  ## [0.2.2] - 2024-05-17
2
17
  - Fixed a bug where optional properties were not excluded from the required list.
3
18
 
data/README.md CHANGED
@@ -4,10 +4,9 @@ EasyTalk is a Ruby library that simplifies defining and generating JSON Schema d
4
4
 
5
5
  Key Features
6
6
  * Intuitive Schema Definition: Use Ruby classes and methods to define JSON Schema documents easily.
7
- * JSON Schema Compliance: Implements the JSON Schema specification to ensure compatibility and standards adherence.
8
- * LLM Function Support: Ideal for integrating with Large Language Models (LLMs) such as OpenAI's GPT-3.5-turbo and GPT-4. EasyTalk enables you to effortlessly create JSON Schema documents needed to describe the inputs and outputs of LLM function calls.
9
- * Validation: Validates JSON inputs and outputs against defined schemas to ensure they meet expected formats and types. Write custom validations using ActiveModel's validations.
10
- * Integration with ActiveModel: EasyTalk integrates with ActiveModel to provide additional functionality such as attribute assignment, introspections, validations, translation (i18n), and more.
7
+ * LLM Function Support: Ideal for integrating with Large Language Models (LLMs) such as OpenAI’s GPT series. EasyTalk enables you to effortlessly create JSON Schema documents describing the inputs and outputs of LLM function calls.
8
+ * Schema Composition: Define EasyTalk models and reference them in other EasyTalk models to create complex schemas.
9
+ * Validation: Write validations using ActiveModels validations.
11
10
 
12
11
  Inspiration
13
12
  Inspired by Python's Pydantic library, EasyTalk brings similar functionality to the Ruby ecosystem, providing a Ruby-friendly approach to JSON Schema operations.
@@ -18,85 +17,78 @@ Example Use:
18
17
  class User
19
18
  include EasyTalk::Model
20
19
 
20
+ validates :name, :email, :group, presence: true
21
+ validates :age, numericality: { greater_than_or_equal_to: 18, less_than_or_equal_to: 100 }
22
+
21
23
  define_schema do
22
24
  title "User"
23
25
  description "A user of the system"
24
26
  property :name, String, description: "The user's name", title: "Full Name"
25
- property :email, :object do
27
+ property :email, Hash do
26
28
  property :address, String, format: "email", description: "The user's email", title: "Email Address"
27
29
  property :verified, T::Boolean, description: "Whether the email is verified"
28
30
  end
29
- property :group, String, enum: [1, 2, 3], default: 1, description: "The user's group"
31
+ property :group, Integer, enum: [1, 2, 3], default: 1, description: "The user's group"
30
32
  property :age, Integer, minimum: 18, maximum: 100, description: "The user's age"
31
- property :tags, T::Array[String], min_items: 1, unique_item: true, description: "The user's tags"
33
+ property :tags, T::Array[String], min_items: 1, unique_items: true, description: "The user's tags"
32
34
  end
33
35
  end
34
36
  ```
35
37
 
36
- Calling `User.json_schema` will return the JSON Schema for the User class:
38
+ Calling `User.json_schema` will return the Ruby representation of the JSON Schema for the `User` class:
37
39
 
38
- ```json
40
+ ```ruby
39
41
  {
40
- "title": "User",
41
- "description": "A user of the system",
42
- "type": "object",
43
- "properties": {
44
- "name": {
45
- "title": "Full Name",
46
- "description": "The user's name",
47
- "type": "string"
48
- },
49
- "email": {
50
- "type": "object",
51
- "properties": {
52
- "address": {
53
- "title": "Email Address",
54
- "description": "The user's email",
55
- "type": "string",
56
- "format": "email"
57
- },
58
- "verified": {
59
- "type": "boolean",
60
- "description": "Whether the email is verified"
61
- }
62
- },
63
- "required": [
64
- "address",
65
- "verified"
66
- ]
67
- },
68
- "group": {
69
- "type": "number",
70
- "enum": [1, 2, 3],
71
- "default": 1,
72
- "description": "The user's group"
73
- },
74
- "age": {
75
- "type": "integer",
76
- "minimum": 18,
77
- "maximum": 100,
78
- "description": "The user's age"
42
+ "type" => "object",
43
+ "title" => "User",
44
+ "description" => "A user of the system",
45
+ "properties" => {
46
+ "name" => {
47
+ "type" => "string", "title" => "Full Name", "description" => "The user's name"
48
+ },
49
+ "email" => {
50
+ "type" => "object",
51
+ "properties" => {
52
+ "address" => {
53
+ "type" => "string", "title" => "Email Address", "description" => "The user's email", "format" => "email"
79
54
  },
80
- "tags": {
81
- "type": "array",
82
- "items": {
83
- "type": "string"
84
- },
85
- "minItems": 1,
86
- "uniqueItems": true,
87
- "description": "The user's tags"
55
+ "verified" => {
56
+ "type" => "boolean", "description" => "Whether the email is verified"
88
57
  }
58
+ },
59
+ "required" => ["address", "verified"]
60
+ },
61
+ "group" => {
62
+ "type" => "integer", "description" => "The user's group", "enum" => [1, 2, 3], "default" => 1
63
+ },
64
+ "age" => {
65
+ "type" => "integer", "description" => "The user's age", "minimum" => 18, "maximum" => 100
89
66
  },
90
- "required:": [
91
- "name",
92
- "email",
93
- "group",
94
- "age",
95
- "tags"
96
- ]
67
+ "tags" => {
68
+ "type" => "array",
69
+ "items" => { "type" => "string" },
70
+ "description" => "The user's tags",
71
+ "minItems" => 1,
72
+ "uniqueItems" => true
73
+ }
74
+ },
75
+ "required" => ["name", "email", "group", "age", "tags"]
97
76
  }
98
77
  ```
99
78
 
79
+ Instantiate a User object and validate it with ActiveModel validations:
80
+
81
+ ```ruby
82
+ user = User.new(name: "John Doe", email: { address: "john@test.com", verified: true }, group: 1, age: 25, tags: ["tag1", "tag2"])
83
+ user.valid? # => true
84
+
85
+ user.name = nil
86
+ user.valid? # => false
87
+
88
+ user.errors.full_messages # => ["Name can't be blank"]
89
+ user.errors["name"] # => ["can't be blank"]
90
+ ```
91
+
100
92
  ## Installation
101
93
 
102
94
  install the gem by running the following command in your terminal:
@@ -105,12 +97,20 @@ Calling `User.json_schema` will return the JSON Schema for the User class:
105
97
 
106
98
  ## Usage
107
99
 
108
- Simply include the `EasyTalk::Model` module in your Ruby class, define the schema using the `define_schema` block and call the `json_schema` class method to generate the JSON Schema document.
100
+ Simply include the `EasyTalk::Model` module in your Ruby class, define the schema using the `define_schema` block, and call the `json_schema` class method to generate the JSON Schema document.
109
101
 
110
102
 
111
103
  ## Schema Definition
112
104
 
113
- In the example above, the `define_schema` method is used to add a description and a title to the schema document. The `property` method is used to define the properties of the schema document. The `property` method accepts the name of the property as a symbol, the type, which can be a generic Ruby type or a [Sorbet type](https://sorbet.org/docs/stdlib-generics), and a hash of constraints as options.
105
+ In the example above, the define_schema method adds a title and description to the schema. The property method defines properties of the schema document. property accepts:
106
+
107
+ * A name (symbol)
108
+ * A type (generic Ruby type like String/Integer, a Sorbet type like T::Boolean, or one of the custom types like T::AnyOf[...])
109
+ * A hash of constraints (e.g., minimum: 18, enum: [1, 2, 3], etc.)
110
+
111
+ ## Why Sortbet-style types?
112
+
113
+ Ruby doesn’t natively allow complex types like Array[String] or Array[Integer]. Sorbet-style types let you define these compound types clearly. EasyTalk uses this style to handle property types such as T::Array[String] or T::AnyOf[ClassA, ClassB].
114
114
 
115
115
  ## Property Constraints
116
116
 
@@ -119,88 +119,89 @@ Property constraints are type-dependent. Refer to the [CONSTRAINTS.md](CONSTRAIN
119
119
 
120
120
  ## Schema Composition
121
121
 
122
- EasyTalk supports schema composition. You can define a schema for a nested object by defining a new class and including the `EasyTalk::Model` module. You can then reference the nested schema in the parent schema using the following special types:
122
+ EasyTalk supports schema composition. You can define a schema for a nested object by defining a new class that includes `EasyTalk::Model`. You can then reference the nested schema in the parent using special types:
123
+
124
+ T::OneOf[Model1, Model2, ...] — The property must match at least one of the specified schemas
125
+ T::AnyOf[Model1, Model2, ...] — The property can match any of the specified schemas
126
+ T::AllOf[Model1, Model2, ...] — The property must match all of the specified schemas
123
127
 
124
- - T::OneOf[Model1, Model2, ...] - The property must match at least one of the specified schemas.
125
- - T::AnyOf[Model1, Model2, ...] - The property can match any of the specified schemas.
126
- - T::AllOf[Model1, Model2, ...] - The property must match all of the specified schemas.
128
+ Example: A Payment object that can be a credit card, PayPal, or bank transfer:
127
129
 
128
- Here is an example where we define a schema for a payment object that can be a credit card, a PayPal account, or a bank transfer. The first three classes represent the schemas for the different payment methods. The `Payment` class represents the schema for the payment object where the `Details` property can be any of the payment method schemas.
129
130
 
130
131
  ```ruby
131
- class CreditCard
132
- include EasyTalk::Model
133
-
134
- define_schema do
135
- property :CardNumber, String
136
- property :CardType, String, enum: %w[Visa MasterCard AmericanExpress]
137
- property :CardExpMonth, Integer, minimum: 1, maximum: 12
138
- property :CardExpYear, Integer, minimum: Date.today.year, maximum: Date.today.year + 10
139
- property :CardCVV, String, pattern: '^[0-9]{3,4}$'
140
- additional_properties false
141
- end
132
+ class CreditCard
133
+ include EasyTalk::Model
134
+
135
+ define_schema do
136
+ property :CardNumber, String
137
+ property :CardType, String, enum: %w[Visa MasterCard AmericanExpress]
138
+ property :CardExpMonth, Integer, minimum: 1, maximum: 12
139
+ property :CardExpYear, Integer, minimum: Date.today.year, maximum: Date.today.year + 10
140
+ property :CardCVV, String, pattern: '^[0-9]{3,4}$'
141
+ additional_properties false
142
142
  end
143
+ end
143
144
 
144
- class Paypal
145
- include EasyTalk::Model
145
+ class Paypal
146
+ include EasyTalk::Model
146
147
 
147
- define_schema do
148
- property :PaypalEmail, String, format: 'email'
149
- property :PaypalPasswordEncrypted, String
150
- additional_properties false
151
- end
148
+ define_schema do
149
+ property :PaypalEmail, String, format: 'email'
150
+ property :PaypalPasswordEncrypted, String
151
+ additional_properties false
152
152
  end
153
+ end
153
154
 
154
- class BankTransfer
155
- include EasyTalk::Model
155
+ class BankTransfer
156
+ include EasyTalk::Model
156
157
 
157
- define_schema do
158
- property :BankName, String
159
- property :AccountNumber, String
160
- property :RoutingNumber, String
161
- property :AccountType, String, enum: %w[Checking Savings]
162
- additional_properties false
163
- end
158
+ define_schema do
159
+ property :BankName, String
160
+ property :AccountNumber, String
161
+ property :RoutingNumber, String
162
+ property :AccountType, String, enum: %w[Checking Savings]
163
+ additional_properties false
164
164
  end
165
+ end
165
166
 
166
- class Payment
167
- include EasyTalk::Model
167
+ class Payment
168
+ include EasyTalk::Model
168
169
 
169
- define_schema do
170
- title 'Payment'
171
- description 'Payment info'
172
- property :PaymentMethod, String, enum: %w[CreditCard Paypal BankTransfer]
173
- property :Details, T::AnyOf[CreditCard, Paypal, BankTransfer]
174
- end
170
+ define_schema do
171
+ title 'Payment'
172
+ description 'Payment info'
173
+ property :PaymentMethod, String, enum: %w[CreditCard Paypal BankTransfer]
174
+ property :Details, T::AnyOf[CreditCard, Paypal, BankTransfer]
175
175
  end
176
-
176
+ end
177
177
  ```
178
178
 
179
179
  ## Type Checking and Schema Constraints
180
180
 
181
- EasyTalk uses [Sorbet](https://sorbet.org/) to perform type checking on the property constraint values. The `property` method accepts a type as the second argument. The type can be a Ruby class or a Sorbet type. For example, `String`, `Integer`, `T::Array[String]`, etc.
182
-
183
- EasyTalk raises an error if the constraint values do not match the property type. For example, if you specify the `enum` constraint with the values [1,2,3], but the property type is `String`, EasyTalk will raise a type error.
181
+ EasyTalk uses a combination of standard Ruby types (`String`, `Integer`), Sorbet types (`T::Boolean`, `T::Array[String]`, etc.), and custom Sorbet-style types (`T::AnyOf[]`, `T::OneOf[]`) to perform basic type checking. For example:
184
182
 
185
- EasyTalk also raises an error if the constraints are not valid for the property type. For example, if you define a property with a `minimum` or a `maximum` constraint, but the type is `String`, EasyTalk will raise an error.
183
+ If you specify `enum: [1,2,3]` but the property type is `String`, EasyTalk raises a type error.
184
+ If you define `minimum: 1` on a `String` property, it raises an error because minimum applies only to numeric types.
186
185
 
187
186
  ## Schema Validation
188
187
 
189
- EasyTalk does not yet perform JSON validation. So far, it only aims to generate a valid JSON Schema document. You can use the `json_schema` method to generate the JSON Schema and use a JSON Schema validator library like [JSONSchemer](https://github.com/davishmcclurg/json_schemer) to validate JSON against. See https://json-schema.org/implementations#validators-ruby for a list of JSON Schema validator libraries for Ruby.
190
-
191
- The goal is to introduce JSON validation in the near future.
188
+ You can instantiate an EasyTalk model with a hash of attributes and validate it using standard ActiveModel validations. EasyTalk does not automatically validate instances; you must explicitly define ActiveModel validations in your EasyTalk model. See [spec/easy_talk/activemodel_integration_spec.rb](ActiveModel Integration Spec) for examples.
192
189
 
193
190
  ## JSON Schema Specifications
194
191
 
195
- EasyTalk is currently very loose about JSON Schema specifications. It does not enforce the use of the latest JSON Schema specifications. Support for the dictionary of JSON Schema keywords varies depending on the keyword. The goal is to have robust support for the latest JSON Schema specifications in the near future.
192
+ EasyTalk is currently loose about JSON Schema versions. It doesn’t strictly enforce or adhere to any particular version of the specification. The goal is to add more robust support for the latest JSON Schema specs in the future.
196
193
 
197
- To learn about the current EasyTalk capabilities, take a look at the [spec/easy_talk/examples](https://github.com/sergiobayona/easy_talk/tree/main/spec/easy_talk/examples) folder. The examples are used to test the JSON Schema generation.
194
+ To learn about current capabilities, see the [spec/easy_talk/examples](https://github.com/sergiobayona/easy_talk/tree/main/spec/easy_talk/examples) folder. The examples illustrate how EasyTalk generates JSON Schema in different scenarios.
198
195
 
199
196
  ## Development
200
197
 
201
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
198
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that lets you experiment.
202
199
 
203
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
200
+ To install this gem onto your local machine, run:
201
+
202
+ ```bash
203
+ bundle exec rake install
204
+ ```
204
205
 
205
206
  ## Contributing
206
207
 
@@ -1,15 +1,21 @@
1
- # frozen_string_literal: true
2
-
3
1
  require_relative 'base_builder'
2
+ require 'set'
4
3
 
5
4
  module EasyTalk
6
5
  module Builders
7
- # Builder class for json schema objects.
6
+ #
7
+ # ObjectBuilder is responsible for turning a SchemaDefinition of an "object" type
8
+ # into a validated JSON Schema hash. It:
9
+ #
10
+ # 1) Recursively processes the schema’s :properties,
11
+ # 2) Determines which properties are required (unless nilable or optional),
12
+ # 3) Handles sub-schema composition (allOf, anyOf, oneOf, not),
13
+ # 4) Produces the final object-level schema hash.
14
+ #
8
15
  class ObjectBuilder < BaseBuilder
9
16
  extend T::Sig
10
17
 
11
- attr_reader :schema
12
-
18
+ # Required by BaseBuilder: recognized schema options for "object" types
13
19
  VALID_OPTIONS = {
14
20
  properties: { type: T::Hash[T.any(Symbol, String), T.untyped], key: :properties },
15
21
  additional_properties: { type: T::Boolean, key: :additionalProperties },
@@ -24,83 +30,173 @@ module EasyTalk
24
30
 
25
31
  sig { params(schema_definition: EasyTalk::SchemaDefinition).void }
26
32
  def initialize(schema_definition)
33
+ # Keep a reference to the original schema definition
27
34
  @schema_definition = schema_definition
28
- @schema = schema_definition.schema.dup
29
- @required_properties = []
30
- name = schema_definition.name ? schema_definition.name.to_sym : :klass
31
- super(name, { type: 'object' }, options, VALID_OPTIONS)
35
+ # Duplicate the raw schema hash so we can mutate it safely
36
+ @original_schema = schema_definition.schema.dup
37
+
38
+ # We'll collect required property names in this Set
39
+ @required_properties = Set.new
40
+
41
+ # Usually the name is a string (class name). Fallback to :klass if nil.
42
+ name_for_builder = schema_definition.name ? schema_definition.name.to_sym : :klass
43
+
44
+ # Build the base structure: { type: 'object' } plus any top-level options
45
+ super(
46
+ name_for_builder,
47
+ { type: 'object' }, # minimal "object" structure
48
+ build_options_hash, # method below merges & cleans final top-level keys
49
+ VALID_OPTIONS
50
+ )
32
51
  end
33
52
 
34
53
  private
35
54
 
36
- def properties_from_schema_definition
37
- @properties_from_schema_definition ||= begin
38
- properties = schema.delete(:properties) || {}
39
- properties.each_with_object({}) do |(property_name, options), context|
40
- add_required_property(property_name, options)
41
- context[property_name] = build_property(property_name, options)
55
+ ##
56
+ # Main aggregator: merges the top-level schema keys (like :properties, :subschemas)
57
+ # into a single hash that we’ll feed to BaseBuilder.
58
+ def build_options_hash
59
+ # Start with a copy of the raw schema
60
+ merged = @original_schema.dup
61
+
62
+ # Extract and build sub-schemas first (handles allOf/anyOf/oneOf references, etc.)
63
+ process_subschemas(merged)
64
+
65
+ # Build :properties into a final form (and find "required" props)
66
+ merged[:properties] = build_properties(merged.delete(:properties))
67
+
68
+ # Populate the final "required" array from @required_properties
69
+ merged[:required] = @required_properties.to_a if @required_properties.any?
70
+
71
+ # Prune empty or nil values so we don't produce stuff like "properties": {} unnecessarily
72
+ merged.reject! { |_k, v| v.nil? || v == {} || v == [] }
73
+
74
+ merged
75
+ end
76
+
77
+ ##
78
+ # Given the property definitions hash, produce a new hash of
79
+ # { property_name => [Property or nested schema builder result] }.
80
+ #
81
+ def build_properties(properties_hash)
82
+ return {} unless properties_hash.is_a?(Hash)
83
+
84
+ # Cache with a key based on property name and its full configuration
85
+ @properties_cache ||= {}
86
+
87
+ properties_hash.each_with_object({}) do |(prop_name, prop_options), result|
88
+ cache_key = [prop_name, prop_options].hash
89
+
90
+ # Use cache if the exact property and configuration have been processed before
91
+ @properties_cache[cache_key] ||= begin
92
+ mark_required_unless_optional(prop_name, prop_options)
93
+ build_property(prop_name, prop_options)
42
94
  end
95
+
96
+ result[prop_name] = @properties_cache[cache_key]
43
97
  end
44
98
  end
45
99
 
46
- # rubocop:disable Style/DoubleNegation
47
- def add_required_property(property_name, options)
48
- return if options.is_a?(Hash) && !!(options[:type].respond_to?(:nilable?) && options[:type].nilable?)
49
- return if options.respond_to?(:optional?) && options.optional?
50
- return if options.is_a?(Hash) && options.dig(:constraints, :optional)
100
+ ##
101
+ # Decide if a property should be required. If it's optional or nilable,
102
+ # we won't include it in the "required" array.
103
+ #
104
+ def mark_required_unless_optional(prop_name, prop_options)
105
+ return if property_optional?(prop_options)
106
+
107
+ @required_properties.add(prop_name)
108
+ end
109
+
110
+ ##
111
+ # Returns true if the property is declared optional or is T.nilable(...).
112
+ #
113
+ def property_optional?(prop_options)
114
+ # For convenience, treat :type as an object
115
+ type_obj = prop_options[:type]
116
+
117
+ # Check Sorbet's nilable (like T.nilable(String))
118
+ return true if type_obj.respond_to?(:nilable?) && type_obj.nilable?
119
+
120
+ # Check constraints[:optional]
121
+ return true if prop_options.dig(:constraints, :optional)
51
122
 
52
- @required_properties << property_name
123
+ false
53
124
  end
54
- # rubocop:enable Style/DoubleNegation
55
125
 
56
- def build_property(property_name, options)
126
+ ##
127
+ # Builds a single property. Could be a nested schema if it has sub-properties,
128
+ # or a standard scalar property (String, Integer, etc.).
129
+ #
130
+ def build_property(prop_name, prop_options)
57
131
  @property_cache ||= {}
58
132
 
59
- @property_cache[property_name] ||= if options.is_a?(EasyTalk::SchemaDefinition)
60
- ObjectBuilder.new(options).build
61
- else
62
- handle_option_type(options)
63
- Property.new(property_name, options[:type], options[:constraints])
64
- end
133
+ # Memoize so we only build each property once
134
+ @property_cache[prop_name] ||= if prop_options[:properties]
135
+ # This indicates block-style definition => nested schema
136
+ nested_schema_builder(prop_options)
137
+ else
138
+ # Normal property: e.g. { type: String, constraints: {...} }
139
+ handle_nilable_type(prop_options)
140
+ Property.new(prop_name, prop_options[:type], prop_options[:constraints])
141
+ end
65
142
  end
66
143
 
67
- def handle_option_type(options)
68
- if options[:type].respond_to?(:nilable?) && options[:type].nilable? && options[:type].unwrap_nilable.class != T::Types::TypedArray
69
- options[:type] = options[:type].unwrap_nilable.raw_type
70
- end
144
+ ##
145
+ # Build a child schema by calling another ObjectBuilder on the nested SchemaDefinition.
146
+ #
147
+ def nested_schema_builder(prop_options)
148
+ child_schema_def = prop_options[:properties]
149
+ # If user used T.nilable(...) with a block, unwrap the nilable
150
+ handle_nilable_type(prop_options)
151
+ ObjectBuilder.new(child_schema_def).build
71
152
  end
72
153
 
73
- def subschemas_from_schema_definition
74
- @subschemas_from_schema_definition ||= begin
75
- subschemas = schema.delete(:subschemas) || []
76
- subschemas.each do |subschema|
77
- add_definitions(subschema)
78
- add_references(subschema)
79
- end
80
- end
154
+ ##
155
+ # If the type is T.nilable(SomeType), unwrap it so we produce the correct schema.
156
+ # This logic is borrowed from the old #handle_option_type method.
157
+ #
158
+ def handle_nilable_type(prop_options)
159
+ type_obj = prop_options[:type]
160
+ return unless type_obj.respond_to?(:nilable?) && type_obj.nilable?
161
+
162
+ # If the underlying raw_type isn't T::Types::TypedArray, then we unwrap it
163
+ return unless type_obj.unwrap_nilable.class != T::Types::TypedArray
164
+
165
+ prop_options[:type] = type_obj.unwrap_nilable.raw_type
81
166
  end
82
167
 
83
- def add_definitions(subschema)
84
- definitions = subschema.items.each_with_object({}) do |item, hash|
85
- hash[item.name] = item.schema
168
+ ##
169
+ # Process top-level composition keywords (e.g. allOf, anyOf, oneOf),
170
+ # converting them to definitions + references if appropriate.
171
+ #
172
+ def process_subschemas(schema_hash)
173
+ subschemas = schema_hash.delete(:subschemas) || []
174
+ subschemas.each do |subschema|
175
+ add_defs_from_subschema(schema_hash, subschema)
176
+ add_refs_from_subschema(schema_hash, subschema)
86
177
  end
87
- schema[:defs] = definitions
88
178
  end
89
179
 
90
- def add_references(subschema)
91
- references = subschema.items.map do |item|
92
- { '$ref': item.ref_template }
180
+ ##
181
+ # For each item in the composer, add it to :defs so that we can reference it later.
182
+ #
183
+ def add_defs_from_subschema(schema_hash, subschema)
184
+ # Build up a hash of class_name => schema for each sub-item
185
+ definitions = subschema.items.each_with_object({}) do |item, acc|
186
+ acc[item.name] = item.schema
93
187
  end
94
- schema[subschema.name] = references
188
+ # Merge or create :defs
189
+ existing_defs = schema_hash[:defs] || {}
190
+ schema_hash[:defs] = existing_defs.merge(definitions)
95
191
  end
96
192
 
97
- def options
98
- @options = schema
99
- subschemas_from_schema_definition
100
- @options[:properties] = properties_from_schema_definition
101
- @options[:required] = @required_properties
102
- @options.reject! { |_key, value| [nil, [], {}].include?(value) }
103
- @options
193
+ ##
194
+ # Add references to the schema for each sub-item in the composer
195
+ # e.g. { "$ref": "#/$defs/SomeClass" }
196
+ #
197
+ def add_refs_from_subschema(schema_hash, subschema)
198
+ references = subschema.items.map { |item| { '$ref': item.ref_template } }
199
+ schema_hash[subschema.name] = references
104
200
  end
105
201
  end
106
202
  end
@@ -7,8 +7,6 @@ require 'active_support/time'
7
7
  require 'active_support/concern'
8
8
  require 'active_support/json'
9
9
  require 'active_model'
10
- require 'json_schemer'
11
- require_relative 'schema_errors_mapper'
12
10
  require_relative 'builders/object_builder'
13
11
  require_relative 'schema_definition'
14
12
 
@@ -39,30 +37,9 @@ module EasyTalk
39
37
  base.include ActiveModel::API # Include ActiveModel::API in the class including EasyTalk::Model
40
38
  base.include ActiveModel::Validations
41
39
  base.extend ActiveModel::Callbacks
42
- base.validates_with SchemaValidator
43
40
  base.extend(ClassMethods)
44
41
  end
45
42
 
46
- class SchemaValidator < ActiveModel::Validator
47
- def validate(record)
48
- result = schema_validation(record)
49
- result.errors.each do |key, error_msg|
50
- record.errors.add key.to_sym, error_msg
51
- end
52
- end
53
-
54
- def schema_validation(record)
55
- schema = JSONSchemer.schema(record.class.json_schema)
56
- errors = schema.validate(record.properties)
57
- SchemaErrorsMapper.new(errors)
58
- end
59
- end
60
-
61
- # Returns the properties of the model as a hash with symbolized keys.
62
- def properties
63
- as_json.symbolize_keys!
64
- end
65
-
66
43
  # Module containing class-level methods for defining and accessing the schema of a model.
67
44
  module ClassMethods
68
45
  # Returns the schema for the model.
@@ -72,13 +49,6 @@ module EasyTalk
72
49
  @schema ||= build_schema(schema_definition)
73
50
  end
74
51
 
75
- # Returns true if the class inherits a schema.
76
- #
77
- # @return [Boolean] `true` if the class inherits a schema, `false` otherwise.
78
- def inherits_schema?
79
- false
80
- end
81
-
82
52
  # Returns the reference template for the model.
83
53
  #
84
54
  # @return [String] The reference template for the model.
@@ -86,13 +56,6 @@ module EasyTalk
86
56
  "#/$defs/#{name}"
87
57
  end
88
58
 
89
- # Returns the name of the model as a human-readable function name.
90
- #
91
- # @return [String] The human-readable function name of the model.
92
- def function_name
93
- name.humanize.titleize
94
- end
95
-
96
59
  def properties
97
60
  @properties ||= begin
98
61
  return unless schema[:properties].present?
@@ -119,7 +82,7 @@ module EasyTalk
119
82
  @schema_definition.instance_eval(&block)
120
83
  attr_accessor(*properties)
121
84
 
122
- @schema_defintion
85
+ @schema_definition
123
86
  end
124
87
 
125
88
  # Returns the unvalidated schema definition for the model.
@@ -3,12 +3,14 @@
3
3
  require_relative 'keywords'
4
4
 
5
5
  module EasyTalk
6
+ class InvalidPropertyNameError < StandardError; end
6
7
  #
7
8
  #= EasyTalk \SchemaDefinition
8
9
  # SchemaDefinition provides the methods for defining a schema within the define_schema block.
9
10
  # The @schema is a hash that contains the unvalidated schema definition for the model.
10
11
  # A SchemaDefinition instanace is the passed to the Builder.build_schema method to validate and compile the schema.
11
12
  class SchemaDefinition
13
+
12
14
  extend T::Sig
13
15
  extend T::AnyOf
14
16
  extend T::OneOf
@@ -35,18 +37,30 @@ module EasyTalk
35
37
  sig do
36
38
  params(name: T.any(Symbol, String), type: T.untyped, constraints: T.untyped, blk: T.nilable(T.proc.void)).void
37
39
  end
38
- def property(name, type, **constraints, &blk)
40
+ def property(name, type, constraints = {}, &blk)
41
+ validate_property_name(name)
39
42
  @schema[:properties] ||= {}
40
43
 
41
44
  if block_given?
42
- property_schema = SchemaDefinition.new(name, constraints)
45
+ property_schema = SchemaDefinition.new(name)
43
46
  property_schema.instance_eval(&blk)
44
- @schema[:properties][name] = property_schema
47
+
48
+ @schema[:properties][name] = {
49
+ type:,
50
+ constraints:,
51
+ properties: property_schema
52
+ }
45
53
  else
46
54
  @schema[:properties][name] = { type:, constraints: }
47
55
  end
48
56
  end
49
57
 
58
+ def validate_property_name(name)
59
+ unless name.to_s.match?(/^[A-Za-z_][A-Za-z0-9_]*$/)
60
+ raise InvalidPropertyNameError, "Invalid property name '#{name}'. Must start with letter/underscore and contain only letters, numbers, underscores"
61
+ end
62
+ end
63
+
50
64
  def optional?
51
65
  @schema[:optional]
52
66
  end
@@ -5,23 +5,35 @@ module EasyTalk
5
5
  # FunctionBuilder is a module that builds a hash with the function type and function details.
6
6
  # The return value is typically passed as argument to LLM function calling APIs.
7
7
  module FunctionBuilder
8
- # Creates a new function object based on the given model.
9
- #
10
- # @param [Model] model The EasyTalk model containing the function details.
11
- # @return [Hash] The function object.
12
- def self.new(model)
13
- {
14
- type: 'function',
15
- function: {
16
- name: model.function_name,
17
- description: generate_description(model),
18
- parameters: model.json_schema
8
+ class << self
9
+ # Creates a new function object based on the given model.
10
+ #
11
+ # @param [Model] model The EasyTalk model containing the function details.
12
+ # @return [Hash] The function object.
13
+ def new(model)
14
+ {
15
+ type: 'function',
16
+ function: {
17
+ name: generate_function_name(model),
18
+ description: generate_function_description(model),
19
+ parameters: model.json_schema
20
+ }
19
21
  }
20
- }
21
- end
22
+ end
23
+
24
+ def generate_function_name(model)
25
+ model.schema.fetch(:title, model.name)
26
+ end
27
+
28
+ def generate_function_description(model)
29
+ if model.respond_to?(:instructions)
30
+ raise Instructor::Error, 'The instructions must be a string' unless model.instructions.is_a?(String)
22
31
 
23
- def self.generate_description(model)
24
- "Correctly extracted `#{model.name}` with all the required parameters with correct types"
32
+ model.instructions
33
+ else
34
+ "Correctly extracted `#{model.name}` with all the required parameters and correct types."
35
+ end
36
+ end
25
37
  end
26
38
  end
27
39
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyTalk
4
- VERSION = '0.2.2'
4
+ VERSION = '1.0.0'
5
5
  end
metadata CHANGED
@@ -1,69 +1,54 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_talk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-05-17 00:00:00.000000000 Z
10
+ date: 2025-01-09 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activemodel
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
- - - "~>"
16
+ - - ">="
18
17
  - !ruby/object:Gem::Version
19
18
  version: '7.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
- - - "~>"
23
+ - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '7.0'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: activesupport
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '7.0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '7.0'
41
- - !ruby/object:Gem::Dependency
42
- name: json_schemer
43
28
  requirement: !ruby/object:Gem::Requirement
44
29
  requirements:
45
30
  - - ">="
46
31
  - !ruby/object:Gem::Version
47
- version: '0'
32
+ version: '7.0'
48
33
  type: :runtime
49
34
  prerelease: false
50
35
  version_requirements: !ruby/object:Gem::Requirement
51
36
  requirements:
52
37
  - - ">="
53
38
  - !ruby/object:Gem::Version
54
- version: '0'
39
+ version: '7.0'
55
40
  - !ruby/object:Gem::Dependency
56
41
  name: sorbet-runtime
57
42
  requirement: !ruby/object:Gem::Requirement
58
43
  requirements:
59
- - - "~>"
44
+ - - ">="
60
45
  - !ruby/object:Gem::Version
61
46
  version: '0.5'
62
47
  type: :runtime
63
48
  prerelease: false
64
49
  version_requirements: !ruby/object:Gem::Requirement
65
50
  requirements:
66
- - - "~>"
51
+ - - ">="
67
52
  - !ruby/object:Gem::Version
68
53
  version: '0.5'
69
54
  - !ruby/object:Gem::Dependency
@@ -84,98 +69,98 @@ dependencies:
84
69
  name: rake
85
70
  requirement: !ruby/object:Gem::Requirement
86
71
  requirements:
87
- - - "~>"
72
+ - - ">="
88
73
  - !ruby/object:Gem::Version
89
74
  version: '13.1'
90
75
  type: :development
91
76
  prerelease: false
92
77
  version_requirements: !ruby/object:Gem::Requirement
93
78
  requirements:
94
- - - "~>"
79
+ - - ">="
95
80
  - !ruby/object:Gem::Version
96
81
  version: '13.1'
97
82
  - !ruby/object:Gem::Dependency
98
83
  name: rspec
99
84
  requirement: !ruby/object:Gem::Requirement
100
85
  requirements:
101
- - - "~>"
86
+ - - ">="
102
87
  - !ruby/object:Gem::Version
103
88
  version: '3.0'
104
89
  type: :development
105
90
  prerelease: false
106
91
  version_requirements: !ruby/object:Gem::Requirement
107
92
  requirements:
108
- - - "~>"
93
+ - - ">="
109
94
  - !ruby/object:Gem::Version
110
95
  version: '3.0'
111
96
  - !ruby/object:Gem::Dependency
112
97
  name: rspec-json_expectations
113
98
  requirement: !ruby/object:Gem::Requirement
114
99
  requirements:
115
- - - "~>"
100
+ - - ">="
116
101
  - !ruby/object:Gem::Version
117
102
  version: '2.0'
118
103
  type: :development
119
104
  prerelease: false
120
105
  version_requirements: !ruby/object:Gem::Requirement
121
106
  requirements:
122
- - - "~>"
107
+ - - ">="
123
108
  - !ruby/object:Gem::Version
124
109
  version: '2.0'
125
110
  - !ruby/object:Gem::Dependency
126
111
  name: rspec-mocks
127
112
  requirement: !ruby/object:Gem::Requirement
128
113
  requirements:
129
- - - "~>"
114
+ - - ">="
130
115
  - !ruby/object:Gem::Version
131
116
  version: '3.13'
132
117
  type: :development
133
118
  prerelease: false
134
119
  version_requirements: !ruby/object:Gem::Requirement
135
120
  requirements:
136
- - - "~>"
121
+ - - ">="
137
122
  - !ruby/object:Gem::Version
138
123
  version: '3.13'
139
124
  - !ruby/object:Gem::Dependency
140
125
  name: rubocop
141
126
  requirement: !ruby/object:Gem::Requirement
142
127
  requirements:
143
- - - "~>"
128
+ - - ">="
144
129
  - !ruby/object:Gem::Version
145
130
  version: '1.21'
146
131
  type: :development
147
132
  prerelease: false
148
133
  version_requirements: !ruby/object:Gem::Requirement
149
134
  requirements:
150
- - - "~>"
135
+ - - ">="
151
136
  - !ruby/object:Gem::Version
152
137
  version: '1.21'
153
138
  - !ruby/object:Gem::Dependency
154
139
  name: rubocop-rake
155
140
  requirement: !ruby/object:Gem::Requirement
156
141
  requirements:
157
- - - "~>"
142
+ - - ">="
158
143
  - !ruby/object:Gem::Version
159
144
  version: '0.6'
160
145
  type: :development
161
146
  prerelease: false
162
147
  version_requirements: !ruby/object:Gem::Requirement
163
148
  requirements:
164
- - - "~>"
149
+ - - ">="
165
150
  - !ruby/object:Gem::Version
166
151
  version: '0.6'
167
152
  - !ruby/object:Gem::Dependency
168
153
  name: rubocop-rspec
169
154
  requirement: !ruby/object:Gem::Requirement
170
155
  requirements:
171
- - - "~>"
156
+ - - ">="
172
157
  - !ruby/object:Gem::Version
173
158
  version: '2.29'
174
159
  type: :development
175
160
  prerelease: false
176
161
  version_requirements: !ruby/object:Gem::Requirement
177
162
  requirements:
178
- - - "~>"
163
+ - - ">="
179
164
  - !ruby/object:Gem::Version
180
165
  version: '2.29'
181
166
  description: Generate json-schema from plain Ruby classes.
@@ -220,7 +205,6 @@ files:
220
205
  - lib/easy_talk/model.rb
221
206
  - lib/easy_talk/property.rb
222
207
  - lib/easy_talk/schema_definition.rb
223
- - lib/easy_talk/schema_errors_mapper.rb
224
208
  - lib/easy_talk/sorbet_extension.rb
225
209
  - lib/easy_talk/tools/function_builder.rb
226
210
  - lib/easy_talk/types/all_of.rb
@@ -236,7 +220,6 @@ metadata:
236
220
  homepage_uri: https://github.com/sergiobayona/easy_talk
237
221
  source_code_uri: https://github.com/sergiobayona/easy_talk
238
222
  changelog_uri: https://github.com/sergiobayona/easy_talk/blob/main/CHANGELOG.md
239
- post_install_message:
240
223
  rdoc_options: []
241
224
  require_paths:
242
225
  - lib
@@ -251,8 +234,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
251
234
  - !ruby/object:Gem::Version
252
235
  version: '0'
253
236
  requirements: []
254
- rubygems_version: 3.5.9
255
- signing_key:
237
+ rubygems_version: 3.6.2
256
238
  specification_version: 4
257
239
  summary: Generate json-schema from Ruby classes.
258
240
  test_files: []
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module EasyTalk
4
- class SchemaErrorsMapper
5
- def initialize(errors)
6
- @errors = errors.to_a
7
- end
8
-
9
- def errors
10
- @errors.each_with_object({}) do |error, hash|
11
- if error['data_pointer'].present?
12
- key = error['data_pointer'].split('/').compact_blank.join('.')
13
- hash[key] = error['error']
14
- else
15
- error['details']['missing_keys'].each do |missing_key|
16
- message = "#{error['error'].split(':').first}: #{missing_key}"
17
- hash[missing_key] = message
18
- end
19
- end
20
- end
21
- end
22
- end
23
- end