From 7669c823ddd0d2513477622b45ad1d24c8573c37 Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Fri, 26 Sep 2025 18:42:29 -0700 Subject: [PATCH 1/2] Fix JSON schema generation for Dry::Struct wrapped in constructors - Extend struct? method to recognize struct constructors - Add extract_struct_class method to handle both direct structs and constructors - Ensure struct properties are included in JSON schema for constructor-wrapped structs - Add comprehensive tests for struct constructor JSON schema generation Fixes #495 --- CHANGELOG.md | 3 + lib/dry/schema/extensions/struct.rb | 16 +++- .../json_schema/struct_constructor_spec.rb | 81 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 spec/integration/extensions/json_schema/struct_constructor_spec.rb 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 From 19b3226a8178a99f96724d0aa0365b7aca8ca8b4 Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Sat, 11 Oct 2025 13:44:53 -0700 Subject: [PATCH 2/2] Fix Rubocop issues: hash literal braces spacing and word array style --- dry-schema.gemspec | 1 - .../json_schema/struct_constructor_spec.rb | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/dry-schema.gemspec b/dry-schema.gemspec index b2c53f2c..2d7aae36 100644 --- a/dry-schema.gemspec +++ b/dry-schema.gemspec @@ -47,4 +47,3 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec" spec.add_development_dependency "yard" end - diff --git a/spec/integration/extensions/json_schema/struct_constructor_spec.rb b/spec/integration/extensions/json_schema/struct_constructor_spec.rb index acafeaee..77ca5db1 100644 --- a/spec/integration/extensions/json_schema/struct_constructor_spec.rb +++ b/spec/integration/extensions/json_schema/struct_constructor_spec.rb @@ -29,10 +29,10 @@ expect(json_schema[:properties][:address]).to include( type: "object", properties: { - street: { anyOf: [{ type: "null" }, { type: "string" }] }, - city: { type: "string" } + street: {anyOf: [{type: "null"}, {type: "string"}]}, + city: {type: "string"} }, - required: ["street", "city"] + required: %w[street city] ) end end @@ -51,10 +51,10 @@ expect(json_schema[:properties][:address]).to include( type: "object", properties: { - street: { anyOf: [{ type: "null" }, { type: "string" }] }, - city: { type: "string" } + street: {anyOf: [{type: "null"}, {type: "string"}]}, + city: {type: "string"} }, - required: ["street", "city"] + required: %w[street city] ) end end