diff --git a/CHANGELOG.md b/CHANGELOG.md index 751830dc..db3653a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve ## [Unreleased] +### Fixed + +- JSON schema generation now properly handles Dry::Struct wrapped in constructors (fixes #495) (@baweaver) ## [1.14.1] - 2025-03-03 diff --git a/lib/dry/schema/extensions/struct.rb b/lib/dry/schema/extensions/struct.rb index 200e792b..24cb0045 100644 --- a/lib/dry/schema/extensions/struct.rb +++ b/lib/dry/schema/extensions/struct.rb @@ -27,7 +27,8 @@ def call(*args) "a struct class (#{name.inspect} => #{args[0]})" end - schema = struct_compiler.(args[0]) + struct_class = extract_struct_class(args[0]) + schema = struct_compiler.(struct_class) super(schema, *args.drop(1)) type(schema_dsl.types[name].constructor(schema)) @@ -39,7 +40,18 @@ def call(*args) private def struct?(type) - type.is_a?(::Class) && type <= ::Dry::Struct + (type.is_a?(::Class) && type <= ::Dry::Struct) || + (type.is_a?(::Dry::Types::Constructor) && type.primitive <= ::Dry::Struct) + end + + def extract_struct_class(type) + if type.is_a?(::Class) && type <= ::Dry::Struct + type + elsif type.is_a?(::Dry::Types::Constructor) && type.primitive <= ::Dry::Struct + type.primitive + else + type + end end }) end diff --git a/spec/integration/extensions/json_schema/struct_constructor_spec.rb b/spec/integration/extensions/json_schema/struct_constructor_spec.rb new file mode 100644 index 00000000..acafeaee --- /dev/null +++ b/spec/integration/extensions/json_schema/struct_constructor_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "dry-struct" +require "dry/schema/extensions/struct" + +RSpec.describe "JSON Schema with struct constructors" do + before do + Dry::Schema.load_extensions(:json_schema) + end + + let(:address_struct) do + Class.new(Dry::Struct) do + attribute :street, Types::Strict::String.optional.default(nil) + attribute :city, Types::Strict::String + end + end + + context "with direct struct" do + let(:schema) do + struct = address_struct + Dry::Schema.Params do + required(:address).value(struct) + end + end + + it "generates JSON schema with struct properties" do + json_schema = schema.json_schema + + expect(json_schema[:properties][:address]).to include( + type: "object", + properties: { + street: { anyOf: [{ type: "null" }, { type: "string" }] }, + city: { type: "string" } + }, + required: ["street", "city"] + ) + end + end + + context "with struct constructor" do + let(:schema) do + struct = address_struct + Dry::Schema.Params do + required(:address).value(struct.constructor(&:itself)) + end + end + + it "generates JSON schema with struct properties" do + json_schema = schema.json_schema + + expect(json_schema[:properties][:address]).to include( + type: "object", + properties: { + street: { anyOf: [{ type: "null" }, { type: "string" }] }, + city: { type: "string" } + }, + required: ["street", "city"] + ) + end + end + + context "comparing direct struct vs constructor" do + let(:direct_schema) do + struct = address_struct + Dry::Schema.Params do + required(:address).value(struct) + end + end + + let(:constructor_schema) do + struct = address_struct + Dry::Schema.Params do + required(:address).value(struct.constructor(&:itself)) + end + end + + it "generates identical JSON schemas" do + expect(direct_schema.json_schema).to eq(constructor_schema.json_schema) + end + end +end