From 89107171e5c7bc68d5a96d1bbfa7673029336f87 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 00:22:34 +0900 Subject: [PATCH 01/11] Add pending specs for SpecValidator + openapi_version + runtime exclusiveMinimum (3.1 strategy) --- spec/openapi_parser/config_spec.rb | 17 ++++++++ .../integer_validator_spec.rb | 26 +++++++++++++ spec/openapi_parser/schemas/open_api_spec.rb | 34 ++++++++++++++++ .../spec_validator/integration_3_1_spec.rb | 15 +++++++ .../rules/exclusive_minimum_spec.rb | 27 +++++++++++++ spec/openapi_parser/spec_validator_spec.rb | 39 +++++++++++++++++++ 6 files changed, 158 insertions(+) create mode 100644 spec/openapi_parser/config_spec.rb create mode 100644 spec/openapi_parser/spec_validator/integration_3_1_spec.rb create mode 100644 spec/openapi_parser/spec_validator/rules/exclusive_minimum_spec.rb create mode 100644 spec/openapi_parser/spec_validator_spec.rb diff --git a/spec/openapi_parser/config_spec.rb b/spec/openapi_parser/config_spec.rb new file mode 100644 index 0000000..7928aea --- /dev/null +++ b/spec/openapi_parser/config_spec.rb @@ -0,0 +1,17 @@ +require_relative '../spec_helper' + +RSpec.describe OpenAPIParser::Config do + describe '#strict_specification_version' do + context 'when the option is not provided' do + it 'defaults to :silent' + end + + context 'when set to :warn' do + it 'returns :warn' + end + + context 'when set to :raise' do + it 'returns :raise' + end + end +end diff --git a/spec/openapi_parser/schema_validator/integer_validator_spec.rb b/spec/openapi_parser/schema_validator/integer_validator_spec.rb index 12825eb..f3a6555 100644 --- a/spec/openapi_parser/schema_validator/integer_validator_spec.rb +++ b/spec/openapi_parser/schema_validator/integer_validator_spec.rb @@ -109,6 +109,32 @@ end end + describe 'validate integer 3.1-style numeric exclusiveMinimum value' do + subject { OpenAPIParser::SchemaValidator.validate(params, target_schema, options) } + + let(:params) { {} } + let(:replace_schema) do + { + my_integer: { + type: 'integer', + exclusiveMinimum: 10, + }, + } + end + + context 'with a value strictly greater than exclusiveMinimum' do + it 'passes validation' + end + + context 'with a value equal to exclusiveMinimum' do + it 'raises LessThanExclusiveMinimum' + end + + context 'with a value less than exclusiveMinimum' do + it 'raises LessThanExclusiveMinimum' + end + end + describe 'validate integer exclusiveMinimum value' do subject { OpenAPIParser::SchemaValidator.validate(params, target_schema, options) } diff --git a/spec/openapi_parser/schemas/open_api_spec.rb b/spec/openapi_parser/schemas/open_api_spec.rb index 7d1a485..7285637 100644 --- a/spec/openapi_parser/schemas/open_api_spec.rb +++ b/spec/openapi_parser/schemas/open_api_spec.rb @@ -21,4 +21,38 @@ describe '#components' do it { expect(subject.components).not_to eq nil } end + + describe '#openapi_version' do + context 'with a typical 3.0.x version like "3.0.0"' do + it 'returns :v3_0' + end + + context 'with a typical 3.1.x version like "3.1.0"' do + it 'returns :v3_1' + end + + context 'with a minor-only version "3.0"' do + it 'returns :v3_0' + end + + context 'with a minor-only version "3.1"' do + it 'returns :v3_1' + end + + context 'with a prerelease tag like "3.0.0-rc1"' do + it 'returns :v3_0 by prefix match' + end + + context 'with an unknown major version like "4.0.0"' do + it 'returns :unknown' + end + + context 'when the openapi field is missing' do + it 'returns :unknown' + end + + context 'with a non-string openapi field' do + it 'returns :unknown' + end + end end diff --git a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb new file mode 100644 index 0000000..b1afdf6 --- /dev/null +++ b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb @@ -0,0 +1,15 @@ +require_relative '../../spec_helper' + +RSpec.describe 'OpenAPIParser 3.1 spec validator (integration)' do + describe 'strict_specification_version :silent (default)' do + it 'never warns or raises even when the document contains violations' + end + + describe 'exclusiveMinimum (3.0 Boolean modifier vs 3.1 numeric bound)' do + it 'warns on the version-mismatched document under :warn' + + it 'raises SpecViolationError on the version-mismatched document under :raise' + + it 'stays clean on the correctly-versioned document' + end +end diff --git a/spec/openapi_parser/spec_validator/rules/exclusive_minimum_spec.rb b/spec/openapi_parser/spec_validator/rules/exclusive_minimum_spec.rb new file mode 100644 index 0000000..39ef2fe --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/exclusive_minimum_spec.rb @@ -0,0 +1,27 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::ExclusiveMinimum' do + context 'with a 3.0 document using a 3.0-style boolean exclusiveMinimum' do + it 'reports no violation' + end + + context 'with a 3.1 document using a 3.1-style numeric exclusiveMinimum' do + it 'reports no violation' + end + + context 'with a 3.0 document using a 3.1-style numeric exclusiveMinimum' do + it 'reports one violation pointing at the offending schema' + end + + context 'with a 3.1 document using a 3.0-style boolean exclusiveMinimum' do + it 'reports one violation pointing at the offending schema' + end + + context 'with an :unknown version document containing exclusiveMinimum' do + it 'reports no violation (rule skipped)' + end + + context 'with a schema that has no exclusiveMinimum' do + it 'reports no violation' + end +end diff --git a/spec/openapi_parser/spec_validator_spec.rb b/spec/openapi_parser/spec_validator_spec.rb new file mode 100644 index 0000000..188061b --- /dev/null +++ b/spec/openapi_parser/spec_validator_spec.rb @@ -0,0 +1,39 @@ +require_relative '../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator' do + describe '.run' do + context 'with a root that has no spec-version-specific issues' do + it 'returns an empty array' + end + + context 'with a root whose openapi field is unrecognized' do + it 'returns an empty array (version-specific rules are skipped)' + end + + context 'with violations detected by a registered rule' do + it 'returns the collected SpecViolation list' + end + end + + describe '.run!' do + context 'with policy :silent' do + it 'returns nil without running validation' + end + + context 'with policy :warn and violations present' do + it 'emits one warning per violation to stderr' + end + + context 'with policy :warn and no violations' do + it 'produces no output' + end + + context 'with policy :raise and violations present' do + it 'raises SpecViolationError carrying the violations' + end + + context 'with policy :raise and no violations' do + it 'does not raise' + end + end +end From 3c48d012e88ed1cf16665af7a0a3b125a32872fb Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 00:24:36 +0900 Subject: [PATCH 02/11] Config#strict_specification_version + OpenAPI#openapi_version (3.1 strategy) --- lib/openapi_parser/config.rb | 5 +++ lib/openapi_parser/schemas/openapi.rb | 13 +++++++ spec/openapi_parser/config_spec.rb | 21 +++++++++-- spec/openapi_parser/schemas/open_api_spec.rb | 38 +++++++++++++++----- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/lib/openapi_parser/config.rb b/lib/openapi_parser/config.rb index 68cc182..920f9dd 100644 --- a/lib/openapi_parser/config.rb +++ b/lib/openapi_parser/config.rb @@ -41,6 +41,11 @@ def strict_reference_validation @config.fetch(:strict_reference_validation, false) end + # @return [Symbol] :silent / :warn / :raise + def strict_specification_version + @config.fetch(:strict_specification_version, :silent) + end + def validate_header @config.fetch(:validate_header, true) end diff --git a/lib/openapi_parser/schemas/openapi.rb b/lib/openapi_parser/schemas/openapi.rb index 9ab7e63..af0244c 100644 --- a/lib/openapi_parser/schemas/openapi.rb +++ b/lib/openapi_parser/schemas/openapi.rb @@ -21,6 +21,19 @@ def initialize(raw_schema, config, uri: nil, schema_registry: {}) # @return [String, nil] openapi_attr_values :openapi + # @return [Symbol] :v3_0 / :v3_1 / :unknown + def openapi_version + return :unknown unless openapi.is_a?(String) + + if openapi.start_with?('3.0') + :v3_0 + elsif openapi.start_with?('3.1') + :v3_1 + else + :unknown + end + end + # @!attribute [r] paths # @return [Paths, nil] openapi_attr_object :paths, Paths, reference: false diff --git a/spec/openapi_parser/config_spec.rb b/spec/openapi_parser/config_spec.rb index 7928aea..ad3eb9a 100644 --- a/spec/openapi_parser/config_spec.rb +++ b/spec/openapi_parser/config_spec.rb @@ -3,15 +3,30 @@ RSpec.describe OpenAPIParser::Config do describe '#strict_specification_version' do context 'when the option is not provided' do - it 'defaults to :silent' + it 'defaults to :silent' do + config = OpenAPIParser::Config.new(strict_reference_validation: false) + expect(config.strict_specification_version).to eq :silent + end end context 'when set to :warn' do - it 'returns :warn' + it 'returns :warn' do + config = OpenAPIParser::Config.new( + strict_reference_validation: false, + strict_specification_version: :warn, + ) + expect(config.strict_specification_version).to eq :warn + end end context 'when set to :raise' do - it 'returns :raise' + it 'returns :raise' do + config = OpenAPIParser::Config.new( + strict_reference_validation: false, + strict_specification_version: :raise, + ) + expect(config.strict_specification_version).to eq :raise + end end end end diff --git a/spec/openapi_parser/schemas/open_api_spec.rb b/spec/openapi_parser/schemas/open_api_spec.rb index 7285637..83e806d 100644 --- a/spec/openapi_parser/schemas/open_api_spec.rb +++ b/spec/openapi_parser/schemas/open_api_spec.rb @@ -23,36 +23,58 @@ end describe '#openapi_version' do + def parse_with_openapi_field(value, present: true) + schema = { 'info' => { 'title' => 'test', 'version' => '1.0' }, 'paths' => {} } + schema['openapi'] = value if present + OpenAPIParser.parse(schema, strict_reference_validation: false) + end + context 'with a typical 3.0.x version like "3.0.0"' do - it 'returns :v3_0' + it 'returns :v3_0' do + expect(parse_with_openapi_field('3.0.0').openapi_version).to eq :v3_0 + end end context 'with a typical 3.1.x version like "3.1.0"' do - it 'returns :v3_1' + it 'returns :v3_1' do + expect(parse_with_openapi_field('3.1.0').openapi_version).to eq :v3_1 + end end context 'with a minor-only version "3.0"' do - it 'returns :v3_0' + it 'returns :v3_0' do + expect(parse_with_openapi_field('3.0').openapi_version).to eq :v3_0 + end end context 'with a minor-only version "3.1"' do - it 'returns :v3_1' + it 'returns :v3_1' do + expect(parse_with_openapi_field('3.1').openapi_version).to eq :v3_1 + end end context 'with a prerelease tag like "3.0.0-rc1"' do - it 'returns :v3_0 by prefix match' + it 'returns :v3_0 by prefix match' do + expect(parse_with_openapi_field('3.0.0-rc1').openapi_version).to eq :v3_0 + end end context 'with an unknown major version like "4.0.0"' do - it 'returns :unknown' + it 'returns :unknown' do + expect(parse_with_openapi_field('4.0.0').openapi_version).to eq :unknown + end end context 'when the openapi field is missing' do - it 'returns :unknown' + it 'returns :unknown' do + expect(parse_with_openapi_field(nil, present: false).openapi_version).to eq :unknown + end end context 'with a non-string openapi field' do - it 'returns :unknown' + it 'returns :unknown' do + expect(parse_with_openapi_field(31).openapi_version).to eq :unknown + end end end end From b4090a83433f805a32ad90a9c06eec092cc60f77 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 00:26:27 +0900 Subject: [PATCH 03/11] SpecValidator skeleton + ExclusiveMinimum rule + run! dispatch (3.1 strategy) --- lib/openapi_parser.rb | 1 + lib/openapi_parser/spec_validator.rb | 55 +++++++++++++ lib/openapi_parser/spec_validator/rule.rb | 55 +++++++++++++ .../spec_validator/rules/exclusive_minimum.rb | 35 ++++++++ .../spec_validator/spec_violation.rb | 11 +++ .../rules/exclusive_minimum_spec.rb | 61 ++++++++++++-- spec/openapi_parser/spec_validator_spec.rb | 82 +++++++++++++++++-- 7 files changed, 285 insertions(+), 15 deletions(-) create mode 100644 lib/openapi_parser/spec_validator.rb create mode 100644 lib/openapi_parser/spec_validator/rule.rb create mode 100644 lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb create mode 100644 lib/openapi_parser/spec_validator/spec_violation.rb diff --git a/lib/openapi_parser.rb b/lib/openapi_parser.rb index b63e04c..79286e4 100644 --- a/lib/openapi_parser.rb +++ b/lib/openapi_parser.rb @@ -15,6 +15,7 @@ require 'openapi_parser/schema_validator' require 'openapi_parser/parameter_validator' require 'openapi_parser/reference_expander' +require 'openapi_parser/spec_validator' module OpenAPIParser class << self diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb new file mode 100644 index 0000000..cfd919d --- /dev/null +++ b/lib/openapi_parser/spec_validator.rb @@ -0,0 +1,55 @@ +require_relative 'spec_validator/spec_violation' +require_relative 'spec_validator/rule' +require_relative 'spec_validator/rules/exclusive_minimum' + +module OpenAPIParser + class SpecViolationError < OpenAPIError + attr_reader :violations + + def initialize(violations) + @violations = violations + super(nil) + end + + def message + @violations.map(&:to_s).join("\n") + end + end + + class SpecValidator + def self.run(root) + new(root).run + end + + def self.run!(root, policy:) + return if policy == :silent + + violations = run(root) + return if violations.empty? + + case policy + when :warn + violations.each { |v| warn(v.to_s) } + when :raise + raise OpenAPIParser::SpecViolationError.new(violations) + end + end + + def initialize(root) + @root = root + @version = root.openapi_version + end + + def run + rules.flat_map { |klass| klass.new(@version).check(@root) } + end + + private + + def rules + [ + Rules::ExclusiveMinimum, + ] + end + end +end diff --git a/lib/openapi_parser/spec_validator/rule.rb b/lib/openapi_parser/spec_validator/rule.rb new file mode 100644 index 0000000..733f128 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rule.rb @@ -0,0 +1,55 @@ +module OpenAPIParser + class SpecValidator + class Rule + def self.rule_name + name + .split('::') + .last + .gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" } + .sub(/^_/, '') + .to_sym + end + + def initialize(version) + @version = version + end + + attr_reader :version + + def check(_root) + raise NotImplementedError + end + + private + + def violation(path:, message:) + OpenAPIParser::SpecViolation.new( + message: message, + path: path, + rule_name: self.class.rule_name, + ) + end + + def each_schema(root, &block) + return enum_for(:each_schema, root) unless block + + visited = {} + walk(root, visited) do |node| + yield node if node.is_a?(OpenAPIParser::Schemas::Schema) + end + end + + def walk(node, visited, &block) + return unless node.respond_to?(:_openapi_all_child_objects) + return if visited[node.object_id] + + visited[node.object_id] = true + block.call(node) + + node._openapi_all_child_objects.each_value do |child| + walk(child, visited, &block) + end + end + end + end +end diff --git a/lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb b/lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb new file mode 100644 index 0000000..0dc7ff1 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb @@ -0,0 +1,35 @@ +module OpenAPIParser + class SpecValidator + module Rules + class ExclusiveMinimum < Rule + def check(root) + return [] if version == :unknown + + violations = [] + each_schema(root) do |schema| + value = schema.exclusiveMinimum + next if value.nil? + + case version + when :v3_0 + if value.is_a?(Numeric) && !value.is_a?(TrueClass) && !value.is_a?(FalseClass) + violations << violation( + path: schema.object_reference, + message: 'numeric exclusiveMinimum is a 3.1-only form; in 3.0 use a Boolean modifier paired with `minimum`', + ) + end + when :v3_1 + if value == true || value == false + violations << violation( + path: schema.object_reference, + message: 'Boolean exclusiveMinimum is a 3.0-only form; in 3.1 use a standalone numeric bound', + ) + end + end + end + violations + end + end + end + end +end diff --git a/lib/openapi_parser/spec_validator/spec_violation.rb b/lib/openapi_parser/spec_validator/spec_violation.rb new file mode 100644 index 0000000..490e1bb --- /dev/null +++ b/lib/openapi_parser/spec_validator/spec_violation.rb @@ -0,0 +1,11 @@ +module OpenAPIParser + class SpecValidator + SpecViolation = Struct.new(:message, :path, :rule_name, keyword_init: true) do + def to_s + "[#{rule_name}] #{path}: #{message}" + end + end + end + + SpecViolation = SpecValidator::SpecViolation +end diff --git a/spec/openapi_parser/spec_validator/rules/exclusive_minimum_spec.rb b/spec/openapi_parser/spec_validator/rules/exclusive_minimum_spec.rb index 39ef2fe..7e44b45 100644 --- a/spec/openapi_parser/spec_validator/rules/exclusive_minimum_spec.rb +++ b/spec/openapi_parser/spec_validator/rules/exclusive_minimum_spec.rb @@ -1,27 +1,74 @@ require_relative '../../../spec_helper' -RSpec.describe 'OpenAPIParser::SpecValidator::Rules::ExclusiveMinimum' do +RSpec.describe OpenAPIParser::SpecValidator::Rules::ExclusiveMinimum do + def schema_with_exclusive_minimum(openapi_version_string, exclusive_minimum_value, minimum_value: nil) + schema_payload = { 'type' => 'integer', 'exclusiveMinimum' => exclusive_minimum_value } + schema_payload['minimum'] = minimum_value if minimum_value + raw = { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => schema_payload } }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::ExclusiveMinimum.new(root.openapi_version).check(root) + end + context 'with a 3.0 document using a 3.0-style boolean exclusiveMinimum' do - it 'reports no violation' + it 'reports no violation' do + root = schema_with_exclusive_minimum('3.0.0', true, minimum_value: 5) + expect(run_rule_for(root)).to eq [] + end end context 'with a 3.1 document using a 3.1-style numeric exclusiveMinimum' do - it 'reports no violation' + it 'reports no violation' do + root = schema_with_exclusive_minimum('3.1.0', 5) + expect(run_rule_for(root)).to eq [] + end end context 'with a 3.0 document using a 3.1-style numeric exclusiveMinimum' do - it 'reports one violation pointing at the offending schema' + it 'reports one violation pointing at the offending schema' do + root = schema_with_exclusive_minimum('3.0.0', 5) + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/components/schemas/Sample' + expect(violations.first.rule_name).to eq :exclusive_minimum + expect(violations.first.message).to include('numeric exclusiveMinimum') + end end context 'with a 3.1 document using a 3.0-style boolean exclusiveMinimum' do - it 'reports one violation pointing at the offending schema' + it 'reports one violation pointing at the offending schema' do + root = schema_with_exclusive_minimum('3.1.0', true, minimum_value: 5) + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/components/schemas/Sample' + expect(violations.first.message).to include('Boolean exclusiveMinimum') + end end context 'with an :unknown version document containing exclusiveMinimum' do - it 'reports no violation (rule skipped)' + it 'reports no violation (rule skipped)' do + root = schema_with_exclusive_minimum('4.0.0', 5) + expect(run_rule_for(root)).to eq [] + end end context 'with a schema that has no exclusiveMinimum' do - it 'reports no violation' + it 'reports no violation' do + raw = { + 'openapi' => '3.0.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => { 'type' => 'integer' } } }, + } + root = OpenAPIParser.parse(raw, strict_reference_validation: false) + expect(run_rule_for(root)).to eq [] + end end end diff --git a/spec/openapi_parser/spec_validator_spec.rb b/spec/openapi_parser/spec_validator_spec.rb index 188061b..3179a96 100644 --- a/spec/openapi_parser/spec_validator_spec.rb +++ b/spec/openapi_parser/spec_validator_spec.rb @@ -1,39 +1,105 @@ require_relative '../spec_helper' RSpec.describe 'OpenAPIParser::SpecValidator' do + def parse_root(raw) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def clean_root(version = '3.0.0') + parse_root( + 'openapi' => version, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + ) + end + describe '.run' do context 'with a root that has no spec-version-specific issues' do - it 'returns an empty array' + it 'returns an empty array' do + expect(OpenAPIParser::SpecValidator.run(clean_root)).to eq [] + end end context 'with a root whose openapi field is unrecognized' do - it 'returns an empty array (version-specific rules are skipped)' + it 'returns an empty array (version-specific rules are skipped)' do + expect(OpenAPIParser::SpecValidator.run(clean_root('4.0.0'))).to eq [] + end end context 'with violations detected by a registered rule' do - it 'returns the collected SpecViolation list' + it 'returns the collected SpecViolation list' do + root = parse_root( + 'openapi' => '3.0.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { + 'schemas' => { + 'Sample' => { 'type' => 'integer', 'exclusiveMinimum' => 5 }, + }, + }, + ) + violations = OpenAPIParser::SpecValidator.run(root) + expect(violations.size).to eq 1 + expect(violations.first).to be_a(OpenAPIParser::SpecViolation) + end end end describe '.run!' do context 'with policy :silent' do - it 'returns nil without running validation' + it 'returns nil without running validation' do + expect(OpenAPIParser::SpecValidator.run!(clean_root, policy: :silent)).to be_nil + end end context 'with policy :warn and violations present' do - it 'emits one warning per violation to stderr' + it 'emits one warning per violation to stderr' do + root = parse_root( + 'openapi' => '3.0.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { + 'schemas' => { + 'Sample' => { 'type' => 'integer', 'exclusiveMinimum' => 5 }, + }, + }, + ) + expect { OpenAPIParser::SpecValidator.run!(root, policy: :warn) } + .to output(/exclusive_minimum/).to_stderr + end end context 'with policy :warn and no violations' do - it 'produces no output' + it 'produces no output' do + expect { OpenAPIParser::SpecValidator.run!(clean_root, policy: :warn) } + .not_to output.to_stderr + end end context 'with policy :raise and violations present' do - it 'raises SpecViolationError carrying the violations' + it 'raises SpecViolationError carrying the violations' do + root = parse_root( + 'openapi' => '3.0.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { + 'schemas' => { + 'Sample' => { 'type' => 'integer', 'exclusiveMinimum' => 5 }, + }, + }, + ) + expect { OpenAPIParser::SpecValidator.run!(root, policy: :raise) } + .to raise_error(OpenAPIParser::SpecViolationError) do |e| + expect(e.violations.size).to eq 1 + end + end end context 'with policy :raise and no violations' do - it 'does not raise' + it 'does not raise' do + expect { OpenAPIParser::SpecValidator.run!(clean_root, policy: :raise) } + .not_to raise_error + end end end end From 72ed99ebcd52e1da68e4a8fbf5d6c652dd18c831 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 00:27:11 +0900 Subject: [PATCH 04/11] Runtime: handle 3.1-style numeric exclusiveMinimum in value validation (3.1 strategy) --- .../schema_validator/minimum_maximum.rb | 13 +++++++++--- .../integer_validator_spec.rb | 21 ++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/openapi_parser/schema_validator/minimum_maximum.rb b/lib/openapi_parser/schema_validator/minimum_maximum.rb index 3e3e98f..9a943a8 100644 --- a/lib/openapi_parser/schema_validator/minimum_maximum.rb +++ b/lib/openapi_parser/schema_validator/minimum_maximum.rb @@ -4,8 +4,9 @@ module MinimumMaximum # @param [Object] value # @param [OpenAPIParser::Schemas::Schema] schema def check_minimum_maximum(value, schema) - include_min_max = schema.minimum || schema.maximum - return [value, nil] unless include_min_max + has_min_or_max = schema.minimum || schema.maximum + has_numeric_exclusive_min = schema.exclusiveMinimum.is_a?(Numeric) + return [value, nil] unless has_min_or_max || has_numeric_exclusive_min validate(value, schema) [value, nil] @@ -18,8 +19,14 @@ def check_minimum_maximum(value, schema) def validate(value, schema) reference = schema.object_reference + # 3.1: exclusiveMinimum is a standalone numeric bound. + if schema.exclusiveMinimum.is_a?(Numeric) && value <= schema.exclusiveMinimum + raise OpenAPIParser::LessThanExclusiveMinimum.new(value, reference) + end + if schema.minimum - if schema.exclusiveMinimum && value <= schema.minimum + # 3.0: exclusiveMinimum is a Boolean modifier on `minimum`. + if schema.exclusiveMinimum == true && value <= schema.minimum raise OpenAPIParser::LessThanExclusiveMinimum.new(value, reference) elsif value < schema.minimum raise OpenAPIParser::LessThanMinimum.new(value, reference) diff --git a/spec/openapi_parser/schema_validator/integer_validator_spec.rb b/spec/openapi_parser/schema_validator/integer_validator_spec.rb index f3a6555..474747a 100644 --- a/spec/openapi_parser/schema_validator/integer_validator_spec.rb +++ b/spec/openapi_parser/schema_validator/integer_validator_spec.rb @@ -123,15 +123,30 @@ end context 'with a value strictly greater than exclusiveMinimum' do - it 'passes validation' + let(:params) { { 'my_integer' => 11 } } + it 'passes validation' do + expect(subject).to eq({ 'my_integer' => 11 }) + end end context 'with a value equal to exclusiveMinimum' do - it 'raises LessThanExclusiveMinimum' + let(:params) { { 'my_integer' => 10 } } + it 'raises LessThanExclusiveMinimum' do + expect { subject }.to raise_error do |e| + expect(e).to be_kind_of(OpenAPIParser::LessThanExclusiveMinimum) + expect(e.message).to end_with('10 cannot be less than or equal to exclusive minimum value') + end + end end context 'with a value less than exclusiveMinimum' do - it 'raises LessThanExclusiveMinimum' + let(:params) { { 'my_integer' => 9 } } + it 'raises LessThanExclusiveMinimum' do + expect { subject }.to raise_error do |e| + expect(e).to be_kind_of(OpenAPIParser::LessThanExclusiveMinimum) + expect(e.message).to end_with('9 cannot be less than or equal to exclusive minimum value') + end + end end end From b6384e4265776e66c40a71861dd2c110838450c5 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 00:28:09 +0900 Subject: [PATCH 05/11] Wire SpecValidator.run! into load_hash + integration tests (3.1 strategy) --- lib/openapi_parser.rb | 2 + .../openapi_3_1/exclusive_minimum_30.yaml | 24 ++++++++ .../openapi_3_1/exclusive_minimum_31.yaml | 24 ++++++++ .../spec_validator/integration_3_1_spec.rb | 55 +++++++++++++++++-- 4 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 spec/data/openapi_3_1/exclusive_minimum_30.yaml create mode 100644 spec/data/openapi_3_1/exclusive_minimum_31.yaml diff --git a/lib/openapi_parser.rb b/lib/openapi_parser.rb index 79286e4..5b6a8ad 100644 --- a/lib/openapi_parser.rb +++ b/lib/openapi_parser.rb @@ -100,6 +100,8 @@ def load_hash(hash, config:, uri:, schema_registry:) path_item.set_path_item_to_operation end + OpenAPIParser::SpecValidator.run!(root, policy: config.strict_specification_version) + root end end diff --git a/spec/data/openapi_3_1/exclusive_minimum_30.yaml b/spec/data/openapi_3_1/exclusive_minimum_30.yaml new file mode 100644 index 0000000..8664de4 --- /dev/null +++ b/spec/data/openapi_3_1/exclusive_minimum_30.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: + title: Membership API + version: '1.0' +paths: + /members: + post: + summary: Register a member + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + responses: + '201': + description: Created +components: + schemas: + Member: + type: object + properties: + age: + type: integer + exclusiveMinimum: 0 diff --git a/spec/data/openapi_3_1/exclusive_minimum_31.yaml b/spec/data/openapi_3_1/exclusive_minimum_31.yaml new file mode 100644 index 0000000..7d5aa3b --- /dev/null +++ b/spec/data/openapi_3_1/exclusive_minimum_31.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Membership API + version: '1.0' +paths: + /members: + post: + summary: Register a member + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + responses: + '201': + description: Created +components: + schemas: + Member: + type: object + properties: + age: + type: integer + exclusiveMinimum: 0 diff --git a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb index b1afdf6..0b1d899 100644 --- a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb @@ -1,15 +1,62 @@ require_relative '../../spec_helper' RSpec.describe 'OpenAPIParser 3.1 spec validator (integration)' do + DATA_DIR = './spec/data/openapi_3_1'.freeze + + def load_doc(file, policy) + OpenAPIParser.load( + "#{DATA_DIR}/#{file}", + strict_reference_validation: false, + strict_specification_version: policy, + ) + end + + def capture_stderr + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + + def expect_mismatch_warns(file, rule_names) + stderr = capture_stderr { load_doc(file, :warn) } + rule_names.each { |rule_name| expect(stderr).to include("[#{rule_name}]") } + expect(stderr.lines.size).to eq rule_names.size + end + + def expect_mismatch_raises(file, rule_names) + expect { load_doc(file, :raise) } + .to raise_error(OpenAPIParser::SpecViolationError) do |error| + expect(error.violations.map(&:rule_name)).to match_array(rule_names) + end + end + + def expect_clean(file) + expect(OpenAPIParser::SpecValidator.run(load_doc(file, :silent))).to eq [] + expect { load_doc(file, :warn) }.not_to output.to_stderr + expect { load_doc(file, :raise) }.not_to raise_error + end + describe 'strict_specification_version :silent (default)' do - it 'never warns or raises even when the document contains violations' + it 'never warns or raises even when the document contains violations' do + expect { load_doc('exclusive_minimum_30.yaml', :silent) }.not_to output.to_stderr + expect { load_doc('exclusive_minimum_30.yaml', :silent) }.not_to raise_error + end end describe 'exclusiveMinimum (3.0 Boolean modifier vs 3.1 numeric bound)' do - it 'warns on the version-mismatched document under :warn' + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('exclusive_minimum_30.yaml', [:exclusive_minimum]) + end - it 'raises SpecViolationError on the version-mismatched document under :raise' + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('exclusive_minimum_30.yaml', [:exclusive_minimum]) + end - it 'stays clean on the correctly-versioned document' + it 'stays clean on the correctly-versioned document' do + expect_clean('exclusive_minimum_31.yaml') + end end end From 81ddf081be33699e24f38ccd164af9d9332d9169 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 00:29:16 +0900 Subject: [PATCH 06/11] Add CHANGELOG entries + RBS signatures (3.1 strategy) --- CHANGELOG.md | 2 ++ sig/openapi_parser.rbs | 2 ++ sig/openapi_parser/config.rbs | 1 + sig/openapi_parser/spec_validator.rbs | 47 +++++++++++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 sig/openapi_parser/spec_validator.rbs diff --git a/CHANGELOG.md b/CHANGELOG.md index f45e7f8..69b3ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased * support `components.pathItems` so `$ref`s into it resolve, unblocking OpenAPI 3.1 documents that use reusable path items +* add `SpecValidator` with `strict_specification_version` config (`:silent` / `:warn` / `:raise`) to detect version mismatches between declared OpenAPI version and actual field usage; ships with `ExclusiveMinimum` rule detecting 3.0 Boolean vs 3.1 numeric form +* support 3.1-style numeric `exclusiveMinimum` in value validation (standalone bound, not a Boolean modifier on `minimum`) ## 2.3.1 (2025-11-14) * add optional date coercion with behavior matching existing datetime coercion diff --git a/sig/openapi_parser.rbs b/sig/openapi_parser.rbs index 9bf45cf..b422c58 100644 --- a/sig/openapi_parser.rbs +++ b/sig/openapi_parser.rbs @@ -14,6 +14,8 @@ module OpenAPIParser module Schemas class OpenAPI def initialize: (Hash[bot, bot] hash, untyped config, uri: OpenAPIParser::readable_uri?, schema_registry: Hash[bot, bot]) -> OpenAPIParser::Schemas::OpenAPI + def openapi: () -> String? + def openapi_version: () -> (:v3_0 | :v3_1 | :unknown) end end end diff --git a/sig/openapi_parser/config.rbs b/sig/openapi_parser/config.rbs index 944abac..ddf35ca 100644 --- a/sig/openapi_parser/config.rbs +++ b/sig/openapi_parser/config.rbs @@ -15,6 +15,7 @@ module OpenAPIParser def expand_reference: -> bool def strict_response_validation: -> bool def strict_reference_validation: -> bool + def strict_specification_version: -> (:silent | :warn | :raise) def validate_header: -> bool def request_validator_options: -> OpenAPIParser::SchemaValidator::Options def response_validate_options: -> OpenAPIParser::SchemaValidator::ResponseValidateOptions diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs new file mode 100644 index 0000000..aabfdbe --- /dev/null +++ b/sig/openapi_parser/spec_validator.rbs @@ -0,0 +1,47 @@ +module OpenAPIParser + class SpecViolationError < OpenAPIError + @violations: Array[OpenAPIParser::SpecValidator::SpecViolation] + attr_reader violations: Array[OpenAPIParser::SpecValidator::SpecViolation] + + def initialize: (Array[OpenAPIParser::SpecValidator::SpecViolation] violations) -> void + def message: () -> String + end + + class SpecValidator + @root: OpenAPIParser::Schemas::OpenAPI + @version: (:v3_0 | :v3_1 | :unknown) + + def self.run: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecViolation] + def self.run!: (OpenAPIParser::Schemas::OpenAPI root, policy: (:silent | :warn | :raise)) -> void + def initialize: (OpenAPIParser::Schemas::OpenAPI root) -> void + def run: () -> Array[SpecViolation] + private def rules: () -> Array[singleton(Rule)] + + class SpecViolation < ::Struct[untyped] + attr_accessor message: String + attr_accessor path: String + attr_accessor rule_name: Symbol + def to_s: () -> String + end + + class Rule + @version: (:v3_0 | :v3_1 | :unknown) + attr_reader version: (:v3_0 | :v3_1 | :unknown) + + def self.rule_name: () -> Symbol + def initialize: ((:v3_0 | :v3_1 | :unknown) version) -> void + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecViolation] + private def violation: (path: String, message: String) -> SpecViolation + private def each_schema: (untyped root) ?{ (untyped) -> void } -> untyped + private def walk: (untyped node, Hash[Integer, bool] visited) { (untyped) -> void } -> void + end + + module Rules + class ExclusiveMinimum < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end + end + end + + SpecViolation: singleton(SpecValidator::SpecViolation) +end From 027c40ebcb2b0a9b11d6fb8a5060f2d4f38b32a2 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 00:46:09 +0900 Subject: [PATCH 07/11] ExclusiveMaximum rule + 3.1-style numeric exclusiveMaximum runtime (3.1 strategy) --- .../schema_validator/minimum_maximum.rb | 10 ++- lib/openapi_parser/spec_validator.rb | 2 + .../spec_validator/rules/exclusive_maximum.rb | 35 +++++++++ .../openapi_3_1/exclusive_maximum_30.yaml | 24 ++++++ .../openapi_3_1/exclusive_maximum_31.yaml | 24 ++++++ .../integer_validator_spec.rb | 41 +++++++++++ .../spec_validator/integration_3_1_spec.rb | 14 ++++ .../rules/exclusive_maximum_spec.rb | 73 +++++++++++++++++++ 8 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 lib/openapi_parser/spec_validator/rules/exclusive_maximum.rb create mode 100644 spec/data/openapi_3_1/exclusive_maximum_30.yaml create mode 100644 spec/data/openapi_3_1/exclusive_maximum_31.yaml create mode 100644 spec/openapi_parser/spec_validator/rules/exclusive_maximum_spec.rb diff --git a/lib/openapi_parser/schema_validator/minimum_maximum.rb b/lib/openapi_parser/schema_validator/minimum_maximum.rb index 9a943a8..e5d71e2 100644 --- a/lib/openapi_parser/schema_validator/minimum_maximum.rb +++ b/lib/openapi_parser/schema_validator/minimum_maximum.rb @@ -6,7 +6,8 @@ module MinimumMaximum def check_minimum_maximum(value, schema) has_min_or_max = schema.minimum || schema.maximum has_numeric_exclusive_min = schema.exclusiveMinimum.is_a?(Numeric) - return [value, nil] unless has_min_or_max || has_numeric_exclusive_min + has_numeric_exclusive_max = schema.exclusiveMaximum.is_a?(Numeric) + return [value, nil] unless has_min_or_max || has_numeric_exclusive_min || has_numeric_exclusive_max validate(value, schema) [value, nil] @@ -33,8 +34,13 @@ def validate(value, schema) end end + # 3.1: standalone numeric upper bound, mirror of exclusiveMinimum. + if schema.exclusiveMaximum.is_a?(Numeric) && value >= schema.exclusiveMaximum + raise OpenAPIParser::MoreThanExclusiveMaximum.new(value, reference) + end + if schema.maximum - if schema.exclusiveMaximum && value >= schema.maximum + if schema.exclusiveMaximum == true && value >= schema.maximum raise OpenAPIParser::MoreThanExclusiveMaximum.new(value, reference) elsif value > schema.maximum raise OpenAPIParser::MoreThanMaximum.new(value, reference) diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index cfd919d..8f52f37 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -1,6 +1,7 @@ require_relative 'spec_validator/spec_violation' require_relative 'spec_validator/rule' require_relative 'spec_validator/rules/exclusive_minimum' +require_relative 'spec_validator/rules/exclusive_maximum' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -49,6 +50,7 @@ def run def rules [ Rules::ExclusiveMinimum, + Rules::ExclusiveMaximum, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/exclusive_maximum.rb b/lib/openapi_parser/spec_validator/rules/exclusive_maximum.rb new file mode 100644 index 0000000..c2b4cdf --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/exclusive_maximum.rb @@ -0,0 +1,35 @@ +module OpenAPIParser + class SpecValidator + module Rules + class ExclusiveMaximum < Rule + def check(root) + return [] if version == :unknown + + violations = [] + each_schema(root) do |schema| + value = schema.exclusiveMaximum + next if value.nil? + + case version + when :v3_0 + if value.is_a?(Numeric) + violations << violation( + path: schema.object_reference, + message: 'numeric exclusiveMaximum is a 3.1-only form; in 3.0 use a Boolean modifier paired with `maximum`', + ) + end + when :v3_1 + if value == true || value == false + violations << violation( + path: schema.object_reference, + message: 'Boolean exclusiveMaximum is a 3.0-only form; in 3.1 use a standalone numeric bound', + ) + end + end + end + violations + end + end + end + end +end diff --git a/spec/data/openapi_3_1/exclusive_maximum_30.yaml b/spec/data/openapi_3_1/exclusive_maximum_30.yaml new file mode 100644 index 0000000..d91fa89 --- /dev/null +++ b/spec/data/openapi_3_1/exclusive_maximum_30.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: + title: Pricing API + version: '1.0' +paths: + /quotes: + post: + summary: Request a price quote + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' + responses: + '201': + description: Created +components: + schemas: + Quote: + type: object + properties: + discountPercent: + type: number + exclusiveMaximum: 100 diff --git a/spec/data/openapi_3_1/exclusive_maximum_31.yaml b/spec/data/openapi_3_1/exclusive_maximum_31.yaml new file mode 100644 index 0000000..10d67d9 --- /dev/null +++ b/spec/data/openapi_3_1/exclusive_maximum_31.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Pricing API + version: '1.0' +paths: + /quotes: + post: + summary: Request a price quote + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' + responses: + '201': + description: Created +components: + schemas: + Quote: + type: object + properties: + discountPercent: + type: number + exclusiveMaximum: 100 diff --git a/spec/openapi_parser/schema_validator/integer_validator_spec.rb b/spec/openapi_parser/schema_validator/integer_validator_spec.rb index 474747a..490e554 100644 --- a/spec/openapi_parser/schema_validator/integer_validator_spec.rb +++ b/spec/openapi_parser/schema_validator/integer_validator_spec.rb @@ -109,6 +109,47 @@ end end + describe 'validate integer 3.1-style numeric exclusiveMaximum value' do + subject { OpenAPIParser::SchemaValidator.validate(params, target_schema, options) } + + let(:params) { {} } + let(:replace_schema) do + { + my_integer: { + type: 'integer', + exclusiveMaximum: 10, + }, + } + end + + context 'with a value strictly less than exclusiveMaximum' do + let(:params) { { 'my_integer' => 9 } } + it 'passes validation' do + expect(subject).to eq({ 'my_integer' => 9 }) + end + end + + context 'with a value equal to exclusiveMaximum' do + let(:params) { { 'my_integer' => 10 } } + it 'raises MoreThanExclusiveMaximum' do + expect { subject }.to raise_error do |e| + expect(e).to be_kind_of(OpenAPIParser::MoreThanExclusiveMaximum) + expect(e.message).to end_with('10 cannot be more than or equal to exclusive maximum value') + end + end + end + + context 'with a value greater than exclusiveMaximum' do + let(:params) { { 'my_integer' => 11 } } + it 'raises MoreThanExclusiveMaximum' do + expect { subject }.to raise_error do |e| + expect(e).to be_kind_of(OpenAPIParser::MoreThanExclusiveMaximum) + expect(e.message).to end_with('11 cannot be more than or equal to exclusive maximum value') + end + end + end + end + describe 'validate integer 3.1-style numeric exclusiveMinimum value' do subject { OpenAPIParser::SchemaValidator.validate(params, target_schema, options) } diff --git a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb index 0b1d899..3a0c4f1 100644 --- a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb @@ -59,4 +59,18 @@ def expect_clean(file) expect_clean('exclusive_minimum_31.yaml') end end + + describe 'exclusiveMaximum (3.0 Boolean modifier vs 3.1 numeric bound)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('exclusive_maximum_30.yaml', [:exclusive_maximum]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('exclusive_maximum_30.yaml', [:exclusive_maximum]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('exclusive_maximum_31.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/exclusive_maximum_spec.rb b/spec/openapi_parser/spec_validator/rules/exclusive_maximum_spec.rb new file mode 100644 index 0000000..5b35124 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/exclusive_maximum_spec.rb @@ -0,0 +1,73 @@ +require_relative '../../../spec_helper' + +RSpec.describe OpenAPIParser::SpecValidator::Rules::ExclusiveMaximum do + def schema_with_exclusive_maximum(openapi_version_string, exclusive_maximum_value, maximum_value: nil) + schema_payload = { 'type' => 'integer', 'exclusiveMaximum' => exclusive_maximum_value } + schema_payload['maximum'] = maximum_value if maximum_value + raw = { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => schema_payload } }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::ExclusiveMaximum.new(root.openapi_version).check(root) + end + + context 'with a 3.0 document using a 3.0-style boolean exclusiveMaximum' do + it 'reports no violation' do + root = schema_with_exclusive_maximum('3.0.0', true, maximum_value: 5) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using a 3.1-style numeric exclusiveMaximum' do + it 'reports no violation' do + root = schema_with_exclusive_maximum('3.1.0', 5) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using a 3.1-style numeric exclusiveMaximum' do + it 'reports one violation pointing at the offending schema' do + root = schema_with_exclusive_maximum('3.0.0', 5) + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/components/schemas/Sample' + expect(violations.first.rule_name).to eq :exclusive_maximum + expect(violations.first.message).to include('numeric exclusiveMaximum') + end + end + + context 'with a 3.1 document using a 3.0-style boolean exclusiveMaximum' do + it 'reports one violation pointing at the offending schema' do + root = schema_with_exclusive_maximum('3.1.0', true, maximum_value: 5) + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.message).to include('Boolean exclusiveMaximum') + end + end + + context 'with an :unknown version document containing exclusiveMaximum' do + it 'reports no violation (rule skipped)' do + root = schema_with_exclusive_maximum('4.0.0', 5) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a schema that has no exclusiveMaximum' do + it 'reports no violation' do + raw = { + 'openapi' => '3.0.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => { 'type' => 'integer' } } }, + } + root = OpenAPIParser.parse(raw, strict_reference_validation: false) + expect(run_rule_for(root)).to eq [] + end + end +end From 09f73f19b6b6ac80b04f080deb09ae7528c3fd08 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 00:46:26 +0900 Subject: [PATCH 08/11] Update CHANGELOG + RBS for ExclusiveMaximum (3.1 strategy) --- CHANGELOG.md | 5 +++-- sig/openapi_parser/spec_validator.rbs | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b3ada..0481a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ ## Unreleased * support `components.pathItems` so `$ref`s into it resolve, unblocking OpenAPI 3.1 documents that use reusable path items -* add `SpecValidator` with `strict_specification_version` config (`:silent` / `:warn` / `:raise`) to detect version mismatches between declared OpenAPI version and actual field usage; ships with `ExclusiveMinimum` rule detecting 3.0 Boolean vs 3.1 numeric form -* support 3.1-style numeric `exclusiveMinimum` in value validation (standalone bound, not a Boolean modifier on `minimum`) +* add `SpecValidator` with `strict_specification_version` config (`:silent` / `:warn` / `:raise`) to detect version mismatches between declared OpenAPI version and actual field usage + * `ExclusiveMinimum` / `ExclusiveMaximum`: detect 3.0 Boolean vs 3.1 numeric form mismatch +* support 3.1-style numeric `exclusiveMinimum` / `exclusiveMaximum` in value validation (standalone bound, not a Boolean modifier on `minimum` / `maximum`) ## 2.3.1 (2025-11-14) * add optional date coercion with behavior matching existing datetime coercion diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index aabfdbe..f680c79 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -40,6 +40,10 @@ module OpenAPIParser class ExclusiveMinimum < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class ExclusiveMaximum < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end From d313c3e04025879c356e0627eb985325196fc29c Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 08:58:13 +0900 Subject: [PATCH 09/11] Config#strict_specification_version: warn and fall back to :silent on unrecognized value (3.1 strategy) --- lib/openapi_parser/config.rb | 9 ++++++++- spec/openapi_parser/config_spec.rb | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/openapi_parser/config.rb b/lib/openapi_parser/config.rb index 920f9dd..850b555 100644 --- a/lib/openapi_parser/config.rb +++ b/lib/openapi_parser/config.rb @@ -41,9 +41,16 @@ def strict_reference_validation @config.fetch(:strict_reference_validation, false) end + KNOWN_STRICT_SPECIFICATION_VERSION_VALUES = %i[silent warn raise].freeze + # @return [Symbol] :silent / :warn / :raise def strict_specification_version - @config.fetch(:strict_specification_version, :silent) + value = @config.fetch(:strict_specification_version, :silent) + unless KNOWN_STRICT_SPECIFICATION_VERSION_VALUES.include?(value) + warn("[OpenAPIParser] unknown strict_specification_version: #{value.inspect}, falling back to :silent") + return :silent + end + value end def validate_header diff --git a/spec/openapi_parser/config_spec.rb b/spec/openapi_parser/config_spec.rb index ad3eb9a..aca7b17 100644 --- a/spec/openapi_parser/config_spec.rb +++ b/spec/openapi_parser/config_spec.rb @@ -28,5 +28,16 @@ expect(config.strict_specification_version).to eq :raise end end + + context 'when set to an unrecognized value' do + it 'warns and falls back to :silent' do + config = OpenAPIParser::Config.new( + strict_reference_validation: false, + strict_specification_version: :bogus, + ) + expect { expect(config.strict_specification_version).to eq :silent } + .to output(/unknown strict_specification_version.*:bogus.*falling back to :silent/).to_stderr + end + end end end From 280f64e7c05f35baceb963832eccdf08c51b284b Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 08:58:13 +0900 Subject: [PATCH 10/11] Remove redundant TrueClass/FalseClass guard in ExclusiveMinimum rule (3.1 strategy) --- lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb b/lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb index 0dc7ff1..b1894db 100644 --- a/lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb +++ b/lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb @@ -12,7 +12,7 @@ def check(root) case version when :v3_0 - if value.is_a?(Numeric) && !value.is_a?(TrueClass) && !value.is_a?(FalseClass) + if value.is_a?(Numeric) violations << violation( path: schema.object_reference, message: 'numeric exclusiveMinimum is a 3.1-only form; in 3.0 use a Boolean modifier paired with `minimum`', From eb225114aab357a5e1e6e6a0caf4a1b060a871be Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 5 Jun 2026 09:01:04 +0900 Subject: [PATCH 11/11] Add RBS declaration for KNOWN_STRICT_SPECIFICATION_VERSION_VALUES (3.1 strategy) --- sig/openapi_parser/config.rbs | 1 + 1 file changed, 1 insertion(+) diff --git a/sig/openapi_parser/config.rbs b/sig/openapi_parser/config.rbs index ddf35ca..e4a65cf 100644 --- a/sig/openapi_parser/config.rbs +++ b/sig/openapi_parser/config.rbs @@ -15,6 +15,7 @@ module OpenAPIParser def expand_reference: -> bool def strict_response_validation: -> bool def strict_reference_validation: -> bool + KNOWN_STRICT_SPECIFICATION_VERSION_VALUES: Array[Symbol] def strict_specification_version: -> (:silent | :warn | :raise) def validate_header: -> bool def request_validator_options: -> OpenAPIParser::SchemaValidator::Options