diff --git a/.gitignore b/.gitignore index b07bab6..608ca0b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ Gemfile.lock # rspec failure tracking .rspec_status +.vscode diff --git a/CHANGELOG.md b/CHANGELOG.md index daae3ef..0a2fc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [1.0.0] - 2024-06-01 +- Use `Hash` instead of `:object` for inline object schema definition. +example: +```ruby + property :email, Hash do + property :address, :string + property :verified, :boolean + end +``` +- Loosen up the gemspec version requirement. Makes it flexible to use the library with future versions of Rails (i.e 8.*). +- Removed JSONScheemer gem dependency. +- 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. +- Internal improvements to `EasyTalk::ObjectBuilder` class. No changes to the public API. +- Expanded the test suite. + ## [0.2.2] - 2024-05-17 - Fixed a bug where optional properties were not excluded from the required list. diff --git a/Gemfile b/Gemfile index 7f4f5e9..dadc0fe 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,5 @@ source 'https://rubygems.org' gemspec + +gem "dartsass-rails", ">= 0.5.0" diff --git a/README.md b/README.md index 4d05249..dd1d16a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,9 @@ EasyTalk is a Ruby library that simplifies defining and generating JSON Schema d Key Features * Intuitive Schema Definition: Use Ruby classes and methods to define JSON Schema documents easily. -* JSON Schema Compliance: Implements the JSON Schema specification to ensure compatibility and standards adherence. -* 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. -* Validation: Validates JSON inputs and outputs against defined schemas to ensure they meet expected formats and types. Write custom validations using ActiveModel's validations. -* Integration with ActiveModel: EasyTalk integrates with ActiveModel to provide additional functionality such as attribute assignment, introspections, validations, translation (i18n), and more. +* 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. +* Schema Composition: Define EasyTalk models and reference them in other EasyTalk models to create complex schemas. +* Validation: Write validations using ActiveModel’s validations. Inspiration 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: class User include EasyTalk::Model + validates :name, :email, :group, presence: true + validates :age, numericality: { greater_than_or_equal_to: 18, less_than_or_equal_to: 100 } + define_schema do title "User" description "A user of the system" property :name, String, description: "The user's name", title: "Full Name" - property :email, :object do + property :email, Hash do property :address, String, format: "email", description: "The user's email", title: "Email Address" property :verified, T::Boolean, description: "Whether the email is verified" end - property :group, String, enum: [1, 2, 3], default: 1, description: "The user's group" + property :group, Integer, enum: [1, 2, 3], default: 1, description: "The user's group" property :age, Integer, minimum: 18, maximum: 100, description: "The user's age" - property :tags, T::Array[String], min_items: 1, unique_item: true, description: "The user's tags" + property :tags, T::Array[String], min_items: 1, unique_items: true, description: "The user's tags" end end ``` -Calling `User.json_schema` will return the JSON Schema for the User class: +Calling `User.json_schema` will return the Ruby representation of the JSON Schema for the `User` class: -```json +```ruby { - "title": "User", - "description": "A user of the system", - "type": "object", - "properties": { - "name": { - "title": "Full Name", - "description": "The user's name", - "type": "string" - }, - "email": { - "type": "object", - "properties": { - "address": { - "title": "Email Address", - "description": "The user's email", - "type": "string", - "format": "email" - }, - "verified": { - "type": "boolean", - "description": "Whether the email is verified" - } - }, - "required": [ - "address", - "verified" - ] - }, - "group": { - "type": "number", - "enum": [1, 2, 3], - "default": 1, - "description": "The user's group" - }, - "age": { - "type": "integer", - "minimum": 18, - "maximum": 100, - "description": "The user's age" + "type" => "object", + "title" => "User", + "description" => "A user of the system", + "properties" => { + "name" => { + "type" => "string", "title" => "Full Name", "description" => "The user's name" + }, + "email" => { + "type" => "object", + "properties" => { + "address" => { + "type" => "string", "title" => "Email Address", "description" => "The user's email", "format" => "email" }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "uniqueItems": true, - "description": "The user's tags" + "verified" => { + "type" => "boolean", "description" => "Whether the email is verified" } + }, + "required" => ["address", "verified"] + }, + "group" => { + "type" => "integer", "description" => "The user's group", "enum" => [1, 2, 3], "default" => 1 + }, + "age" => { + "type" => "integer", "description" => "The user's age", "minimum" => 18, "maximum" => 100 }, - "required:": [ - "name", - "email", - "group", - "age", - "tags" - ] + "tags" => { + "type" => "array", + "items" => { "type" => "string" }, + "description" => "The user's tags", + "minItems" => 1, + "uniqueItems" => true + } + }, + "required" => ["name", "email", "group", "age", "tags"] } ``` +Instantiate a User object and validate it with ActiveModel validations: + +```ruby +user = User.new(name: "John Doe", email: { address: "john@test.com", verified: true }, group: 1, age: 25, tags: ["tag1", "tag2"]) +user.valid? # => true + +user.name = nil +user.valid? # => false + +user.errors.full_messages # => ["Name can't be blank"] +user.errors["name"] # => ["can't be blank"] +``` + ## Installation 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: ## Usage -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. +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. ## Schema Definition -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. +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: + +* A name (symbol) +* A type (generic Ruby type like String/Integer, a Sorbet type like T::Boolean, or one of the custom types like T::AnyOf[...]) +* A hash of constraints (e.g., minimum: 18, enum: [1, 2, 3], etc.) + +## Why Sortbet-style types? + +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]. ## Property Constraints @@ -119,88 +119,89 @@ Property constraints are type-dependent. Refer to the [CONSTRAINTS.md](CONSTRAIN ## Schema Composition -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: +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: + +T::OneOf[Model1, Model2, ...] — The property must match at least one of the specified schemas +T::AnyOf[Model1, Model2, ...] — The property can match any of the specified schemas +T::AllOf[Model1, Model2, ...] — The property must match all of the specified schemas -- T::OneOf[Model1, Model2, ...] - The property must match at least one of the specified schemas. -- T::AnyOf[Model1, Model2, ...] - The property can match any of the specified schemas. -- T::AllOf[Model1, Model2, ...] - The property must match all of the specified schemas. +Example: A Payment object that can be a credit card, PayPal, or bank transfer: -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. ```ruby - class CreditCard - include EasyTalk::Model - - define_schema do - property :CardNumber, String - property :CardType, String, enum: %w[Visa MasterCard AmericanExpress] - property :CardExpMonth, Integer, minimum: 1, maximum: 12 - property :CardExpYear, Integer, minimum: Date.today.year, maximum: Date.today.year + 10 - property :CardCVV, String, pattern: '^[0-9]{3,4}$' - additional_properties false - end +class CreditCard + include EasyTalk::Model + + define_schema do + property :CardNumber, String + property :CardType, String, enum: %w[Visa MasterCard AmericanExpress] + property :CardExpMonth, Integer, minimum: 1, maximum: 12 + property :CardExpYear, Integer, minimum: Date.today.year, maximum: Date.today.year + 10 + property :CardCVV, String, pattern: '^[0-9]{3,4}$' + additional_properties false end +end - class Paypal - include EasyTalk::Model +class Paypal + include EasyTalk::Model - define_schema do - property :PaypalEmail, String, format: 'email' - property :PaypalPasswordEncrypted, String - additional_properties false - end + define_schema do + property :PaypalEmail, String, format: 'email' + property :PaypalPasswordEncrypted, String + additional_properties false end +end - class BankTransfer - include EasyTalk::Model +class BankTransfer + include EasyTalk::Model - define_schema do - property :BankName, String - property :AccountNumber, String - property :RoutingNumber, String - property :AccountType, String, enum: %w[Checking Savings] - additional_properties false - end + define_schema do + property :BankName, String + property :AccountNumber, String + property :RoutingNumber, String + property :AccountType, String, enum: %w[Checking Savings] + additional_properties false end +end - class Payment - include EasyTalk::Model +class Payment + include EasyTalk::Model - define_schema do - title 'Payment' - description 'Payment info' - property :PaymentMethod, String, enum: %w[CreditCard Paypal BankTransfer] - property :Details, T::AnyOf[CreditCard, Paypal, BankTransfer] - end + define_schema do + title 'Payment' + description 'Payment info' + property :PaymentMethod, String, enum: %w[CreditCard Paypal BankTransfer] + property :Details, T::AnyOf[CreditCard, Paypal, BankTransfer] end - +end ``` ## Type Checking and Schema Constraints -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. - -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. +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: -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. +If you specify `enum: [1,2,3]` but the property type is `String`, EasyTalk raises a type error. +If you define `minimum: 1` on a `String` property, it raises an error because minimum applies only to numeric types. ## Schema Validation -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. - -The goal is to introduce JSON validation in the near future. +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. ## JSON Schema Specifications -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. +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. -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. +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. ## Development -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. +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. -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). +To install this gem onto your local machine, run: + +```bash +bundle exec rake install +``` ## Contributing diff --git a/easy_talk.gemspec b/easy_talk.gemspec index e52c74b..b21db8a 100644 --- a/easy_talk.gemspec +++ b/easy_talk.gemspec @@ -31,16 +31,15 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] - spec.add_dependency 'activemodel', '~> 7.0' - spec.add_dependency 'activesupport', '~> 7.0' - spec.add_dependency 'json_schemer' - spec.add_dependency 'sorbet-runtime', '~> 0.5' + spec.add_dependency 'activemodel', '>= 7.0' + spec.add_dependency 'activesupport', '>= 7.0' + spec.add_dependency 'sorbet-runtime', '>= 0.5' spec.add_development_dependency 'pry-byebug', '>= 3.10' - spec.add_development_dependency 'rake', '~> 13.1' - spec.add_development_dependency 'rspec', '~> 3.0' - spec.add_development_dependency 'rspec-json_expectations', '~> 2.0' - spec.add_development_dependency 'rspec-mocks', '~> 3.13' - spec.add_development_dependency 'rubocop', '~> 1.21' - spec.add_development_dependency 'rubocop-rake', '~> 0.6' - spec.add_development_dependency 'rubocop-rspec', '~> 2.29' + spec.add_development_dependency 'rake', '>= 13.1' + spec.add_development_dependency 'rspec', '>= 3.0' + spec.add_development_dependency 'rspec-json_expectations', '>= 2.0' + spec.add_development_dependency 'rspec-mocks', '>= 3.13' + spec.add_development_dependency 'rubocop', '>= 1.21' + spec.add_development_dependency 'rubocop-rake', '>= 0.6' + spec.add_development_dependency 'rubocop-rspec', '>= 2.29' end diff --git a/lib/easy_talk/builders/object_builder.rb b/lib/easy_talk/builders/object_builder.rb index 767460a..441e391 100644 --- a/lib/easy_talk/builders/object_builder.rb +++ b/lib/easy_talk/builders/object_builder.rb @@ -1,15 +1,21 @@ -# frozen_string_literal: true - require_relative 'base_builder' +require 'set' module EasyTalk module Builders - # Builder class for json schema objects. + # + # ObjectBuilder is responsible for turning a SchemaDefinition of an "object" type + # into a validated JSON Schema hash. It: + # + # 1) Recursively processes the schema’s :properties, + # 2) Determines which properties are required (unless nilable or optional), + # 3) Handles sub-schema composition (allOf, anyOf, oneOf, not), + # 4) Produces the final object-level schema hash. + # class ObjectBuilder < BaseBuilder extend T::Sig - attr_reader :schema - + # Required by BaseBuilder: recognized schema options for "object" types VALID_OPTIONS = { properties: { type: T::Hash[T.any(Symbol, String), T.untyped], key: :properties }, additional_properties: { type: T::Boolean, key: :additionalProperties }, @@ -24,83 +30,173 @@ class ObjectBuilder < BaseBuilder sig { params(schema_definition: EasyTalk::SchemaDefinition).void } def initialize(schema_definition) + # Keep a reference to the original schema definition @schema_definition = schema_definition - @schema = schema_definition.schema.dup - @required_properties = [] - name = schema_definition.name ? schema_definition.name.to_sym : :klass - super(name, { type: 'object' }, options, VALID_OPTIONS) + # Duplicate the raw schema hash so we can mutate it safely + @original_schema = schema_definition.schema.dup + + # We'll collect required property names in this Set + @required_properties = Set.new + + # Usually the name is a string (class name). Fallback to :klass if nil. + name_for_builder = schema_definition.name ? schema_definition.name.to_sym : :klass + + # Build the base structure: { type: 'object' } plus any top-level options + super( + name_for_builder, + { type: 'object' }, # minimal "object" structure + build_options_hash, # method below merges & cleans final top-level keys + VALID_OPTIONS + ) end private - def properties_from_schema_definition - @properties_from_schema_definition ||= begin - properties = schema.delete(:properties) || {} - properties.each_with_object({}) do |(property_name, options), context| - add_required_property(property_name, options) - context[property_name] = build_property(property_name, options) + ## + # Main aggregator: merges the top-level schema keys (like :properties, :subschemas) + # into a single hash that we’ll feed to BaseBuilder. + def build_options_hash + # Start with a copy of the raw schema + merged = @original_schema.dup + + # Extract and build sub-schemas first (handles allOf/anyOf/oneOf references, etc.) + process_subschemas(merged) + + # Build :properties into a final form (and find "required" props) + merged[:properties] = build_properties(merged.delete(:properties)) + + # Populate the final "required" array from @required_properties + merged[:required] = @required_properties.to_a if @required_properties.any? + + # Prune empty or nil values so we don't produce stuff like "properties": {} unnecessarily + merged.reject! { |_k, v| v.nil? || v == {} || v == [] } + + merged + end + + ## + # Given the property definitions hash, produce a new hash of + # { property_name => [Property or nested schema builder result] }. + # + def build_properties(properties_hash) + return {} unless properties_hash.is_a?(Hash) + + # Cache with a key based on property name and its full configuration + @properties_cache ||= {} + + properties_hash.each_with_object({}) do |(prop_name, prop_options), result| + cache_key = [prop_name, prop_options].hash + + # Use cache if the exact property and configuration have been processed before + @properties_cache[cache_key] ||= begin + mark_required_unless_optional(prop_name, prop_options) + build_property(prop_name, prop_options) end + + result[prop_name] = @properties_cache[cache_key] end end - # rubocop:disable Style/DoubleNegation - def add_required_property(property_name, options) - return if options.is_a?(Hash) && !!(options[:type].respond_to?(:nilable?) && options[:type].nilable?) - return if options.respond_to?(:optional?) && options.optional? - return if options.is_a?(Hash) && options.dig(:constraints, :optional) + ## + # Decide if a property should be required. If it's optional or nilable, + # we won't include it in the "required" array. + # + def mark_required_unless_optional(prop_name, prop_options) + return if property_optional?(prop_options) + + @required_properties.add(prop_name) + end + + ## + # Returns true if the property is declared optional or is T.nilable(...). + # + def property_optional?(prop_options) + # For convenience, treat :type as an object + type_obj = prop_options[:type] + + # Check Sorbet's nilable (like T.nilable(String)) + return true if type_obj.respond_to?(:nilable?) && type_obj.nilable? + + # Check constraints[:optional] + return true if prop_options.dig(:constraints, :optional) - @required_properties << property_name + false end - # rubocop:enable Style/DoubleNegation - def build_property(property_name, options) + ## + # Builds a single property. Could be a nested schema if it has sub-properties, + # or a standard scalar property (String, Integer, etc.). + # + def build_property(prop_name, prop_options) @property_cache ||= {} - @property_cache[property_name] ||= if options.is_a?(EasyTalk::SchemaDefinition) - ObjectBuilder.new(options).build - else - handle_option_type(options) - Property.new(property_name, options[:type], options[:constraints]) - end + # Memoize so we only build each property once + @property_cache[prop_name] ||= if prop_options[:properties] + # This indicates block-style definition => nested schema + nested_schema_builder(prop_options) + else + # Normal property: e.g. { type: String, constraints: {...} } + handle_nilable_type(prop_options) + Property.new(prop_name, prop_options[:type], prop_options[:constraints]) + end end - def handle_option_type(options) - if options[:type].respond_to?(:nilable?) && options[:type].nilable? && options[:type].unwrap_nilable.class != T::Types::TypedArray - options[:type] = options[:type].unwrap_nilable.raw_type - end + ## + # Build a child schema by calling another ObjectBuilder on the nested SchemaDefinition. + # + def nested_schema_builder(prop_options) + child_schema_def = prop_options[:properties] + # If user used T.nilable(...) with a block, unwrap the nilable + handle_nilable_type(prop_options) + ObjectBuilder.new(child_schema_def).build end - def subschemas_from_schema_definition - @subschemas_from_schema_definition ||= begin - subschemas = schema.delete(:subschemas) || [] - subschemas.each do |subschema| - add_definitions(subschema) - add_references(subschema) - end - end + ## + # If the type is T.nilable(SomeType), unwrap it so we produce the correct schema. + # This logic is borrowed from the old #handle_option_type method. + # + def handle_nilable_type(prop_options) + type_obj = prop_options[:type] + return unless type_obj.respond_to?(:nilable?) && type_obj.nilable? + + # If the underlying raw_type isn't T::Types::TypedArray, then we unwrap it + return unless type_obj.unwrap_nilable.class != T::Types::TypedArray + + prop_options[:type] = type_obj.unwrap_nilable.raw_type end - def add_definitions(subschema) - definitions = subschema.items.each_with_object({}) do |item, hash| - hash[item.name] = item.schema + ## + # Process top-level composition keywords (e.g. allOf, anyOf, oneOf), + # converting them to definitions + references if appropriate. + # + def process_subschemas(schema_hash) + subschemas = schema_hash.delete(:subschemas) || [] + subschemas.each do |subschema| + add_defs_from_subschema(schema_hash, subschema) + add_refs_from_subschema(schema_hash, subschema) end - schema[:defs] = definitions end - def add_references(subschema) - references = subschema.items.map do |item| - { '$ref': item.ref_template } + ## + # For each item in the composer, add it to :defs so that we can reference it later. + # + def add_defs_from_subschema(schema_hash, subschema) + # Build up a hash of class_name => schema for each sub-item + definitions = subschema.items.each_with_object({}) do |item, acc| + acc[item.name] = item.schema end - schema[subschema.name] = references + # Merge or create :defs + existing_defs = schema_hash[:defs] || {} + schema_hash[:defs] = existing_defs.merge(definitions) end - def options - @options = schema - subschemas_from_schema_definition - @options[:properties] = properties_from_schema_definition - @options[:required] = @required_properties - @options.reject! { |_key, value| [nil, [], {}].include?(value) } - @options + ## + # Add references to the schema for each sub-item in the composer + # e.g. { "$ref": "#/$defs/SomeClass" } + # + def add_refs_from_subschema(schema_hash, subschema) + references = subschema.items.map { |item| { '$ref': item.ref_template } } + schema_hash[subschema.name] = references end end end diff --git a/lib/easy_talk/model.rb b/lib/easy_talk/model.rb index d3ec2e9..3d5daea 100644 --- a/lib/easy_talk/model.rb +++ b/lib/easy_talk/model.rb @@ -7,8 +7,6 @@ require 'active_support/concern' require 'active_support/json' require 'active_model' -require 'json_schemer' -require_relative 'schema_errors_mapper' require_relative 'builders/object_builder' require_relative 'schema_definition' @@ -39,30 +37,9 @@ def self.included(base) base.include ActiveModel::API # Include ActiveModel::API in the class including EasyTalk::Model base.include ActiveModel::Validations base.extend ActiveModel::Callbacks - base.validates_with SchemaValidator base.extend(ClassMethods) end - class SchemaValidator < ActiveModel::Validator - def validate(record) - result = schema_validation(record) - result.errors.each do |key, error_msg| - record.errors.add key.to_sym, error_msg - end - end - - def schema_validation(record) - schema = JSONSchemer.schema(record.class.json_schema) - errors = schema.validate(record.properties) - SchemaErrorsMapper.new(errors) - end - end - - # Returns the properties of the model as a hash with symbolized keys. - def properties - as_json.symbolize_keys! - end - # Module containing class-level methods for defining and accessing the schema of a model. module ClassMethods # Returns the schema for the model. @@ -72,13 +49,6 @@ def schema @schema ||= build_schema(schema_definition) end - # Returns true if the class inherits a schema. - # - # @return [Boolean] `true` if the class inherits a schema, `false` otherwise. - def inherits_schema? - false - end - # Returns the reference template for the model. # # @return [String] The reference template for the model. @@ -86,13 +56,6 @@ def ref_template "#/$defs/#{name}" end - # Returns the name of the model as a human-readable function name. - # - # @return [String] The human-readable function name of the model. - def function_name - name.humanize.titleize - end - def properties @properties ||= begin return unless schema[:properties].present? @@ -119,7 +82,7 @@ def define_schema(&block) @schema_definition.instance_eval(&block) attr_accessor(*properties) - @schema_defintion + @schema_definition end # Returns the unvalidated schema definition for the model. diff --git a/lib/easy_talk/schema_definition.rb b/lib/easy_talk/schema_definition.rb index bfc502c..b8f6269 100644 --- a/lib/easy_talk/schema_definition.rb +++ b/lib/easy_talk/schema_definition.rb @@ -3,12 +3,14 @@ require_relative 'keywords' module EasyTalk + class InvalidPropertyNameError < StandardError; end # #= EasyTalk \SchemaDefinition # SchemaDefinition provides the methods for defining a schema within the define_schema block. # The @schema is a hash that contains the unvalidated schema definition for the model. # A SchemaDefinition instanace is the passed to the Builder.build_schema method to validate and compile the schema. class SchemaDefinition + extend T::Sig extend T::AnyOf extend T::OneOf @@ -35,18 +37,30 @@ def compose(*subschemas) sig do params(name: T.any(Symbol, String), type: T.untyped, constraints: T.untyped, blk: T.nilable(T.proc.void)).void end - def property(name, type, **constraints, &blk) + def property(name, type, constraints = {}, &blk) + validate_property_name(name) @schema[:properties] ||= {} if block_given? - property_schema = SchemaDefinition.new(name, constraints) + property_schema = SchemaDefinition.new(name) property_schema.instance_eval(&blk) - @schema[:properties][name] = property_schema + + @schema[:properties][name] = { + type:, + constraints:, + properties: property_schema + } else @schema[:properties][name] = { type:, constraints: } end end + def validate_property_name(name) + unless name.to_s.match?(/^[A-Za-z_][A-Za-z0-9_]*$/) + raise InvalidPropertyNameError, "Invalid property name '#{name}'. Must start with letter/underscore and contain only letters, numbers, underscores" + end + end + def optional? @schema[:optional] end diff --git a/lib/easy_talk/schema_errors_mapper.rb b/lib/easy_talk/schema_errors_mapper.rb deleted file mode 100644 index 3d3a626..0000000 --- a/lib/easy_talk/schema_errors_mapper.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module EasyTalk - class SchemaErrorsMapper - def initialize(errors) - @errors = errors.to_a - end - - def errors - @errors.each_with_object({}) do |error, hash| - if error['data_pointer'].present? - key = error['data_pointer'].split('/').compact_blank.join('.') - hash[key] = error['error'] - else - error['details']['missing_keys'].each do |missing_key| - message = "#{error['error'].split(':').first}: #{missing_key}" - hash[missing_key] = message - end - end - end - end - end -end diff --git a/lib/easy_talk/tools/function_builder.rb b/lib/easy_talk/tools/function_builder.rb index 9d901c8..9759766 100644 --- a/lib/easy_talk/tools/function_builder.rb +++ b/lib/easy_talk/tools/function_builder.rb @@ -5,23 +5,35 @@ module Tools # FunctionBuilder is a module that builds a hash with the function type and function details. # The return value is typically passed as argument to LLM function calling APIs. module FunctionBuilder - # Creates a new function object based on the given model. - # - # @param [Model] model The EasyTalk model containing the function details. - # @return [Hash] The function object. - def self.new(model) - { - type: 'function', - function: { - name: model.function_name, - description: generate_description(model), - parameters: model.json_schema + class << self + # Creates a new function object based on the given model. + # + # @param [Model] model The EasyTalk model containing the function details. + # @return [Hash] The function object. + def new(model) + { + type: 'function', + function: { + name: generate_function_name(model), + description: generate_function_description(model), + parameters: model.json_schema + } } - } - end + end + + def generate_function_name(model) + model.schema.fetch(:title, model.name) + end + + def generate_function_description(model) + if model.respond_to?(:instructions) + raise Instructor::Error, 'The instructions must be a string' unless model.instructions.is_a?(String) - def self.generate_description(model) - "Correctly extracted `#{model.name}` with all the required parameters with correct types" + model.instructions + else + "Correctly extracted `#{model.name}` with all the required parameters and correct types." + end + end end end end diff --git a/lib/easy_talk/version.rb b/lib/easy_talk/version.rb index 6e9d9c7..994e6e0 100644 --- a/lib/easy_talk/version.rb +++ b/lib/easy_talk/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module EasyTalk - VERSION = '0.2.2' + VERSION = '1.0.0' end diff --git a/spec/easy_talk/activemodel_integration_spec.rb b/spec/easy_talk/activemodel_integration_spec.rb index 6f2e306..e9d2d10 100644 --- a/spec/easy_talk/activemodel_integration_spec.rb +++ b/spec/easy_talk/activemodel_integration_spec.rb @@ -8,6 +8,7 @@ include EasyTalk::Model validates :age, comparison: { greater_than: 21 } + validates :height, presence: true, numericality: { greater_than: 0 } validate do |person| MyValidator.new(person).validate @@ -31,7 +32,7 @@ def self.name property :name, String property :age, Integer property :height, Float - property :email, :object do + property :email, Hash do property :address, String property :verified, T::Boolean end @@ -39,9 +40,27 @@ def self.name end end - it 'errors on missing email' do + describe 'validating properties without ActiveModel validations' do + it 'does not validate the nil name' do + jim = user.new(name: nil, age: 30, height: 5.9, email: { address: 'jim@test.com', verified: false }) + expect(jim.valid?).to be true + end + + it 'does not validate the empty name' do + jim = user.new(name: '', age: 30, height: 5.9, email: { address: 'jim@test.com', verified: false }) + expect(jim.valid?).to be true + end + + it 'does not validate the property that is not present' do + jim = user.new(age: 30, height: 5.9, email: { address: 'jim@test.com', verified: false }) + expect(jim.valid?).to be true + end + end + + it 'is valid' do jim = user.new(name: 'Jim', age: 30, height: 5.9, email: { address: 'jim@test.com', verified: false }) expect(jim.valid?).to be true + expect(jim.errors.size).to eq(0) end it 'errors on invalid age' do @@ -57,4 +76,33 @@ def self.name expect(jim.errors.size).to eq(1) expect(jim.errors[:email]).to eq(['must end with @test.com']) end + + it 'errors on missing height' do + jim = user.new(name: 'Jim', age: 30, email: { address: 'jim@gmailcom', verified: false }) + expect(jim.valid?).to be false + expect(jim.errors[:height]).to eq(["can't be blank", 'is not a number']) + end + + it 'errors on invalid height' do + jim = user.new(name: 'Jim', age: 30, height: -5.9, email: { address: 'jim@gmailcom', verified: false }) + expect(jim.valid?).to be false + expect(jim.errors[:height]).to eq(['must be greater than 0']) + end + + it 'responds to #invalid?' do + jim = user.new(name: 'Jim', age: 18, height: 5.9, email: { address: 'jim@test.com', verified: false }) + expect(jim.invalid?).to be true + end + + it 'responds to #errors' do + jim = user.new(name: 'Jim', age: 18, height: 5.9, email: { address: 'jim@test.com', verified: false }) + jim.valid? + expect(jim.errors).to be_a(ActiveModel::Errors) + end + + it 'responds to #errors.messages' do + jim = user.new(name: 'Jim', age: 18, height: 5.9, email: { address: 'jim@test.com', verified: false }) + jim.valid? + expect(jim.errors.messages).to eq(age: ['must be greater than 21']) + end end diff --git a/spec/easy_talk/attribute_accessors_spec.rb b/spec/easy_talk/attribute_accessors_spec.rb new file mode 100644 index 0000000..adc52b7 --- /dev/null +++ b/spec/easy_talk/attribute_accessors_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'attribute accessors' do + let(:user) do + Class.new do + include EasyTalk::Model + + def self.name + 'User' + end + + define_schema do + property :name, String + property :age, Integer + property :email, Hash do + property :address, String + property :verified, T::Boolean + end + end + end + end + + it 'creates a getter and setter for name' do + jim = user.new + expect(jim.name = 'Jim').to eq('Jim') + expect(jim.name).to eq('Jim') + + jim.name = 'Juan' + expect(jim.name).to eq('Juan') + end + + it 'creates a getter and setter for age' do + jim = user.new + expect(jim.age = 30).to eq(30) + expect(jim.age).to eq(30) + jim.age = '30' ## does not raise an error. + expect(jim.age).to eq('30') ## no coercion yet. + end + + it 'creates a getter and setter for email' do + jim = user.new + jim.email = { address: 'jim@test.com', verified: false } + expect(jim.email).to eq({ address: 'jim@test.com', verified: false }) + end + + it "raises exception when assigning a value to a property that doesn't exist" do + jim = user.new + expect { jim.height = 5.9 }.to raise_error(NoMethodError) + end + + it 'raises exception when accessing a property that does not exist' do + jim = user.new + expect { jim.height }.to raise_error(NoMethodError) + end + + it 'allows hash style assignment' do + jim = user.new(name: 'Jim', age: 30, email: { address: 'jim@test.com', verified: false }) + expect(jim.name).to eq('Jim') + expect(jim.age).to eq(30) + expect(jim.email).to eq({ address: 'jim@test.com', verified: false }) + end +end diff --git a/spec/easy_talk/builders/boolean_builder_spec.rb b/spec/easy_talk/builders/boolean_builder_spec.rb new file mode 100644 index 0000000..1041a5d --- /dev/null +++ b/spec/easy_talk/builders/boolean_builder_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +RSpec.describe EasyTalk::Builders::BooleanBuilder do + describe '#build' do + context 'with basic configuration' do + it 'returns type boolean with no options' do + builder = described_class.new(:active) + expect(builder.build).to eq({ type: 'boolean' }) + end + + it 'includes title when provided' do + builder = described_class.new(:active, title: 'Account Status') + expect(builder.build).to eq({ type: 'boolean', title: 'Account Status' }) + end + + it 'includes description when provided' do + builder = described_class.new(:active, description: 'Whether the account is active') + expect(builder.build).to eq({ type: 'boolean', description: 'Whether the account is active' }) + end + end + + context 'with boolean-specific constraints' do + it 'includes enum constraint' do + builder = described_class.new(:active, enum: [true, false]) + expect(builder.build).to eq({ type: 'boolean', enum: [true, false] }) + end + + it 'includes default value when true' do + builder = described_class.new(:active, default: true) + expect(builder.build).to eq({ type: 'boolean', default: true }) + end + + it 'includes default value when false' do + builder = described_class.new(:active, default: false) + expect(builder.build).to eq({ type: 'boolean', default: false }) + end + + it 'combines multiple constraints' do + builder = described_class.new(:active, + title: 'Account Status', + description: 'Whether the account is active', + default: true, + enum: [true, false] + ) + + expect(builder.build).to eq({ + type: 'boolean', + title: 'Account Status', + description: 'Whether the account is active', + default: true, + enum: [true, false] + }) + end + end + + context 'with invalid configurations' do + it 'raises ArgumentError for unknown constraints' do + expect { + described_class.new(:active, invalid_option: 'value').build + }.to raise_error(ArgumentError, /Unknown key/) + end + + it 'raises TypeError when enum contains non-boolean values' do + expect { + described_class.new(:active, enum: [true, 'false']).build + }.to raise_error(TypeError) + end + + it 'raises TypeError when default is not a boolean' do + expect { + described_class.new(:active, default: 'true').build + }.to raise_error(TypeError) + end + + it 'raises TypeError when enum is not an array' do + expect { + described_class.new(:active, enum: 'true,false').build + }.to raise_error(TypeError) + end + end + + context 'with nil values' do + it 'excludes constraints with nil values' do + builder = described_class.new(:active, + default: nil, + enum: nil, + description: nil + ) + expect(builder.build).to eq({ type: 'boolean' }) + end + end + + context 'with optional flag' do + it 'includes optional flag when true' do + builder = described_class.new(:subscribed, optional: true) + expect(builder.build).to eq({ type: 'boolean', optional: true }) + end + + it 'excludes optional flag when false' do + builder = described_class.new(:subscribed, optional: false) + expect(builder.build).to eq({ type: 'boolean', optional: false}) + end + end + + context 'with edge cases' do + it 'handles empty enum array' do + builder = described_class.new(:active, enum: []) + expect(builder.build).to eq({ type: 'boolean', enum: [] }) + end + + it 'handles single enum value' do + builder = described_class.new(:active, enum: [true]) + expect(builder.build).to eq({ type: 'boolean', enum: [true] }) + end + end + end +end diff --git a/spec/easy_talk/integer_builder_spec.rb b/spec/easy_talk/builders/integer_builder_spec.rb similarity index 100% rename from spec/easy_talk/integer_builder_spec.rb rename to spec/easy_talk/builders/integer_builder_spec.rb diff --git a/spec/easy_talk/number_builder_spec.rb b/spec/easy_talk/builders/number_builder_spec.rb similarity index 100% rename from spec/easy_talk/number_builder_spec.rb rename to spec/easy_talk/builders/number_builder_spec.rb diff --git a/spec/easy_talk/builders/string_builder_spec.rb b/spec/easy_talk/builders/string_builder_spec.rb new file mode 100644 index 0000000..f8724d5 --- /dev/null +++ b/spec/easy_talk/builders/string_builder_spec.rb @@ -0,0 +1,168 @@ +require 'spec_helper' + +RSpec.describe EasyTalk::Builders::StringBuilder do + describe '#build' do + context 'with basic configuration' do + it 'returns type string with no options' do + builder = described_class.new(:name) + expect(builder.build).to eq({ type: 'string' }) + end + + it 'includes title when provided' do + builder = described_class.new(:name, title: 'Full Name') + expect(builder.build).to eq({ type: 'string', title: 'Full Name' }) + end + + it 'includes description when provided' do + builder = described_class.new(:name, description: 'Person\'s full name') + expect(builder.build).to eq({ type: 'string', description: 'Person\'s full name' }) + end + end + + context 'with string-specific validations' do + it 'includes format constraint' do + builder = described_class.new(:email, format: 'email') + expect(builder.build).to eq({ type: 'string', format: 'email' }) + end + + it 'includes pattern constraint' do + builder = described_class.new(:zip, pattern: '^\d{5}(-\d{4})?$') + expect(builder.build).to eq({ type: 'string', pattern: '^\d{5}(-\d{4})?$' }) + end + + it 'includes minLength constraint' do + builder = described_class.new(:password, min_length: 8) + expect(builder.build).to eq({ type: 'string', minLength: 8 }) + end + + it 'includes maxLength constraint' do + builder = described_class.new(:username, max_length: 20) + expect(builder.build).to eq({ type: 'string', maxLength: 20 }) + end + + it 'includes enum constraint' do + builder = described_class.new(:status, enum: ['active', 'inactive', 'pending']) + expect(builder.build).to eq({ type: 'string', enum: ['active', 'inactive', 'pending'] }) + end + + it 'includes const constraint' do + builder = described_class.new(:type, const: 'user') + expect(builder.build).to eq({ type: 'string', const: 'user' }) + end + + it 'includes default value' do + builder = described_class.new(:role, default: 'member') + expect(builder.build).to eq({ type: 'string', default: 'member' }) + end + + it 'combines multiple constraints' do + builder = described_class.new(:password, + min_length: 8, + max_length: 32, + pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', + description: 'Must contain letters and numbers' + ) + + expect(builder.build).to eq({ + type: 'string', + minLength: 8, + maxLength: 32, + pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', + description: 'Must contain letters and numbers' + }) + end + end + + context 'with invalid configurations' do + it 'raises ArgumentError for unknown constraints' do + expect { + described_class.new(:name, invalid_option: 'value').build + }.to raise_error(ArgumentError, /Unknown key: :invalid_option/) + end + + it 'raises TypeError when format is not a string' do + expect { + described_class.new(:email, format: 123).build + }.to raise_error(TypeError) + end + + it 'raises TypeError when pattern is not a string' do + expect { + described_class.new(:zip, pattern: 123).build + }.to raise_error(TypeError) + end + + it 'raises TypeError when minLength is not an integer' do + expect { + described_class.new(:name, min_length: '8').build + }.to raise_error(TypeError) + end + + it 'raises TypeError when maxLength is not an integer' do + expect { + described_class.new(:name, max_length: '20').build + }.to raise_error(TypeError) + end + + it 'raises TypeError when enum contains non-string values' do + expect { + described_class.new(:status, enum: ['active', 123, 'pending']).build + }.to raise_error(TypeError) + end + + it 'raises TypeError when const is not a string' do + expect { + described_class.new(:type, const: 123).build + }.to raise_error(TypeError) + end + end + + context 'with nil values' do + it 'excludes constraints with nil values' do + builder = described_class.new(:name, + min_length: nil, + max_length: nil, + pattern: nil, + format: nil + ) + expect(builder.build).to eq({ type: 'string' }) + end + end + + context 'with empty values on lenght validators' do + it 'raises a type error' do + builder = described_class.new(:name, min_length: '') + expect { + builder.build + }.to raise_error(TypeError) + end + + it 'raises a type error' do + builder = described_class.new(:name, max_length: '') + expect { + builder.build + }.to raise_error(TypeError) + end + end + + context "with empty values on pattern" do + it 'returns empty pattern' do + # this is invalid in json schema but there is not practical way to validate non empty strings. + builder = described_class.new(:name, pattern: '') + expect(builder.build).to eq({ type: 'string', pattern: '' }) + end + end + + context 'with optional flag' do + it 'includes optional flag when true' do + builder = described_class.new(:middle_name, optional: true) + expect(builder.build).to eq({ type: 'string', optional: true }) + end + + it 'includes optional flag when false' do + builder = described_class.new(:name, optional: false) + expect(builder.build).to eq({ type: 'string', optional: false }) + end + end + end +end diff --git a/spec/easy_talk/typed_array_builder_spec.rb b/spec/easy_talk/builders/typed_array_builder_spec.rb similarity index 100% rename from spec/easy_talk/typed_array_builder_spec.rb rename to spec/easy_talk/builders/typed_array_builder_spec.rb diff --git a/spec/easy_talk/examples/company_owner_spec.rb b/spec/easy_talk/examples/company_owner_spec.rb new file mode 100644 index 0000000..2093ec7 --- /dev/null +++ b/spec/easy_talk/examples/company_owner_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'json for user model' do + let(:company) do + Class.new do + include EasyTalk::Model + + def self.name + 'Company' + end + end + end + + let(:owner) do + Class.new do + include EasyTalk::Model + + def self.name + 'Owner' + end + + define_schema do + property :first_name, String + property :last_name, String + property :dob, String + end + end + end + + it 'enhances the schema using the provided block' do + stub_const('Owner', owner) + stub_const('Company', company) + + Company.define_schema do + title 'Company' + property :name, String + property :owner, Owner + end + + + expected_schema = { + 'type' => 'object', + 'title' => 'Company', + 'properties' => { + 'name' => { + 'type' => 'string' + }, + 'owner' => { + 'type' => 'object', + 'properties' => { + 'first_name' => { + 'type' => 'string' + }, + 'last_name' => { + 'type' => 'string' + }, + 'dob' => { + 'type' => 'string' + } + }, + 'required' => %w[first_name last_name dob] + } + }, + 'required' => %w[name owner] + } + + expect(company.json_schema).to include_json(expected_schema) + end +end diff --git a/spec/easy_talk/examples/payment_spec.rb b/spec/easy_talk/examples/payment_spec.rb index 18f343b..a7132be 100644 --- a/spec/easy_talk/examples/payment_spec.rb +++ b/spec/easy_talk/examples/payment_spec.rb @@ -103,8 +103,8 @@ def self.name }, "CardExpYear": { "type": 'integer', - "minimum": 2024, - "maximum": 2034 + "minimum": 2025, + "maximum": 2035 }, "CardCVV": { "type": 'string', diff --git a/spec/easy_talk/examples/ticketing_spec.rb b/spec/easy_talk/examples/ticketing_spec.rb index 8adf564..cce0c45 100644 --- a/spec/easy_talk/examples/ticketing_spec.rb +++ b/spec/easy_talk/examples/ticketing_spec.rb @@ -46,7 +46,7 @@ def self.name "description": 'Priority level', "enum": %w[ High - Meidum + Medium Low ] }, @@ -145,7 +145,7 @@ def self.name property :id, Integer, description: 'Unique identifier for the ticket' property :name, String, description: 'Title of the ticket' property :description, String, description: 'Detailed description of the task' - property :priority, String, enum: %w[High Meidum Low], description: 'Priority level' + property :priority, String, enum: %w[High Medium Low], description: 'Priority level' property :assignees, T::Array[String], description: 'List of users assigned to the task' property :subtasks, T.nilable(T::Array[Subtask]), description: 'List of subtasks associated with the main task' property :dependencies, T.nilable(T::Array[Integer]), description: 'List of ticket IDs that this ticket depends on' diff --git a/spec/easy_talk/examples/user_routing_table_spec.rb b/spec/easy_talk/examples/user_routing_table_spec.rb index eabcd84..bb5a8ee 100644 --- a/spec/easy_talk/examples/user_routing_table_spec.rb +++ b/spec/easy_talk/examples/user_routing_table_spec.rb @@ -12,7 +12,7 @@ def self.name end define_schema do - property 'user_id', :object do + property 'user_id', Hash do description 'Get a user by id' property :phrases, T::Array[String], title: 'trigger phrase examples', @@ -22,12 +22,12 @@ def self.name 'search for user by id {id}', 'user id {id}' ] - property :parameter, :object do + property :parameter, Hash do property :id, String, description: 'The user id' end property :path, String, description: 'The route path to get the user by id' end - property 'user_email', :object do + property 'user_email', Hash do description 'Get a user by email' property :phrases, T::Array[String], title: 'trigger phrase examples', @@ -37,12 +37,12 @@ def self.name 'search for user by email {email}', 'user email {email}' ] - property :parameter, :object do + property :parameter, Hash do property :email, String, description: 'the user email address' end property :path, String, const: 'user/:email', description: 'The route path to get the user by email' end - property 'user_id_authenticate', :object do + property 'user_id_authenticate', Hash do description 'Authenticate a user' property :phrases, T::Array[String], title: 'trigger phrase examples', @@ -52,7 +52,7 @@ def self.name 'authenticate user id {id}', 'authenticate user {id}' ] - property :parameters, :object do + property :parameters, Hash do property :id, String, description: 'the user id' end property :path, String, const: 'user/:id/authenticate', description: 'The route path to authenticate a user' diff --git a/spec/easy_talk/model_spec.rb b/spec/easy_talk/model_spec.rb index fe3071f..11adc4c 100644 --- a/spec/easy_talk/model_spec.rb +++ b/spec/easy_talk/model_spec.rb @@ -15,7 +15,7 @@ def self.name title 'User' property :name, String property :age, Integer - property :email, :object do + property :email, Hash do property :address, String property :verified, String end @@ -57,16 +57,8 @@ def self.name expect(user.schema_definition.name).to eq 'User' end - # it 'returns the schema' do - # expect(user.schema_definition.schema).to eq(expected_internal_schema) - # end - - it "returns the function name 'User'" do - expect(user.function_name).to eq('User') - end - - it 'does not inherit schema' do - expect(user.inherits_schema?).to eq(false) + it 'returns its attributes' do + expect(user.properties).to eq(%i[name age email]) end it 'returns a ref template' do @@ -205,11 +197,6 @@ def self.name expect(user.name).to eq('User') end - # FIXME: This test is failing because the email property hash keys are strings. - pending "returns the model's properties' values" do - expect(employee.properties).to eq(name: 'John', age: 21, email: { address: 'john@test.com', verified: 'false' }) - end - it 'returns a property' do expect(employee.name).to eq('John') end diff --git a/spec/easy_talk/optional_properties_spec.rb b/spec/easy_talk/optional_properties_spec.rb index e9c8dc1..c185aeb 100644 --- a/spec/easy_talk/optional_properties_spec.rb +++ b/spec/easy_talk/optional_properties_spec.rb @@ -14,7 +14,7 @@ def self.name define_schema do property :name, String property :age, Integer - property :email, :object do + property :email, Hash do property :address, String property :verified, String end @@ -43,7 +43,7 @@ def self.name define_schema do property :name, String property :age, Integer - property :email, :object do + property :email, Hash do property :address, String property :verified, String, optional: true end @@ -72,7 +72,7 @@ def self.name define_schema do property :name, String property :age, Integer, optional: true - property :email, :object do + property :email, Hash do property :address, String property :verified, String end @@ -101,7 +101,7 @@ def self.name define_schema do property :name, String property :age, Integer - property :email, :object, optional: true do + property :email, Hash, optional: true do property :address, String property :verified, String end @@ -130,7 +130,7 @@ def self.name define_schema do property :name, String property :age, T.nilable(Integer) - property :email, :object do + property :email, Hash do property :address, String property :verified, String end diff --git a/spec/easy_talk/schema_definition_spec.rb b/spec/easy_talk/schema_definition_spec.rb index 1547523..a33f298 100644 --- a/spec/easy_talk/schema_definition_spec.rb +++ b/spec/easy_talk/schema_definition_spec.rb @@ -133,4 +133,53 @@ def self.name expect(model.schema_definition.schema[:content_encoding]).to eq('ContentEncoding') end end + + describe 'compose' do + let(:subschema) do + { + title: 'Subschema', + properties: { + foo: { + constraints: {}, + type: Integer + } + } + } + end + + let(:expected_schema) do + { + properties: { + name: { + constraints: { + maximum: 100, + minimum: 1 + }, + type: String + } + }, + subschemas: [subschema], + title: 'Model' + } + end + + it 'appends a subschema to the model.schema_definition.schema' do + model.schema_definition.compose(subschema) + expect(model.schema_definition.schema).to eq(expected_schema) + end + end + + describe 'with invalid property name' do + it 'raises an error when it starts with a number' do + expect { model.schema_definition.property('1name', String) }.to raise_error(EasyTalk::InvalidPropertyNameError) + end + + it 'raises an error when it contains a special character' do + expect { model.schema_definition.property('name!', String) }.to raise_error(EasyTalk::InvalidPropertyNameError) + end + + it 'raises an error when it contains a space' do + expect { model.schema_definition.property('name name', String) }.to raise_error(EasyTalk::InvalidPropertyNameError) + end + end end diff --git a/spec/easy_talk/schema_validation_spec.rb b/spec/easy_talk/schema_validation_spec.rb index ca05a61..5d5c4a5 100644 --- a/spec/easy_talk/schema_validation_spec.rb +++ b/spec/easy_talk/schema_validation_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'validing json' do +RSpec.describe 'validating json' do let(:user) do Class.new do include EasyTalk::Model @@ -15,7 +15,7 @@ def self.name property :name, String property :age, Integer property :height, Float - property :email, :object do + property :email, Hash do property :address, String property :verified, String end @@ -23,39 +23,54 @@ def self.name end end - it 'errors on missing email' do - jim = user.new(name: 'Jim', age: 30, height: 5.9) - expect(jim.valid?).to be false - expect(jim.errors.size).to eq(1) - expect(jim.errors[:email]).to eq(['object at root is missing required properties: email']) - end + describe 'top level properties' do + pending 'validates the nil name' do + jim = user.new(name: nil, age: 30, height: 5.9, email: { address: 'jim@test.com', verified: 'true' }) + expect(jim.valid?).to be false + expect(jim.errors.size).to eq(1) + expect(jim.errors[:name]).to eq(['is not a valid string']) + end - it 'errors on invalid age, missing email' do - jim = user.new(name: 'Jim', age: 'thirty', height: 4.5, email: { address: 'test@jim.com', verified: 'true' }) - expect(jim.valid?).to be false - expect(jim.errors.size).to eq(1) - expect(jim.errors[:age]).to eq(['value at `/age` is not an integer']) - end + pending 'passes validation on empty name' do + jim = user.new(name: '', age: 30, height: 5.9, email: { address: 'jim@test.com', verified: 'true' }) + expect(jim.valid?).to be false + end - it 'errors on missing age email and height' do - jim = user.new(name: 'Jim', email: { address: 'jim@tst.com', verified: 'true' }) - expect(jim.valid?).to be false - expect(jim.errors.size).to eq(2) - expect(jim.errors[:age]).to eq(['object at root is missing required properties: age']) - expect(jim.errors[:height]).to eq(['object at root is missing required properties: height']) + pending 'validates age attribute is not present' do + jim = user.new(name: 'Jim', height: 5.9, email: { address: 'jim@test.com', verified: 'true' }) + expect(jim.valid?).to be false + expect(jim.errors.size).to eq(1) + expect(jim.errors[:age]).to eq(['is not a valid integer']) + end + + pending 'validates email attribute is not present' do + jim = user.new(name: 'Jim', age: 30, height: 5.9) + expect(jim.valid?).to be false + expect(jim.errors.size).to eq(1) + expect(jim.errors[:email]).to eq(["can't be blank"]) + end + + pending 'validates an empty email hash' do + jim = user.new(name: 'Jim', age: 30, height: 5.9, email: {}) + expect(jim.valid?).to be false + expect(jim.errors.size).to eq(1) + expect(jim.errors['email']).to eq(["can't be blank"]) + end end - it 'errors on invalid name, email and age' do - jim = user.new(name: nil, email: 'test@jim', age: 'thirty') - expect(jim.valid?).to be false - expect(jim.errors[:name]).to eq(['value at `/name` is not a string']) - expect(jim.errors[:email]).to eq(['value at `/email` is not an object']) - expect(jim.errors[:age]).to eq(['value at `/age` is not an integer']) + describe 'properties on nested objects' do + pending 'validates nested properties' do + jim = user.new(name: 'Jim', age: 30, height: 5.9, email: { address: 'test@test.com' }) + jim.valid? + expect(jim.errors['email.verified']).to eq(["can't be blank"]) + end end - it 'errors on verified' do - jim = user.new(name: 'Jim', email: { address: 'test@jim.com', verified: false }, age: 21, height: 5.9) - expect(jim.valid?).to be(false) - expect(jim.errors['email.verified']).to eq(['value at `/email/verified` is not a string']) + pending 'errors on invalid age' do + jim = user.new(name: 'Jim', age: 'thirty', height: 4.5, email: { address: 'test@jim.com', verified: 'true' }) + # binding.pry + expect(jim.valid?).to be false + expect(jim.errors.size).to eq(1) + expect(jim.errors[:age]).to eq(['is not a valid integer']) end end diff --git a/spec/easy_talk/string_builder_spec.rb b/spec/easy_talk/string_builder_spec.rb deleted file mode 100644 index c1a0c8a..0000000 --- a/spec/easy_talk/string_builder_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe EasyTalk::Builders::StringBuilder do - context 'with valid options' do - it 'returns a bare json object' do - prop = described_class.new(:name).build - expect(prop).to eq({ type: 'string' }) - end - - it 'includes a title' do - prop = described_class.new(:name, title: 'Title').build - expect(prop).to eq({ title: 'Title', type: 'string' }) - end - - it 'includes a description' do - prop = described_class.new(:name, description: 'Description').build - expect(prop).to eq({ description: 'Description', type: 'string' }) - end - - it 'includes the format' do - prop = described_class.new(:name, format: 'email').build - expect(prop).to eq({ type: 'string', format: 'email' }) - end - - it 'includes the pattern' do - prop = described_class.new(:name, pattern: '^[a-zA-Z]+$').build - expect(prop).to eq({ type: 'string', pattern: '^[a-zA-Z]+$' }) - end - - it 'includes the minLength' do - prop = described_class.new(:name, min_length: 1).build - expect(prop).to eq({ type: 'string', minLength: 1 }) - end - - it 'includes the maxLength' do - prop = described_class.new(:name, max_length: 10).build - expect(prop).to eq({ type: 'string', maxLength: 10 }) - end - - it 'includes the enum' do - prop = described_class.new(:name, enum: %w[one two three]).build - expect(prop).to eq({ type: 'string', enum: %w[one two three] }) - end - - it 'includes the const' do - prop = described_class.new(:name, const: 'one').build - expect(prop).to eq({ type: 'string', const: 'one' }) - end - - it 'includes the default' do - prop = described_class.new(:name, default: 'default').build - expect(prop).to eq({ type: 'string', default: 'default' }) - end - end - - context 'with invalid keys' do - it 'raises an error' do - error_msg = 'Unknown key: :invalid. Valid keys are: :title, :description, :optional, :format, :pattern, :min_length, :max_length, :enum, :const, :default' - expect do - described_class.new(:name, invalid: 'invalid').build - end.to raise_error(ArgumentError, error_msg) - end - end - - context 'with invalid values' do - it 'raises an error' do - expect do - described_class.new(:name, min_length: 'invalid').build - end.to raise_error(TypeError) - end - end - - context 'with empty string value' do - it 'raises a type error' do - expect do - described_class.new(:name, min_length: '').build - end.to raise_error(TypeError) - end - end - - context 'with nil value' do - it 'does not include the key' do - prop = described_class.new(:name, min_length: nil).build - expect(prop).to eq({ type: 'string' }) - end - end -end diff --git a/spec/easy_talk/tools/function_builder_spec.rb b/spec/easy_talk/tools/function_builder_spec.rb index 1d362dc..9105153 100644 --- a/spec/easy_talk/tools/function_builder_spec.rb +++ b/spec/easy_talk/tools/function_builder_spec.rb @@ -22,8 +22,8 @@ def self.name { type: 'function', function: { - name: 'Mymodel', - description: 'Correctly extracted `MyModel` with all the required parameters with correct types', + name: 'MyModel', + description: 'Correctly extracted `MyModel` with all the required parameters and correct types.', parameters: { type: 'object', properties: {