diff --git a/CHANGELOG.md b/CHANGELOG.md index 751830dc..2551ad87 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 correctly uses `minItems`/`maxItems` for array size predicates instead of `minLength`/`maxLength` (fixes #481) (@baweaver) ## [1.14.1] - 2025-03-03 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/lib/dry/schema/extensions/json_schema/schema_compiler.rb b/lib/dry/schema/extensions/json_schema/schema_compiler.rb index f4383693..9400912b 100644 --- a/lib/dry/schema/extensions/json_schema/schema_compiler.rb +++ b/lib/dry/schema/extensions/json_schema/schema_compiler.rb @@ -163,18 +163,64 @@ def visit_predicate(node, opts = EMPTY_HASH) name, rest = node if name.equal?(:key?) - prop_name = rest[0][1] - keys[prop_name] = {} + handle_key_predicate(rest) else - target = keys[opts[:key]] - type_opts = fetch_type_opts_for_predicate(name, rest, target) - - if target[:type]&.include?("array") - target[:items] ||= {} - merge_opts!(target[:items], type_opts) - else - merge_opts!(target, type_opts) - end + handle_value_predicate(name, rest, opts) + end + end + + # @api private + def handle_key_predicate(rest) + prop_name = rest[0][1] + keys[prop_name] = {} + end + + # @api private + def handle_value_predicate(name, rest, opts) + target = keys[opts[:key]] + type_opts = fetch_type_opts_for_predicate(name, rest, target) + + if array_with_size_predicate?(target, name, opts) + apply_array_size_constraint(target, name, rest) + elsif target[:type]&.include?("array") + apply_array_item_constraint(target, type_opts) + else + merge_opts!(target, type_opts) + end + end + + # @api private + def array_with_size_predicate?(target, name, opts) + target[:type]&.include?("array") && array_size_predicate?(name) && !opts[:member] + end + + # @api private + def apply_array_size_constraint(target, name, rest) + array_type_opts = convert_array_size_predicate(name, rest) + merge_opts!(target, array_type_opts) + end + + # @api private + def apply_array_item_constraint(target, type_opts) + target[:items] ||= {} + merge_opts!(target[:items], type_opts) + end + + # @api private + def array_size_predicate?(name) + name == :min_size? || name == :max_size? + end + + # @api private + def convert_array_size_predicate(name, rest) + value = rest[0][1].to_i + case name + when :min_size? + {minItems: value} + when :max_size? + {maxItems: value} + else + {} end end diff --git a/spec/integration/extensions/json_schema/array_size_predicates_spec.rb b/spec/integration/extensions/json_schema/array_size_predicates_spec.rb new file mode 100644 index 00000000..4043c12f --- /dev/null +++ b/spec/integration/extensions/json_schema/array_size_predicates_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.describe "JSON Schema with array size predicates" do + before do + Dry::Schema.load_extensions(:json_schema) + end + + context "with min_size? and max_size? predicates" do + let(:schema) do + Dry::Schema.JSON do + required(:users).value(:array?, min_size?: 5, max_size?: 10).each(:str?) + end + end + + it "generates minItems and maxItems on array" do + json_schema = schema.json_schema + + expect(json_schema[:properties][:users]).to include( + type: "array", + minItems: 5, + maxItems: 10, + items: {type: "string"} + ) + + expect(json_schema[:properties][:users][:items]).not_to have_key(:minLength) + expect(json_schema[:properties][:users][:items]).not_to have_key(:maxLength) + end + end + + context "with string items having size predicates" do + let(:schema) do + Dry::Schema.JSON do + required(:names).value(:array?, min_size?: 2).each(:str?, min_size?: 3, max_size?: 50) + end + end + + it "applies array size to array and string size to items" do + json_schema = schema.json_schema + + expect(json_schema[:properties][:names]).to include( + type: "array", + minItems: 2, + items: { + type: "string", + minLength: 3, + maxLength: 50 + } + ) + end + end + + context "with equal min and max size constraints" do + let(:schema) do + Dry::Schema.JSON do + required(:users).value(:array?, min_size?: 5, max_size?: 5).each(:str?) + end + end + + it "generates correct minItems and maxItems" do + expected = { + "$schema": "http://json-schema.org/draft-06/schema#", + type: "object", + properties: { + users: { + type: "array", + minItems: 5, + maxItems: 5, + items: { + type: "string" + } + } + }, + required: ["users"] + } + + expect(schema.json_schema).to eq(expected) + end + end +end