diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8135265..b9d4fc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [ main ] pull_request: branches: [ main ] + workflow_call: jobs: test: @@ -15,7 +16,9 @@ jobs: steps: - uses: actions/checkout@v4 - + with: + fetch-depth: 0 + - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96ff009..0f0cc87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,24 +7,7 @@ on: jobs: test: - runs-on: ubuntu-latest - strategy: - matrix: - ruby-version: ['3.1', '3.2', '3.3', '3.4'] - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Ruby ${{ matrix.ruby-version }} - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true - - - name: Run test suite - run: ./bin/test + uses: ./.github/workflows/ci.yml release: needs: test diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..ee6f80b --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,8 @@ +# Release process + +1. Bump the version in `lib/ruby_llm/schema/version.rb` +2. Run `bundle install` to update the gemspec +3. Commit the changes with a message like "Bump version to X.Y.Z" +4. Run `bundle exec rake release:prepare` to create a release branch and push it to GitHub +5. Github Actions will run the tests and publish the gem if they pass +6. Delete the release branch: `git branch -d release/ && git push origin --delete release/` \ No newline at end of file diff --git a/spec/ruby_llm/schema/entry_points/class_inheritance_spec.rb b/spec/ruby_llm/schema/entry_points/class_inheritance_spec.rb new file mode 100644 index 0000000..72f7aa0 --- /dev/null +++ b/spec/ruby_llm/schema/entry_points/class_inheritance_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "class inheritance approach" do + include SchemaBuilders + + describe "schema attributes" do + let(:base_schema) { build_schema_class { string :title } } + + it "derives schema names from constants, instances, and defaults" do + stub_const("NamedSchema", build_schema_class) + expect(NamedSchema.new.to_json_schema[:name]).to eq("NamedSchema") + + expect(base_schema.new.to_json_schema[:name]).to eq("Schema") + expect(base_schema.new("CustomName").to_json_schema[:name]).to eq("CustomName") + end + + it "honours description precedence" do + schema_with_description = build_schema_class do + description "Class-level description" + string :title + end + + anonymous_output = base_schema.new.to_json_schema + expect(anonymous_output[:description]).to be_nil + + class_level_output = schema_with_description.new.to_json_schema + expect(class_level_output[:description]).to eq("Class-level description") + + instance_override = schema_with_description.new("Test", description: "Instance description").to_json_schema + expect(instance_override[:description]).to eq("Instance description") + end + + it "controls additional properties and strictness" do + default_output = base_schema.new.to_json_schema + expect(default_output[:schema][:additionalProperties]).to eq(false) + expect(default_output[:schema][:strict]).to eq(true) + + configured_schema = build_schema_class do + additional_properties true + strict false + string :title + end + + configured_output = configured_schema.new.to_json_schema + expect(configured_output[:schema][:additionalProperties]).to eq(true) + expect(configured_output[:schema][:strict]).to eq(false) + end + + it "renders structured JSON schema" do + configured_schema = build_schema_class do + description "Test description" + additional_properties false + string :title + integer :count, required: false + end + + output = configured_schema.new("ConfiguredSchema").to_json_schema + + expect(output).to include( + name: "ConfiguredSchema", + description: "Test description", + schema: hash_including( + type: "object", + properties: { + title: {type: "string"}, + count: {type: "integer"} + }, + required: [:title], + additionalProperties: false, + strict: true + ) + ) + end + end + + describe "comprehensive scenario" do + let(:schema_class) do + build_schema_class do + description "Comprehensive test schema" + additional_properties true + strict true + + string :name, description: "Name field" + integer :count + boolean :active, required: false + + object :config do + string :setting + end + + array :tags, of: :string + + any_of :status do + string + null + end + end + end + + it "supports full-feature schemas" do + json_output = schema_class.new("TestSchema").to_json_schema + + expect(json_output[:name]).to eq("TestSchema") + expect(json_output[:schema][:additionalProperties]).to eq(true) + expect(json_output[:schema][:strict]).to eq(true) + expect(json_output[:schema][:properties].keys).to contain_exactly( + :name, :count, :active, :config, :tags, :status + ) + expect(json_output[:schema][:required]).to contain_exactly(:name, :count, :config, :tags, :status) + end + end +end diff --git a/spec/ruby_llm/schema/entry_points/factory_spec.rb b/spec/ruby_llm/schema/entry_points/factory_spec.rb new file mode 100644 index 0000000..3045c1a --- /dev/null +++ b/spec/ruby_llm/schema/entry_points/factory_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "factory method (.create) approach" do + include SchemaBuilders + + describe "schema attributes" do + let(:base_schema) { build_factory_schema { string :title } } + + it "derives schema names from constants, instances, and defaults" do + stub_const("NamedFactorySchema", build_factory_schema { string :title }) + expect(NamedFactorySchema.new.to_json_schema[:name]).to eq("NamedFactorySchema") + + expect(base_schema.new.to_json_schema[:name]).to eq("Schema") + expect(base_schema.new("CustomName").to_json_schema[:name]).to eq("CustomName") + end + + it "honours description precedence" do + schema_with_description = build_factory_schema do + description "Factory description" + string :title + end + + anonymous_output = base_schema.new.to_json_schema + expect(anonymous_output[:description]).to be_nil + + class_level_output = schema_with_description.new.to_json_schema + expect(class_level_output[:description]).to eq("Factory description") + + instance_override = schema_with_description.new("NamedSchema", description: "Instance description").to_json_schema + expect(instance_override[:description]).to eq("Instance description") + end + + it "controls additional properties and strictness" do + default_output = base_schema.new.to_json_schema + expect(default_output[:schema][:additionalProperties]).to eq(false) + expect(default_output[:schema][:strict]).to eq(true) + + configured_schema = build_factory_schema do + additional_properties true + strict false + string :title + end + + configured_output = configured_schema.new.to_json_schema + expect(configured_output[:schema][:additionalProperties]).to eq(true) + expect(configured_output[:schema][:strict]).to eq(false) + end + + it "renders structured JSON schema" do + configured_schema = build_factory_schema do + description "Factory test description" + additional_properties false + string :title + integer :count, required: false + end + + output = configured_schema.new("FactoryConfiguredSchema").to_json_schema + + expect(output).to include( + name: "FactoryConfiguredSchema", + description: "Factory test description", + schema: hash_including( + type: "object", + properties: { + title: {type: "string"}, + count: {type: "integer"} + }, + required: [:title], + additionalProperties: false, + strict: true + ) + ) + end + end + + describe "comprehensive scenario" do + let(:schema_class) do + build_factory_schema do + description "Comprehensive factory schema" + additional_properties true + strict true + + string :name, description: "Name field" + integer :count + boolean :active, required: false + + object :config do + string :setting + end + + array :tags, of: :string + + any_of :status do + string + null + end + end + end + + it "supports full-feature schemas" do + json_output = schema_class.new("FactorySchema").to_json_schema + + expect(json_output[:name]).to eq("FactorySchema") + expect(json_output[:schema][:additionalProperties]).to eq(true) + expect(json_output[:schema][:strict]).to eq(true) + expect(json_output[:schema][:properties].keys).to contain_exactly( + :name, :count, :active, :config, :tags, :status + ) + expect(json_output[:schema][:required]).to contain_exactly(:name, :count, :config, :tags, :status) + end + end +end diff --git a/spec/ruby_llm/schema/entry_points/helpers_spec.rb b/spec/ruby_llm/schema/entry_points/helpers_spec.rb new file mode 100644 index 0000000..c52de32 --- /dev/null +++ b/spec/ruby_llm/schema/entry_points/helpers_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "helpers module approach" do + include SchemaBuilders + + describe "schema attributes" do + it "derives schema names from parameters and defaults" do + named_schema = build_helper_schema("ProvidedName") { string :title } + expect(named_schema.to_json_schema[:name]).to eq("ProvidedName") + + default_schema = build_helper_schema { string :title } + expect(default_schema.to_json_schema[:name]).to eq("Schema") + end + + it "honours description precedence" do + default_output = build_helper_schema("TestSchema") { string :title }.to_json_schema + expect(default_output[:description]).to be_nil + + block_description = build_helper_schema("TestSchema") do + description "Block description" + string :title + end.to_json_schema + expect(block_description[:description]).to eq("Block description") + + parameter_override = build_helper_schema( + "TestSchema", + description: "Parameter description" + ) do + description "Block description" + string :title + end.to_json_schema + expect(parameter_override[:description]).to eq("Parameter description") + end + + it "controls additional properties and strictness" do + default_output = build_helper_schema { string :title }.to_json_schema + expect(default_output[:schema][:additionalProperties]).to eq(false) + expect(default_output[:schema][:strict]).to eq(true) + + configured_output = build_helper_schema do + additional_properties true + strict false + string :title + end.to_json_schema + + expect(configured_output[:schema][:additionalProperties]).to eq(true) + expect(configured_output[:schema][:strict]).to eq(false) + end + + it "renders structured JSON schema" do + json_output = build_helper_schema( + "HelperConfiguredSchema", + description: "Helper test description" + ) do + additional_properties false + string :title + integer :count, required: false + end.to_json_schema + + expect(json_output).to include( + name: "HelperConfiguredSchema", + description: "Helper test description", + schema: hash_including( + type: "object", + properties: { + title: {type: "string"}, + count: {type: "integer"} + }, + required: [:title], + additionalProperties: false, + strict: true + ) + ) + end + end + + describe "comprehensive scenario" do + it "supports full-feature schemas" do + json_output = build_helper_schema( + "HelperSchema", + description: "Comprehensive helper schema" + ) do + additional_properties true + strict true + + string :name, description: "Name field" + integer :count + boolean :active, required: false + + object :config do + string :setting + end + + array :tags, of: :string + + any_of :status do + string + null + end + end.to_json_schema + + expect(json_output[:name]).to eq("HelperSchema") + expect(json_output[:description]).to eq("Comprehensive helper schema") + expect(json_output[:schema][:additionalProperties]).to eq(true) + expect(json_output[:schema][:strict]).to eq(true) + expect(json_output[:schema][:properties].keys).to contain_exactly( + :name, :count, :active, :config, :tags, :status + ) + expect(json_output[:schema][:required]).to contain_exactly(:name, :count, :config, :tags, :status) + end + end +end diff --git a/spec/ruby_llm/schema/properties/any_of_spec.rb b/spec/ruby_llm/schema/properties/any_of_spec.rb new file mode 100644 index 0000000..2502805 --- /dev/null +++ b/spec/ruby_llm/schema/properties/any_of_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "anyOf properties" do + let(:schema_class) { Class.new(described_class) } + + it "supports any_of with mixed types including objects" do + schema_class.any_of :flexible_field do + string enum: %w[option1 option2] + integer + object do + string :nested_field + end + null + end + + properties = schema_class.properties + any_of_schemas = properties[:flexible_field][:anyOf] + + expect(any_of_schemas).to include( + {type: "string", enum: %w[option1 option2]}, + {type: "integer"}, + {type: "null"} + ) + + object_schema = any_of_schemas.find { |schema| schema[:type] == "object" } + expect(object_schema[:properties][:nested_field]).to eq({type: "string"}) + end + + it "supports arrays of anyOf types" do + schema_class.array :items do + any_of :value do + string :alphanumeric + number :numeric + end + end + + any_of_schemas = schema_class.properties[:items][:items][:anyOf] + expect(any_of_schemas.map { |schema| schema[:type] }).to include("string", "number") + end +end diff --git a/spec/ruby_llm/schema/properties/arrays_spec.rb b/spec/ruby_llm/schema/properties/arrays_spec.rb new file mode 100644 index 0000000..45277ad --- /dev/null +++ b/spec/ruby_llm/schema/properties/arrays_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "array properties" do + let(:schema_class) { Class.new(described_class) } + + it "supports arrays with primitive types and descriptions" do + schema_class.array :strings, of: :string, description: "String array" + schema_class.array :numbers, of: :number + schema_class.array :integers, of: :integer + schema_class.array :booleans, of: :boolean + + properties = schema_class.properties + + expect(properties[:strings]).to eq({type: "array", items: {type: "string"}, description: "String array"}) + expect(properties[:numbers]).to eq({type: "array", items: {type: "number"}}) + expect(properties[:integers]).to eq({type: "array", items: {type: "integer"}}) + expect(properties[:booleans]).to eq({type: "array", items: {type: "boolean"}}) + end + + it "supports arrays with constraints" do + schema_class.array :strings, of: :string, min_items: 1, max_items: 10, description: "String array" + + properties = schema_class.properties + expect(properties[:strings]).to eq({type: "array", items: {type: "string"}, minItems: 1, maxItems: 10, description: "String array"}) + end + + it "supports arrays with object definitions" do + schema_class.array :items do + object do + string :name + integer :value + end + end + + properties = schema_class.properties + expect(properties[:items][:items]).to include( + type: "object", + properties: { + name: {type: "string"}, + value: {type: "integer"} + }, + required: %i[name value], + additionalProperties: false + ) + end + + it "supports arrays with references to defined schemas" do + schema_class.define :product do + string :name + number :price + end + + schema_class.array :products, of: :product + + properties = schema_class.properties + expect(properties[:products]).to eq({ + type: "array", + items: {"$ref" => "#/$defs/product"} + }) + end +end diff --git a/spec/ruby_llm/schema/properties/booleans_spec.rb b/spec/ruby_llm/schema/properties/booleans_spec.rb new file mode 100644 index 0000000..70f7c5b --- /dev/null +++ b/spec/ruby_llm/schema/properties/booleans_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "boolean properties" do + let(:schema_class) { Class.new(described_class) } + + it "supports boolean type with description" do + schema_class.boolean :enabled, description: "Enabled field" + + properties = schema_class.properties + expect(properties[:enabled]).to eq({type: "boolean", description: "Enabled field"}) + end +end diff --git a/spec/ruby_llm/schema/properties/definitions_reference_spec.rb b/spec/ruby_llm/schema/properties/definitions_reference_spec.rb new file mode 100644 index 0000000..f34aecc --- /dev/null +++ b/spec/ruby_llm/schema/properties/definitions_reference_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "definitions and references" do + let(:schema_class) { Class.new(described_class) } + + it "supports defining and referencing reusable schemas" do + schema_class.define :address do + string :street + string :city + end + + schema_class.object :user do + string :name + array :addresses, of: :address + end + + ref_hash = schema_class.reference(:address) + expect(ref_hash).to eq({"$ref" => "#/$defs/address"}) + + instance = schema_class.new + json_output = instance.to_json_schema + + expect(json_output[:schema]["$defs"][:address]).to include( + type: "object", + properties: { + street: {type: "string"}, + city: {type: "string"} + }, + required: %i[street city] + ) + + user_props = json_output[:schema][:properties][:user][:properties] + expect(user_props[:addresses][:items]).to eq({"$ref" => "#/$defs/address"}) + end + + it "supports reference to the root schema" do + schema_class.string :element_type, enum: %w[input button] + schema_class.string :label + schema_class.object :sub_schema, of: :root + + instance = schema_class.new + json_output = instance.to_json_schema + + expect(json_output[:schema][:properties][:sub_schema]).to eq({"$ref" => "#"}) + end + + it "supports reference to a defined schema by block" do + schema_class.define :address do + string :street + string :city + end + + schema_class.object :user do + string :name + object :address do + reference :address + end + end + + instance = schema_class.new + json_output = instance.to_json_schema + + expect(json_output[:schema][:properties][:user][:properties][:address]).to eq({"$ref" => "#/$defs/address"}) + expect(json_output[:schema]["$defs"][:address]).to eq({ + type: "object", + properties: { + street: {type: "string"}, + city: {type: "string"} + }, + required: %i[street city], + additionalProperties: false + }) + end + + it "supports reference to a defined schema by of option" do + schema_class.define :address do + string :street + string :city + end + + schema_class.object :user do + string :name + object :address, of: :address + end + + instance = schema_class.new + json_output = instance.to_json_schema + + expect(json_output[:schema][:properties][:user][:properties][:address]).to eq({"$ref" => "#/$defs/address"}) + expect(json_output[:schema]["$defs"][:address]).to eq({ + type: "object", + properties: { + street: {type: "string"}, + city: {type: "string"} + }, + required: %i[street city], + additionalProperties: false + }) + end + + it "supports object with symbol reference" do + schema_class.define :address do + string :street + string :city + end + + schema_class.object :headquarters, of: :address + + properties = schema_class.properties + expect(properties[:headquarters]).to eq({"$ref" => "#/$defs/address"}) + end + + it "shows deprecation warning if using reference option" do + schema_class.define :address do + string :street + string :city + end + + expect do + schema_class.object :user do + string :name + object :address, reference: :address + end + end.to output(/DEPRECATION.*reference/).to_stderr + end +end diff --git a/spec/ruby_llm/schema/properties/nested_schemas_spec.rb b/spec/ruby_llm/schema/properties/nested_schemas_spec.rb new file mode 100644 index 0000000..158f69e --- /dev/null +++ b/spec/ruby_llm/schema/properties/nested_schemas_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "nested schemas" do + let(:person_schema) do + Class.new(described_class) do + string :name, description: "Person's name" + integer :age, description: "Person's age" + end + end + + let(:address_schema) do + Class.new(described_class) do + string :street, description: "Street address" + string :city, description: "City name" + string :zipcode, description: "Postal code" + end + end + + let(:person_schema_hash) do + { + type: "object", + properties: { + name: {type: "string", description: "Person's name"}, + age: {type: "integer", description: "Person's age"} + }, + required: %i[name age], + additionalProperties: false + } + end + + let(:address_schema_hash) do + { + type: "object", + properties: { + street: {type: "string", description: "Street address"}, + city: {type: "string", description: "City name"}, + zipcode: {type: "string", description: "Postal code"} + }, + required: %i[street city zipcode], + additionalProperties: false + } + end + + before do + stub_const("PersonSchema", person_schema) + stub_const("AddressSchema", address_schema) + end + + it "supports deeply nested objects" do + schema_class = Class.new(described_class) + schema_class.object :level1 do + object :level2 do + object :level3 do + string :deep_value + end + end + end + + instance = schema_class.new + properties = instance.to_json_schema[:schema][:properties] + + level3 = properties[:level1][:properties][:level2][:properties][:level3] + expect(level3[:properties][:deep_value]).to eq({type: "string"}) + end + + it "supports arrays of schema classes" do + schema_class = Class.new(described_class) + schema_class.array :employees, of: PersonSchema + + properties = schema_class.properties + expect(properties[:employees]).to eq({ + type: "array", + items: person_schema_hash + }) + expect(schema_class.definitions).to be_empty + end + + it "supports arrays of schema classes with description" do + schema_class = Class.new(described_class) + schema_class.array :team_members, of: PersonSchema, description: "List of team members" + + properties = schema_class.properties + expect(properties[:team_members]).to eq({ + type: "array", + description: "List of team members", + items: person_schema_hash + }) + end + + it "handles multiple schema insertions" do + company_schema = Class.new(described_class) + company_schema.string :name + company_schema.array :employees, of: PersonSchema + company_schema.object :headquarters, of: AddressSchema + company_schema.object :founder do + PersonSchema.new + end + + properties = company_schema.properties + expect(properties[:employees]).to eq({type: "array", items: person_schema_hash}) + expect(properties[:headquarters]).to eq(address_schema_hash) + expect(properties[:founder]).to eq(person_schema_hash) + expect(company_schema.definitions).to be_empty + end + + it "creates separate inline schemas for each usage" do + company_schema = Class.new(described_class) + company_schema.array :employees, of: PersonSchema + company_schema.object :ceo, of: PersonSchema + company_schema.object :founder do + PersonSchema.new + end + + properties = company_schema.properties + + expect(properties[:employees][:items]).to eq(person_schema_hash) + expect(properties[:ceo]).to eq(person_schema_hash) + expect(properties[:founder]).to eq(person_schema_hash) + expect(company_schema.definitions).to be_empty + end + + it "generates proper JSON schema output with inline schemas" do + company_schema = Class.new(described_class) + company_schema.string :name + company_schema.array :employees, of: PersonSchema + company_schema.object :founder, of: PersonSchema + + stub_const("CompanySchema", company_schema) + instance = company_schema.new("CompanySchema") + + json_output = instance.to_json_schema + expect(json_output[:schema][:type]).to eq("object") + expect(json_output[:schema][:properties][:employees][:items]).to eq(person_schema_hash) + expect(json_output[:schema][:properties][:founder]).to eq(person_schema_hash) + expect(json_output[:schema]).not_to have_key("$defs") + end +end diff --git a/spec/ruby_llm/schema/properties/null_spec.rb b/spec/ruby_llm/schema/properties/null_spec.rb new file mode 100644 index 0000000..cb9929d --- /dev/null +++ b/spec/ruby_llm/schema/properties/null_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "null properties" do + let(:schema_class) { Class.new(described_class) } + + it "supports null type with description" do + schema_class.null :placeholder, description: "Null field" + + properties = schema_class.properties + expect(properties[:placeholder]).to eq({type: "null", description: "Null field"}) + end +end diff --git a/spec/ruby_llm/schema/properties/numbers_spec.rb b/spec/ruby_llm/schema/properties/numbers_spec.rb new file mode 100644 index 0000000..9e8cb12 --- /dev/null +++ b/spec/ruby_llm/schema/properties/numbers_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "numeric properties" do + let(:schema_class) { Class.new(described_class) } + + it "supports number type with constraints" do + schema_class.number :price, minimum: 0, maximum: 1000, multiple_of: 0.01, description: "Price field" + + properties = schema_class.properties + expect(properties[:price]).to eq({ + type: "number", + minimum: 0, + maximum: 1000, + multipleOf: 0.01, + description: "Price field" + }) + end + + it "supports number type with description" do + schema_class.number :price, description: "Price field" + + properties = schema_class.properties + expect(properties[:price]).to eq({type: "number", description: "Price field"}) + end + + it "supports integer type with description" do + schema_class.integer :count, description: "Count value" + + properties = schema_class.properties + expect(properties[:count]).to eq({type: "integer", description: "Count value"}) + end +end diff --git a/spec/ruby_llm/schema/properties/objects_spec.rb b/spec/ruby_llm/schema/properties/objects_spec.rb new file mode 100644 index 0000000..08f15ad --- /dev/null +++ b/spec/ruby_llm/schema/properties/objects_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "object properties" do + let(:schema_class) { Class.new(described_class) } + + it "supports object properties with inline attributes" do + schema_class.object :address do + string :street + string :city + integer :zip_code, required: false + end + + properties = schema_class.properties + expect(properties[:address]).to include( + type: "object", + properties: { + street: {type: "string"}, + city: {type: "string"}, + zip_code: {type: "integer"} + }, + required: %i[street city], + additionalProperties: false + ) + end + + context "when referencing another schema" do + let(:person_schema) do + Class.new(described_class) do + string :name, description: "Person's name" + integer :age, description: "Person's age" + end + end + + let(:person_schema_hash) do + { + type: "object", + properties: { + name: {type: "string", description: "Person's name"}, + age: {type: "integer", description: "Person's age"} + }, + required: %i[name age], + additionalProperties: false + } + end + + before do + stub_const("PersonSchema", person_schema) + end + + it "supports object with of parameter" do + schema_class.object :founder, of: PersonSchema + + properties = schema_class.properties + expect(properties[:founder]).to eq(person_schema_hash) + expect(schema_class.definitions).to be_empty + end + + it "supports object with of parameter and description" do + schema_class.object :primary_contact, of: PersonSchema, description: "Main contact person" + + properties = schema_class.properties + expect(properties[:primary_contact]).to eq(person_schema_hash.merge(description: "Main contact person")) + end + + it "supports Schema.new inside object block" do + schema_class.object :founder do + PersonSchema.new + end + + properties = schema_class.properties + expect(properties[:founder]).to eq(person_schema_hash) + expect(schema_class.definitions).to be_empty + end + + it "supports Schema.new with description" do + schema_class.object :ceo, description: "Chief Executive Officer" do + PersonSchema.new + end + + properties = schema_class.properties + expect(properties[:ceo]).to eq(person_schema_hash.merge(description: "Chief Executive Officer")) + end + end +end diff --git a/spec/ruby_llm/schema/properties/one_of_spec.rb b/spec/ruby_llm/schema/properties/one_of_spec.rb new file mode 100644 index 0000000..4dedb2f --- /dev/null +++ b/spec/ruby_llm/schema/properties/one_of_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "oneOf properties" do + let(:schema_class) { Class.new(described_class) } + + it "supports one_of with mixed types including objects" do + schema_class.one_of :exclusive_field do + string enum: %w[option1 option2] + integer + object do + string :nested_field + end + null + end + + properties = schema_class.properties + one_of_schemas = properties[:exclusive_field][:oneOf] + + expect(one_of_schemas).to include( + {type: "string", enum: %w[option1 option2]}, + {type: "integer"}, + {type: "null"} + ) + + object_schema = one_of_schemas.find { |schema| schema[:type] == "object" } + expect(object_schema[:properties][:nested_field]).to eq({type: "string"}) + end + + it "supports arrays of oneOf types" do + schema_class.array :items do + one_of :value do + string :alphanumeric + number :numeric + end + end + + one_of_schemas = schema_class.properties[:items][:items][:oneOf] + expect(one_of_schemas.map { |schema| schema[:type] }).to include("string", "number") + end + + it "supports basic oneOf with primitive types" do + schema_class.one_of :status do + string enum: %w[active inactive] + integer + boolean + end + + properties = schema_class.properties + one_of_schemas = properties[:status][:oneOf] + + expect(one_of_schemas).to include( + {type: "string", enum: %w[active inactive]}, + {type: "integer"}, + {type: "boolean"} + ) + end +end diff --git a/spec/ruby_llm/schema/properties/strings_spec.rb b/spec/ruby_llm/schema/properties/strings_spec.rb new file mode 100644 index 0000000..86a6a92 --- /dev/null +++ b/spec/ruby_llm/schema/properties/strings_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "string properties" do + let(:schema_class) { Class.new(described_class) } + + it "supports string type with enum and description" do + schema_class.string :status, enum: %w[active inactive], description: "Status field" + + properties = schema_class.properties + expect(properties[:status]).to eq({ + type: "string", + enum: %w[active inactive], + description: "Status field" + }) + end + + it "supports string type with additional options" do + schema_class.string :email, format: "email", min_length: 5, max_length: 100, pattern: "\\S+@\\S+", description: "Email field" + + properties = schema_class.properties + expect(properties[:email]).to eq({ + type: "string", + format: "email", + minLength: 5, + maxLength: 100, + pattern: "\\S+@\\S+", + description: "Email field" + }) + end + + it "handles required vs optional string properties" do + schema_class.string :required_field + schema_class.string :optional_field, required: false + + expect(schema_class.required_properties).to include(:required_field) + expect(schema_class.required_properties).not_to include(:optional_field) + end +end diff --git a/spec/ruby_llm/schema/robustness/comprehensive_scenarios_spec.rb b/spec/ruby_llm/schema/robustness/comprehensive_scenarios_spec.rb new file mode 100644 index 0000000..5f1068c --- /dev/null +++ b/spec/ruby_llm/schema/robustness/comprehensive_scenarios_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "comprehensive scenarios" do + include SchemaBuilders + + it "handles edge cases" do + empty_output = build_schema_class.new("EmptySchema").to_json_schema + expect(empty_output[:schema][:properties]).to eq({}) + expect(empty_output[:schema][:required]).to eq([]) + + optional_output = build_schema_class { + string :optional1, required: false + integer :optional2, required: false + }.new.to_json_schema + + expect(optional_output[:schema][:required]).to eq([]) + expect(optional_output[:schema][:properties].keys).to contain_exactly(:optional1, :optional2) + end + + it "handles complex nested structures with all features" do + complex_output = build_schema_class { + string :id, description: "Unique identifier" + + object :metadata do + string :created_by + integer :version + boolean :published, required: false + end + + array :tags, of: :string, description: "Resource tags" + + array :items do + object do + string :name + any_of :value do + string + number + boolean + null + end + end + end + + any_of :status do + string enum: %w[draft published] + null + end + + define :author do + string :name + string :email + end + + array :authors, of: :author + }.new("ComplexSchema").to_json_schema + + expect(complex_output[:schema][:properties].keys).to contain_exactly( + :id, :metadata, :tags, :items, :status, :authors + ) + expect(complex_output[:schema]["$defs"][:author]).to be_a(Hash) + expect(complex_output[:schema][:required]).to include(:id, :metadata, :tags, :items, :status, :authors) + expect(complex_output[:schema][:properties][:id][:description]).to eq("Unique identifier") + expect(complex_output[:schema][:properties][:tags][:description]).to eq("Resource tags") + end +end diff --git a/spec/ruby_llm/schema/robustness/error_handling_spec.rb b/spec/ruby_llm/schema/robustness/error_handling_spec.rb new file mode 100644 index 0000000..5dd63ca --- /dev/null +++ b/spec/ruby_llm/schema/robustness/error_handling_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "error handling" do + include SchemaBuilders + + let(:schema_class) { build_schema_class } + + it "raises appropriate errors for invalid configurations" do + expect { + schema_class.array :items, of: 123 + }.to raise_error(RubyLLM::Schema::InvalidArrayTypeError, /Invalid array type: 123./) + + expect { + schema_class.array :items, of: "invalid" + }.to raise_error(RubyLLM::Schema::InvalidArrayTypeError, /Invalid array type: "invalid"./) + end + + it "raises clear errors for invalid object types" do + expect { + schema_class.object :item, of: 123 + }.to raise_error(RubyLLM::Schema::InvalidObjectTypeError, /Invalid object type: 123.*Must be a symbol reference, a Schema class, or a Schema instance/) + + expect { + schema_class.object :item, of: "invalid" + }.to raise_error(RubyLLM::Schema::InvalidObjectTypeError, /Invalid object type: "invalid".*Must be a symbol reference, a Schema class, or a Schema instance/) + + expect { + schema_class.object :item, of: String + }.to raise_error(RubyLLM::Schema::InvalidObjectTypeError, /Invalid object type: String.*Class must inherit from RubyLLM::Schema/) + end + + it "accepts anonymous schema classes with inline schemas" do + anonymous_schema = build_schema_class do + string :test_field + end + + expect { + schema_class.object :item, of: anonymous_schema + }.not_to raise_error + + properties = schema_class.properties + expect(properties[:item]).to eq({ + type: "object", + properties: { + test_field: {type: "string"} + }, + required: [:test_field], + additionalProperties: false + }) + end + + it "accepts symbols as references (even if undefined)" do + expect { + schema_class.array :items, of: :undefined_reference + }.not_to raise_error + + properties = schema_class.properties + expect(properties[:items][:items]).to eq({"$ref" => "#/$defs/undefined_reference"}) + end +end diff --git a/spec/ruby_llm/schema/robustness/instance_methods_spec.rb b/spec/ruby_llm/schema/robustness/instance_methods_spec.rb new file mode 100644 index 0000000..0dcb856 --- /dev/null +++ b/spec/ruby_llm/schema/robustness/instance_methods_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "instance methods" do + include SchemaBuilders + + let(:schema_class) { build_schema_class } + + it "handles naming correctly" do + stub_const("TestSchemaClass", build_schema_class) + expect(TestSchemaClass.new.to_json_schema[:name]).to eq("TestSchemaClass") + + expect(schema_class.new.to_json_schema[:name]).to eq("Schema") + expect(schema_class.new("CustomName").to_json_schema[:name]).to eq("CustomName") + + described_output = schema_class.new("TestName", description: "Custom description").to_json_schema + expect(described_output[:description]).to eq("Custom description") + end + + it "supports method delegation for schema methods" do + instance = schema_class.new + + expect(instance).to respond_to( + :string, :number, :integer, :boolean, :array, :object, :any_of, :one_of, :null + ) + expect(instance).not_to respond_to(:unknown_method) + end + + it "produces correctly structured JSON schema and JSON output" do + schema_with_fields = build_schema_class do + string :name + integer :age, required: false + end + + json_output = schema_with_fields.new("TestSchema").to_json_schema + + expect(json_output).to include( + name: "TestSchema", + description: nil, + schema: hash_including( + type: "object", + properties: { + name: {type: "string"}, + age: {type: "integer"} + }, + required: [:name], + additionalProperties: false, + strict: true + ) + ) + + json_string = schema_with_fields.new("TestSchema").to_json + expect(json_string).to be_a(String) + expect(JSON.parse(json_string)["name"]).to eq("TestSchema") + end +end diff --git a/spec/ruby_llm/schema/robustness/validation_spec.rb b/spec/ruby_llm/schema/robustness/validation_spec.rb new file mode 100644 index 0000000..94cdc19 --- /dev/null +++ b/spec/ruby_llm/schema/robustness/validation_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "validation" do + include SchemaBuilders + + let(:schema_class) { build_schema_class } + + def define_user(schema) + schema.define :user do + string :name + end + end + + describe "circular reference detection" do + it "detects direct circular references" do + define_user(schema_class) + schema_class.definitions[:user][:properties][:self_ref] = schema_class.reference(:user) + + expect(schema_class.valid?).to be false + expect { schema_class.validate! }.to raise_error( + RubyLLM::Schema::ValidationError, + /Circular reference detected involving 'user'/ + ) + end + + it "detects indirect circular references" do + define_user(schema_class) + + schema_class.define :profile do + string :bio + end + + schema_class.definitions[:user][:properties][:profile] = schema_class.reference(:profile) + schema_class.definitions[:profile][:properties][:owner] = schema_class.reference(:user) + + expect(schema_class.valid?).to be false + expect { schema_class.validate! }.to raise_error( + RubyLLM::Schema::ValidationError, + /Circular reference detected involving/ + ) + end + end + + describe "validation guards for JSON generation" do + it "prevents JSON generation for schemas with circular references" do + define_user(schema_class) + schema_class.definitions[:user][:properties][:self_ref] = schema_class.reference(:user) + + instance = schema_class.new + expect { instance.to_json_schema }.to raise_error(RubyLLM::Schema::ValidationError) + expect { instance.to_json }.to raise_error(RubyLLM::Schema::ValidationError) + end + end +end diff --git a/spec/ruby_llm/schema_class_inheritance_spec.rb b/spec/ruby_llm/schema_class_inheritance_spec.rb deleted file mode 100644 index 3fbdaea..0000000 --- a/spec/ruby_llm/schema_class_inheritance_spec.rb +++ /dev/null @@ -1,148 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe RubyLLM::Schema, "class inheritance approach" do - describe "configuration options" do - let(:schema_class) { Class.new(described_class) } - - describe "name" do - it "uses class name when provided (via constant)" do - stub_const("TestNamedSchema", Class.new(described_class)) - instance = TestNamedSchema.new - expect(instance.to_json_schema[:name]).to eq("TestNamedSchema") - end - - it "falls back to 'Schema' when not provided (anonymous class)" do - instance = schema_class.new - expect(instance.to_json_schema[:name]).to eq("Schema") - end - end - - describe "description" do - it "can be set at class level" do - schema_class.description("Class-level description") - expect(schema_class.description).to eq("Class-level description") - end - - it "applies to the schema properly" do - schema_class.description("Class-level description") - instance = schema_class.new - expect(instance.to_json_schema[:description]).to eq("Class-level description") - end - - it "defaults to nil when not provided" do - expect(schema_class.description).to be_nil - end - end - - describe "additional_properties" do - it "can be set to true" do - schema_class.additional_properties(true) - instance = schema_class.new - expect(instance.to_json_schema[:schema][:additionalProperties]).to eq(true) - end - - it "defaults to false when not provided" do - instance = schema_class.new - expect(instance.to_json_schema[:schema][:additionalProperties]).to eq(false) - end - end - - describe "strict" do - it "can be set to true (explicit)" do - schema_class.strict(true) - instance = schema_class.new - expect(instance.to_json_schema[:schema][:strict]).to eq(true) - end - - it "can be set to false (explicit)" do - schema_class.strict(false) - instance = schema_class.new - expect(instance.to_json_schema[:schema][:strict]).to eq(false) - end - - it "defaults to true when not provided" do - instance = schema_class.new - expect(instance.to_json_schema[:schema][:strict]).to eq(true) - end - end - end - - describe "comprehensive functionality" do - it "supports all schema features in class definition" do - comprehensive_class = Class.new(described_class) do - description "Comprehensive test schema" - additional_properties true - strict true - - string :name, description: "Name field" - integer :count - boolean :active, required: false - - object :config do - string :setting - end - - array :tags, of: :string - - any_of :status do - string - null - end - end - - instance = comprehensive_class.new("TestSchema") - json_output = instance.to_json_schema - - expect(json_output[:name]).to eq("TestSchema") - expect(json_output[:schema][:additionalProperties]).to eq(true) - expect(json_output[:schema][:strict]).to eq(true) - expect(json_output[:schema][:properties].keys).to contain_exactly( - :name, :count, :active, :config, :tags, :status - ) - expect(json_output[:schema][:required]).to contain_exactly(:name, :count, :config, :tags, :status) - end - end - - describe "to_json_schema output" do - it "produces correctly structured JSON schema" do - configured_class = Class.new(described_class) do - description "Test description" - additional_properties false - string :title - end - - instance = configured_class.new("ConfiguredSchema") - json_output = instance.to_json_schema - - expect(json_output).to include( - name: "ConfiguredSchema", - description: "Test description", - schema: hash_including( - type: "object", - properties: {title: {type: "string"}}, - required: [:title], - additionalProperties: false, - strict: true - ) - ) - end - - it "produces correctly structured JSON schema with instance description" do - configured_class = Class.new(described_class) do - description "Test description" - additional_properties false - string :title - end - - instance = configured_class.new("ConfiguredSchema", description: "Instance description") - json_output = instance.to_json_schema - - expect(json_output).to include( - name: "ConfiguredSchema", - description: "Instance description" - ) - end - end -end diff --git a/spec/ruby_llm/schema_factory_spec.rb b/spec/ruby_llm/schema_factory_spec.rb deleted file mode 100644 index 04cda40..0000000 --- a/spec/ruby_llm/schema_factory_spec.rb +++ /dev/null @@ -1,165 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe RubyLLM::Schema, "factory method (.create) approach" do - describe "configuration options" do - describe "name" do - it "uses class name when provided (via constant assignment)" do - stub_const("NamedFactorySchema", described_class.create do - string :title - end) - - instance = NamedFactorySchema.new - expect(instance.to_json_schema[:name]).to eq("NamedFactorySchema") - end - - it "falls back to 'Schema' when not provided (anonymous class)" do - schema_class = described_class.create do - string :title - end - - instance = schema_class.new - expect(instance.to_json_schema[:name]).to eq("Schema") - end - end - - describe "description" do - it "can be set within factory block" do - schema_class = described_class.create do - description "Factory description" - string :title - end - - expect(schema_class.description).to eq("Factory description") - end - - it "defaults to nil when not provided" do - schema_class = described_class.create do - string :title - end - - expect(schema_class.description).to be_nil - end - end - - describe "additional_properties" do - it "can be set to true within factory block" do - schema_class = described_class.create do - additional_properties true - string :title - end - - instance = schema_class.new - expect(instance.to_json_schema[:schema][:additionalProperties]).to eq(true) - end - - it "defaults to false when not provided" do - schema_class = described_class.create do - string :title - end - - instance = schema_class.new - expect(instance.to_json_schema[:schema][:additionalProperties]).to eq(false) - end - end - - describe "strict" do - it "can be set to true within factory block" do - schema_class = described_class.create do - strict true - string :title - end - - instance = schema_class.new - expect(instance.to_json_schema[:schema][:strict]).to eq(true) - end - - it "defaults to true when not provided" do - schema_class = described_class.create do - string :title - end - - instance = schema_class.new - expect(instance.to_json_schema[:schema][:strict]).to eq(true) - end - end - end - - describe "comprehensive functionality" do - it "supports all schema features in factory block" do - comprehensive_class = described_class.create do - description "Comprehensive factory schema" - additional_properties true - strict true - - string :name, description: "Name field" - integer :count - boolean :active, required: false - - object :config do - string :setting - end - - array :tags, of: :string - - any_of :status do - string - null - end - end - - instance = comprehensive_class.new("FactorySchema") - json_output = instance.to_json_schema - - expect(json_output[:name]).to eq("FactorySchema") - expect(json_output[:schema][:additionalProperties]).to eq(true) - expect(json_output[:schema][:strict]).to eq(true) - expect(json_output[:schema][:properties].keys).to contain_exactly( - :name, :count, :active, :config, :tags, :status - ) - expect(json_output[:schema][:required]).to contain_exactly(:name, :count, :config, :tags, :status) - end - end - - describe "to_json_schema output" do - it "produces correctly structured JSON schema" do - configured_class = described_class.create do - description "Factory test description" - additional_properties false - string :title - end - - instance = configured_class.new("FactoryConfiguredSchema") - json_output = instance.to_json_schema - - expect(json_output).to include( - name: "FactoryConfiguredSchema", - description: "Factory test description", - schema: hash_including( - type: "object", - properties: {title: {type: "string"}}, - required: [:title], - additionalProperties: false, - strict: true - ) - ) - end - - it "produces correctly structured JSON schema with instance description" do - configured_class = described_class.create do - description "Factory test description" - additional_properties false - string :title - end - - instance = configured_class.new("FactoryConfiguredSchema", description: "Instance description") - json_output = instance.to_json_schema - - expect(json_output).to include( - name: "FactoryConfiguredSchema", - description: "Instance description" - ) - end - end -end diff --git a/spec/ruby_llm/schema_helpers_spec.rb b/spec/ruby_llm/schema_helpers_spec.rb deleted file mode 100644 index 5f97eba..0000000 --- a/spec/ruby_llm/schema_helpers_spec.rb +++ /dev/null @@ -1,142 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe RubyLLM::Schema, "helpers module approach" do - include RubyLLM::Helpers - - describe "configuration options" do - describe "name" do - it "uses provided name parameter" do - schema_instance = schema "ProvidedName" do - string :title - end - - expect(schema_instance.to_json_schema[:name]).to eq("ProvidedName") - end - - it "falls back to 'Schema' when not provided" do - schema_instance = schema do - string :title - end - - expect(schema_instance.to_json_schema[:name]).to eq("Schema") - end - end - - describe "description" do - it "uses provided description parameter (takes precedence)" do - schema_instance = schema "TestSchema", description: "Parameter description" do - description "Block description" # This gets overridden - string :title - end - - expect(schema_instance.to_json_schema[:description]).to eq("Parameter description") - end - - it "defaults to nil when not provided" do - schema_instance = schema "TestSchema" do - string :title - end - - expect(schema_instance.to_json_schema[:description]).to be_nil - end - end - - describe "additional_properties" do - it "can be set to true within helper block" do - schema_instance = schema do - additional_properties true - string :title - end - - expect(schema_instance.to_json_schema[:schema][:additionalProperties]).to eq(true) - end - - it "defaults to false when not provided" do - schema_instance = schema do - string :title - end - - expect(schema_instance.to_json_schema[:schema][:additionalProperties]).to eq(false) - end - end - - describe "strict" do - it "can be set to true within helper block" do - schema_instance = schema do - strict true - string :title - end - - expect(schema_instance.to_json_schema[:schema][:strict]).to eq(true) - end - - it "defaults to true when not provided" do - schema_instance = schema do - string :title - end - - expect(schema_instance.to_json_schema[:schema][:strict]).to eq(true) - end - end - end - - describe "comprehensive functionality" do - it "supports all schema features in helper block" do - comprehensive_instance = schema "HelperSchema", description: "Comprehensive helper schema" do - additional_properties true - strict true - - string :name, description: "Name field" - integer :count - boolean :active, required: false - - object :config do - string :setting - end - - array :tags, of: :string - - any_of :status do - string - null - end - end - - json_output = comprehensive_instance.to_json_schema - - expect(json_output[:name]).to eq("HelperSchema") - expect(json_output[:description]).to eq("Comprehensive helper schema") - expect(json_output[:schema][:additionalProperties]).to eq(true) - expect(json_output[:schema][:strict]).to eq(true) - expect(json_output[:schema][:properties].keys).to contain_exactly( - :name, :count, :active, :config, :tags, :status - ) - expect(json_output[:schema][:required]).to contain_exactly(:name, :count, :config, :tags, :status) - end - end - - describe "to_json_schema output" do - it "produces correctly structured JSON schema" do - configured_instance = schema "HelperConfiguredSchema", description: "Helper test description" do - additional_properties false - string :title - end - - json_output = configured_instance.to_json_schema - - expect(json_output).to include( - name: "HelperConfiguredSchema", - description: "Helper test description", - schema: hash_including( - type: "object", - properties: {title: {type: "string"}}, - required: [:title], - additionalProperties: false, - strict: true - ) - ) - end - end -end diff --git a/spec/ruby_llm/schema_spec.rb b/spec/ruby_llm/schema_spec.rb deleted file mode 100644 index 178864b..0000000 --- a/spec/ruby_llm/schema_spec.rb +++ /dev/null @@ -1,937 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe RubyLLM::Schema do - # =========================================== - # PRIMITIVE TYPES TESTS - # =========================================== - describe "primitive types" do - let(:schema_class) { Class.new(described_class) } - - it "supports string type with enum and description" do - schema_class.string :status, enum: %w[active inactive], description: "Status field" - - properties = schema_class.properties - expect(properties[:status]).to eq({ - type: "string", - enum: %w[active inactive], - description: "Status field" - }) - end - - it "supports string type with additional properties" do - schema_class.string :email, format: "email", min_length: 5, max_length: 100, pattern: "\\S+@\\S+", description: "Email field" - - properties = schema_class.properties - expect(properties[:email]).to eq({ - type: "string", - format: "email", - minLength: 5, - maxLength: 100, - pattern: "\\S+@\\S+", - description: "Email field" - }) - end - - it "supports number type with constraints" do - schema_class.number :price, minimum: 0, maximum: 1000, multiple_of: 0.01, description: "Price field" - - properties = schema_class.properties - expect(properties[:price]).to eq({ - type: "number", - minimum: 0, - maximum: 1000, - multipleOf: 0.01, - description: "Price field" - }) - end - - it "supports number type with description" do - schema_class.number :price, description: "Price field" - - properties = schema_class.properties - expect(properties[:price]).to eq({type: "number", description: "Price field"}) - end - - it "supports integer type with description" do - schema_class.integer :count, description: "Count value" - - properties = schema_class.properties - expect(properties[:count]).to eq({type: "integer", description: "Count value"}) - end - - it "supports boolean type with description" do - schema_class.boolean :enabled, description: "Enabled field" - - properties = schema_class.properties - expect(properties[:enabled]).to eq({type: "boolean", description: "Enabled field"}) - end - - it "supports null type with description" do - schema_class.null :placeholder, description: "Null field" - - properties = schema_class.properties - expect(properties[:placeholder]).to eq({type: "null", description: "Null field"}) - end - - it "handles required vs optional properties" do - schema_class.string :required_field - schema_class.string :optional_field, required: false - - expect(schema_class.required_properties).to include(:required_field) - expect(schema_class.required_properties).not_to include(:optional_field) - end - end - - # =========================================== - # ARRAY TYPES TESTS - # =========================================== - describe "array types" do - let(:schema_class) { Class.new(described_class) } - - it "supports arrays with primitive types and descriptions" do - schema_class.array :strings, of: :string, description: "String array" - schema_class.array :numbers, of: :number - schema_class.array :integers, of: :integer - schema_class.array :booleans, of: :boolean - - properties = schema_class.properties - - expect(properties[:strings]).to eq({type: "array", items: {type: "string"}, description: "String array"}) - expect(properties[:numbers]).to eq({type: "array", items: {type: "number"}}) - expect(properties[:integers]).to eq({type: "array", items: {type: "integer"}}) - expect(properties[:booleans]).to eq({type: "array", items: {type: "boolean"}}) - end - - it "supports arrays with constraints" do - schema_class.array :strings, of: :string, min_items: 1, max_items: 10, description: "String array" - - properties = schema_class.properties - expect(properties[:strings]).to eq({type: "array", items: {type: "string"}, minItems: 1, maxItems: 10, description: "String array"}) - end - - it "supports arrays with object definitions" do - schema_class.array :items do - object do - string :name - integer :value - end - end - - properties = schema_class.properties - expect(properties[:items][:items]).to include( - type: "object", - properties: { - name: {type: "string"}, - value: {type: "integer"} - }, - required: %i[name value], - additionalProperties: false - ) - end - - it "supports arrays with references to defined schemas" do - schema_class.define :product do - string :name - number :price - end - - schema_class.array :products, of: :product - - properties = schema_class.properties - expect(properties[:products]).to eq({ - type: "array", - items: {"$ref" => "#/$defs/product"} - }) - end - - it "supports arrays of anyOf types" do - schema_class.array :items do - any_of :value do - string :alphanumeric - number :numeric - end - end - end - - it "supports arrays of oneOf types" do - schema_class.array :items do - one_of :value do - string :alphanumeric - number :numeric - end - end - end - - it "supports basic oneOf with primitive types" do - schema_class.one_of :status do - string enum: %w[active inactive] - integer - boolean - end - - properties = schema_class.properties - one_of_schemas = properties[:status][:oneOf] - - expect(one_of_schemas).to include( - {type: "string", enum: %w[active inactive]}, - {type: "integer"}, - {type: "boolean"} - ) - end - end - - # =========================================== - # OBJECT AND NESTING TESTS - # =========================================== - describe "object types and nesting" do - let(:schema_class) { Class.new(described_class) } - - it "supports nested objects with mixed property types" do - schema_class.object :address do - string :street - string :city - integer :zip_code, required: false - end - - properties = schema_class.properties - expect(properties[:address]).to include( - type: "object", - properties: { - street: {type: "string"}, - city: {type: "string"}, - zip_code: {type: "integer"} - }, - required: %i[street city], - additionalProperties: false - ) - end - - it "supports deeply nested objects" do - schema_class.object :level1 do - object :level2 do - object :level3 do - string :deep_value - end - end - end - - instance = schema_class.new - properties = instance.to_json_schema[:schema][:properties] - - level3 = properties[:level1][:properties][:level2][:properties][:level3] - expect(level3[:properties][:deep_value]).to eq({type: "string"}) - end - - it "supports any_of with mixed types including objects" do - schema_class.any_of :flexible_field do - string enum: %w[option1 option2] - integer - object do - string :nested_field - end - null - end - - properties = schema_class.properties - any_of_schemas = properties[:flexible_field][:anyOf] - - expect(any_of_schemas).to include( - {type: "string", enum: %w[option1 option2]}, - {type: "integer"}, - {type: "null"} - ) - - object_schema = any_of_schemas.find { |s| s[:type] == "object" } - expect(object_schema[:properties][:nested_field]).to eq({type: "string"}) - end - - it "supports one_of with mixed types including objects" do - schema_class.one_of :exclusive_field do - string enum: %w[option1 option2] - integer - object do - string :nested_field - end - null - end - - properties = schema_class.properties - one_of_schemas = properties[:exclusive_field][:oneOf] - - expect(one_of_schemas).to include( - {type: "string", enum: %w[option1 option2]}, - {type: "integer"}, - {type: "null"} - ) - - object_schema = one_of_schemas.find { |s| s[:type] == "object" } - expect(object_schema[:properties][:nested_field]).to eq({type: "string"}) - end - - it "supports reference to a defined schema by block" do - schema_class.define :address do - string :street - string :city - end - - schema_class.object :user do - string :name - object :address do - reference :address - end - end - - instance = schema_class.new - json_output = instance.to_json_schema - - expect(json_output[:schema][:properties][:user][:properties][:address]).to eq({"$ref" => "#/$defs/address"}) - expect(json_output[:schema]["$defs"][:address]).to eq({ - type: "object", - properties: { - street: {type: "string"}, - city: {type: "string"} - }, - required: %i[street city], - additionalProperties: false - }) - end - - it "supports reference to a defined schema by `of` option" do - schema_class.define :address do - string :street - string :city - end - - schema_class.object :user do - string :name - object :address, of: :address - end - - instance = schema_class.new - json_output = instance.to_json_schema - - expect(json_output[:schema][:properties][:user][:properties][:address]).to eq({"$ref" => "#/$defs/address"}) - expect(json_output[:schema]["$defs"][:address]).to eq({ - type: "object", - properties: { - street: {type: "string"}, - city: {type: "string"} - }, - required: %i[street city], - additionalProperties: false - }) - end - - it "shows deprecation warning if using `reference` instead of `of`" do - schema_class.define :address do - string :street - string :city - end - - expect { - schema_class.object :user do - string :name - object :address, reference: :address - end - }.to output(/DEPRECATION.*reference/).to_stderr - end - end - - # =========================================== - # DEFINITIONS AND REFERENCES - # =========================================== - describe "definitions and references" do - let(:schema_class) { Class.new(described_class) } - - it "supports defining and referencing reusable schemas" do - schema_class.define :address do - string :street - string :city - end - - schema_class.object :user do - string :name - array :addresses, of: :address - end - - ref_hash = schema_class.reference(:address) - expect(ref_hash).to eq({"$ref" => "#/$defs/address"}) - - instance = schema_class.new - json_output = instance.to_json_schema - - # Check definition - expect(json_output[:schema]["$defs"][:address]).to include( - type: "object", - properties: { - street: {type: "string"}, - city: {type: "string"} - }, - required: %i[street city] - ) - - # Check reference usage - user_props = json_output[:schema][:properties][:user][:properties] - expect(user_props[:addresses][:items]).to eq({"$ref" => "#/$defs/address"}) - end - - it "supports reference to the root schema" do - schema_class.string :element_type, enum: ["input", "button"] - schema_class.string :label - schema_class.object :sub_schema, of: :root - - instance = schema_class.new - json_output = instance.to_json_schema - - expect(json_output[:schema][:properties][:sub_schema]).to eq({"$ref" => "#"}) - end - end - - # =========================================== - # INSTANCE METHODS TESTS - # =========================================== - describe "instance methods" do - let(:schema_class) { Class.new(described_class) } - - it "handles naming correctly" do - # Named class - stub_const("TestSchemaClass", Class.new(described_class)) - named_instance = TestSchemaClass.new - expect(named_instance.to_json_schema[:name]).to eq("TestSchemaClass") - - # Anonymous class - anonymous_instance = schema_class.new - expect(anonymous_instance.to_json_schema[:name]).to eq("Schema") - - # Provided name - custom_instance = schema_class.new("CustomName") - expect(custom_instance.to_json_schema[:name]).to eq("CustomName") - - # Provided description - described_instance = schema_class.new("TestName", description: "Custom description") - expect(described_instance.to_json_schema[:description]).to eq("Custom description") - end - - it "supports method delegation for schema methods" do - instance = schema_class.new - - expect(instance).to respond_to(:string, :number, :integer, :boolean, :array, :object, :any_of, :one_of, :null) - expect(instance).not_to respond_to(:unknown_method) - end - - it "produces correctly structured JSON schema and JSON output" do - schema_class.string :name - schema_class.integer :age, required: false - - instance = schema_class.new("TestSchema") - json_output = instance.to_json_schema - - expect(json_output).to include( - name: "TestSchema", - description: nil, - schema: hash_including( - type: "object", - properties: { - name: {type: "string"}, - age: {type: "integer"} - }, - required: [:name], - additionalProperties: false, - strict: true - ) - ) - - # Test JSON string output - json_string = instance.to_json - expect(json_string).to be_a(String) - parsed_json = JSON.parse(json_string) - expect(parsed_json["name"]).to eq("TestSchema") - end - end - - # =========================================== - # ERROR HANDLING TESTS - # =========================================== - describe "error handling" do - let(:schema_class) { Class.new(described_class) } - - it "raises appropriate errors for invalid configurations" do - # Invalid array types - expect { - schema_class.array :items, of: 123 - }.to raise_error(RubyLLM::Schema::InvalidArrayTypeError, /Invalid array type: 123./) - - expect { - schema_class.array :items, of: "invalid" - }.to raise_error(RubyLLM::Schema::InvalidArrayTypeError, /Invalid array type: "invalid"./) - end - - it "raises clear errors for invalid object types" do - # Invalid object types - expect { - schema_class.object :item, of: 123 - }.to raise_error(RubyLLM::Schema::InvalidObjectTypeError, /Invalid object type: 123.*Must be a symbol reference, a Schema class, or a Schema instance/) - - expect { - schema_class.object :item, of: "invalid" - }.to raise_error(RubyLLM::Schema::InvalidObjectTypeError, /Invalid object type: "invalid".*Must be a symbol reference, a Schema class, or a Schema instance/) - - # Non-Schema class - expect { - schema_class.object :item, of: String - }.to raise_error(RubyLLM::Schema::InvalidObjectTypeError, /Invalid object type: String.*Class must inherit from RubyLLM::Schema/) - end - - it "accepts anonymous schema classes with inline schemas" do - anonymous_schema = Class.new(described_class) do - string :test_field - end - - expect { - schema_class.object :item, of: anonymous_schema - }.not_to raise_error - - properties = schema_class.properties - expect(properties[:item]).to eq({ - type: "object", - properties: { - test_field: {type: "string"} - }, - required: [:test_field], - additionalProperties: false - }) - end - - it "accepts symbols as references (even if undefined)" do - expect { - schema_class.array :items, of: :undefined_reference - }.not_to raise_error - - properties = schema_class.properties - expect(properties[:items][:items]).to eq({"$ref" => "#/$defs/undefined_reference"}) - end - end - - # =========================================== - # VALIDATION TESTS - # =========================================== - describe "validation" do - let(:schema_class) { Class.new(described_class) } - - describe "circular reference detection" do - it "detects direct circular references" do - schema_class.define :user do - string :name - end - - # Create a direct circular reference - schema_class.definitions[:user][:properties][:self_ref] = schema_class.reference(:user) - - expect(schema_class.valid?).to be false - expect { schema_class.validate! }.to raise_error( - RubyLLM::Schema::ValidationError, - /Circular reference detected involving 'user'/ - ) - end - - it "detects indirect circular references" do - schema_class.define :user do - string :name - end - - schema_class.define :profile do - string :bio - end - - # Create circular chain: user -> profile -> user - schema_class.definitions[:user][:properties][:profile] = schema_class.reference(:profile) - schema_class.definitions[:profile][:properties][:owner] = schema_class.reference(:user) - - expect(schema_class.valid?).to be false - expect { schema_class.validate! }.to raise_error( - RubyLLM::Schema::ValidationError, - /Circular reference detected involving/ - ) - end - end - - describe "validation guards for JSON generation" do - it "prevents JSON generation for schemas with circular references" do - schema_class.define :user do - string :name - end - - # Add circular reference - schema_class.definitions[:user][:properties][:self_ref] = schema_class.reference(:user) - - instance = schema_class.new - expect { instance.to_json_schema }.to raise_error(RubyLLM::Schema::ValidationError) - expect { instance.to_json }.to raise_error(RubyLLM::Schema::ValidationError) - end - end - end - - # =========================================== - # COMPREHENSIVE SCENARIOS - # =========================================== - describe "comprehensive scenarios" do - it "handles edge cases" do - # Empty schema - empty_schema = Class.new(described_class) - empty_instance = empty_schema.new("EmptySchema") - empty_output = empty_instance.to_json_schema - - expect(empty_output[:schema][:properties]).to eq({}) - expect(empty_output[:schema][:required]).to eq([]) - - # Schema with only optional properties - optional_schema = Class.new(described_class) do - string :optional1, required: false - integer :optional2, required: false - end - - optional_instance = optional_schema.new - optional_output = optional_instance.to_json_schema - - expect(optional_output[:schema][:required]).to eq([]) - expect(optional_output[:schema][:properties].keys).to contain_exactly(:optional1, :optional2) - end - - it "handles complex nested structures with all features" do - complex_schema = Class.new(described_class) do - string :id, description: "Unique identifier" - - object :metadata do - string :created_by - integer :version - boolean :published, required: false - end - - array :tags, of: :string, description: "Resource tags" - - array :items do - object do - string :name - any_of :value do - string - number - boolean - null - end - end - end - - any_of :status do - string enum: %w[draft published] - null - end - - define :author do - string :name - string :email - end - - array :authors, of: :author - end - - instance = complex_schema.new("ComplexSchema") - json_output = instance.to_json_schema - - # Verify comprehensive structure - expect(json_output[:schema][:properties].keys).to contain_exactly( - :id, :metadata, :tags, :items, :status, :authors - ) - expect(json_output[:schema]["$defs"][:author]).to be_a(Hash) - expect(json_output[:schema][:required]).to include(:id, :metadata, :tags, :items, :status, :authors) - - # Verify descriptions are preserved - expect(json_output[:schema][:properties][:id][:description]).to eq("Unique identifier") - expect(json_output[:schema][:properties][:tags][:description]).to eq("Resource tags") - end - end - - # =========================================== - # SCHEMA INSERTION TESTS - # =========================================== - describe "schema insertion functionality" do - let(:person_schema) do - Class.new(described_class) do - string :name, description: "Person's name" - integer :age, description: "Person's age" - end - end - - let(:address_schema) do - Class.new(described_class) do - string :street, description: "Street address" - string :city, description: "City name" - string :zipcode, description: "Postal code" - end - end - - before do - # Give schemas constant names for proper reference generation - stub_const("PersonSchema", person_schema) - stub_const("AddressSchema", address_schema) - end - - describe "array with schema class" do - it "supports arrays of schema classes" do - schema_class = Class.new(described_class) - schema_class.array :employees, of: PersonSchema - - properties = schema_class.properties - expect(properties[:employees]).to eq({ - type: "array", - items: { - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - } - }) - - # No definitions should be created with inline schemas - definitions = schema_class.definitions - expect(definitions).to be_empty - end - - it "supports arrays with description" do - schema_class = Class.new(described_class) - schema_class.array :team_members, of: PersonSchema, description: "List of team members" - - properties = schema_class.properties - expect(properties[:team_members]).to eq({ - type: "array", - description: "List of team members", - items: { - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - } - }) - end - end - - describe "object with of parameter" do - it "supports object with of parameter" do - schema_class = Class.new(described_class) - schema_class.object :founder, of: PersonSchema - - properties = schema_class.properties - expect(properties[:founder]).to eq({ - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - }) - - # No definitions should be created with inline schemas - definitions = schema_class.definitions - expect(definitions).to be_empty - end - - it "supports object with of parameter and description" do - schema_class = Class.new(described_class) - schema_class.object :primary_contact, of: PersonSchema, description: "Main contact person" - - properties = schema_class.properties - expect(properties[:primary_contact]).to eq({ - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false, - description: "Main contact person" - }) - end - - it "supports object with symbol reference" do - schema_class = Class.new(described_class) - schema_class.define :address do - string :street - string :city - end - - schema_class.object :headquarters, of: :address - - properties = schema_class.properties - expect(properties[:headquarters]).to eq({"$ref" => "#/$defs/address"}) - end - end - - describe "object with Schema.new in block" do - it "supports Schema.new inside object block" do - schema_class = Class.new(described_class) - schema_class.object :founder do - PersonSchema.new - end - - properties = schema_class.properties - expect(properties[:founder]).to eq({ - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - }) - - # No definitions should be created with inline schemas - definitions = schema_class.definitions - expect(definitions).to be_empty - end - - it "supports Schema.new with description" do - schema_class = Class.new(described_class) - schema_class.object :ceo, description: "Chief Executive Officer" do - PersonSchema.new - end - - properties = schema_class.properties - expect(properties[:ceo]).to eq({ - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false, - description: "Chief Executive Officer" - }) - end - end - - describe "complex schema insertion scenarios" do - it "handles multiple schema insertions" do - company_schema = Class.new(described_class) - company_schema.string :name - company_schema.array :employees, of: PersonSchema - company_schema.object :headquarters, of: AddressSchema - company_schema.object :founder do - PersonSchema.new - end - - properties = company_schema.properties - expect(properties[:employees]).to eq({ - type: "array", - items: { - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - } - }) - expect(properties[:headquarters]).to eq({ - type: "object", - properties: { - street: {type: "string", description: "Street address"}, - city: {type: "string", description: "City name"}, - zipcode: {type: "string", description: "Postal code"} - }, - required: [:street, :city, :zipcode], - additionalProperties: false - }) - expect(properties[:founder]).to eq({ - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - }) - - # No definitions should be created with inline schemas - definitions = company_schema.definitions - expect(definitions).to be_empty - end - - it "creates separate inline schemas for each usage" do - company_schema = Class.new(described_class) - company_schema.array :employees, of: PersonSchema - company_schema.object :ceo, of: PersonSchema - company_schema.object :founder do - PersonSchema.new - end - - properties = company_schema.properties - - # Each usage gets its own inline schema copy - person_schema = { - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - } - - expect(properties[:employees][:items]).to eq(person_schema) - expect(properties[:ceo]).to eq(person_schema) - expect(properties[:founder]).to eq(person_schema) - - # No shared definitions - definitions = company_schema.definitions - expect(definitions).to be_empty - end - - it "generates proper JSON schema output with inline schemas" do - company_schema = Class.new(described_class) - company_schema.string :name - company_schema.array :employees, of: PersonSchema - company_schema.object :founder, of: PersonSchema - - stub_const("CompanySchema", company_schema) - instance = company_schema.new("CompanySchema") - - json_output = instance.to_json_schema - expect(json_output[:schema][:type]).to eq("object") - - # Check inline schema in array items - expect(json_output[:schema][:properties][:employees][:items]).to eq({ - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - }) - - # Check inline schema in object - expect(json_output[:schema][:properties][:founder]).to eq({ - type: "object", - properties: { - name: {type: "string", description: "Person's name"}, - age: {type: "integer", description: "Person's age"} - }, - required: [:name, :age], - additionalProperties: false - }) - - # No $defs section should exist - expect(json_output[:schema]).not_to have_key("$defs") - end - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b71a8fa..e5a54ec 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,8 @@ require "bundler/setup" require "ruby_llm/schema" +Dir[File.join(__dir__, "support/**/*.rb")].sort.each { |file| require file } + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" diff --git a/spec/support/schema_builders.rb b/spec/support/schema_builders.rb new file mode 100644 index 0000000..cf0c7a0 --- /dev/null +++ b/spec/support/schema_builders.rb @@ -0,0 +1,21 @@ +module SchemaBuilders + extend self + + def build_schema_class(&block) + Class.new(RubyLLM::Schema) do + class_eval(&block) if block + end + end + + def build_factory_schema(&block) + RubyLLM::Schema.create do + instance_eval(&block) if block + end + end + + def build_helper_schema(name = nil, description: nil, &block) + helper = Object.new + helper.extend(RubyLLM::Helpers) + helper.schema(name, description: description, &block) + end +end