From 6ff1d77a8f29668a2cfb65ee448586e25e77a596 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Wed, 20 May 2026 01:39:30 +0900 Subject: [PATCH 1/4] Add pending specs for TypeNullIn30 rule + 3.1 null runtime (3.1 strategy PR8) --- .../rules/type_null_in_30_spec.rb | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 spec/openapi_parser/spec_validator/rules/type_null_in_30_spec.rb diff --git a/spec/openapi_parser/spec_validator/rules/type_null_in_30_spec.rb b/spec/openapi_parser/spec_validator/rules/type_null_in_30_spec.rb new file mode 100644 index 0000000..7dc472f --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/type_null_in_30_spec.rb @@ -0,0 +1,54 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::TypeNullIn30' do + def schema_with_type_null(openapi_version_string) + raw = { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => { 'type' => 'null' } } }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::TypeNullIn30.new(root.openapi_version).check(root) + end + + context 'with a 3.1 document using type: "null"' do + it 'reports no violation' + end + + context 'with a 3.0 document using type: "null"' do + it 'reports one violation pointing at the offending schema' + end + + context 'with a 3.0 document using a normal type' do + it 'reports no violation' + end + + context 'with an :unknown version document using type: "null"' do + it 'reports no violation (rule skipped)' + end +end + +RSpec.describe 'runtime: type: "null" semantic' do + let(:options) { ::OpenAPIParser::SchemaValidator::Options.new } + let(:schema) do + raw = { + 'openapi' => '3.1.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Nullable' => { 'type' => 'null' } } }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false).components.schemas['Nullable'] + end + + context 'when value is nil' do + it 'passes validation without nullable' + end + + context 'when value is not nil' do + it 'raises a type-mismatch error' + end +end From 7e72a4a609a4465951523f6d5a8a2612166fd1e1 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Wed, 20 May 2026 01:41:00 +0900 Subject: [PATCH 2/4] type: \"null\" runtime support + 3.0 mismatch rule (3.1 strategy PR8) - nil_validator now passes when schema declares `type: \"null\"` even without `nullable: true` - NullTypeValidator handles non-nil values against `type: \"null\"` with a ValidateError so 3.1 enforces the singleton-null semantic - Rules::TypeNullIn30 flags `type: \"null\"` on 3.0 documents --- lib/openapi_parser/schema_validator.rb | 10 +++++ .../schema_validator/nil_validator.rb | 2 + .../schema_validator/null_type_validator.rb | 10 +++++ lib/openapi_parser/spec_validator.rb | 2 + .../spec_validator/rules/type_null_in_30.rb | 26 ++++++++++++ sig/openapi_parser/spec_validator.rbs | 4 ++ .../rules/type_null_in_30_spec.rb | 40 ++++++++++++++++--- 7 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 lib/openapi_parser/schema_validator/null_type_validator.rb create mode 100644 lib/openapi_parser/spec_validator/rules/type_null_in_30.rb diff --git a/lib/openapi_parser/schema_validator.rb b/lib/openapi_parser/schema_validator.rb index f95c492..1b65330 100644 --- a/lib/openapi_parser/schema_validator.rb +++ b/lib/openapi_parser/schema_validator.rb @@ -13,6 +13,7 @@ require_relative 'schema_validator/all_of_validator' require_relative 'schema_validator/one_of_validator' require_relative 'schema_validator/nil_validator' +require_relative 'schema_validator/null_type_validator' require_relative 'schema_validator/unspecified_type_validator' class OpenAPIParser::SchemaValidator @@ -116,11 +117,20 @@ def validator(value, schema) object_validator when 'array' array_validator + when 'null' + # 3.1: only nil values are valid here. nil is handled earlier in + # this method, so a non-nil value reaching this branch is a type + # mismatch that should fail validation. + null_type_validator else unspecified_type_validator end end + def null_type_validator + @null_type_validator ||= OpenAPIParser::SchemaValidator::NullTypeValidator.new(self, @coerce_value) + end + def string_validator @string_validator ||= OpenAPIParser::SchemaValidator::StringValidator.new(self, @allow_empty_date_and_datetime, @coerce_value, @datetime_coerce_class, @date_coerce_class) end diff --git a/lib/openapi_parser/schema_validator/nil_validator.rb b/lib/openapi_parser/schema_validator/nil_validator.rb index be74e4e..1537e15 100644 --- a/lib/openapi_parser/schema_validator/nil_validator.rb +++ b/lib/openapi_parser/schema_validator/nil_validator.rb @@ -4,6 +4,8 @@ class NilValidator < Base # @param [OpenAPIParser::Schemas::Schema] schema def coerce_and_validate(value, schema, **_keyword_args) return [value, nil] if schema.nullable + # 3.1: `type: "null"` makes nil the only valid value for the schema. + return [value, nil] if schema.type == 'null' [nil, OpenAPIParser::NotNullError.new(schema.object_reference)] end diff --git a/lib/openapi_parser/schema_validator/null_type_validator.rb b/lib/openapi_parser/schema_validator/null_type_validator.rb new file mode 100644 index 0000000..9aeb103 --- /dev/null +++ b/lib/openapi_parser/schema_validator/null_type_validator.rb @@ -0,0 +1,10 @@ +class OpenAPIParser::SchemaValidator + # Validates schemas declared as `type: "null"` (3.1) against non-nil + # values. The nil case is short-circuited by NilValidator before this + # validator is even picked up. + class NullTypeValidator < Base + def coerce_and_validate(value, schema, **_keyword_args) + OpenAPIParser::ValidateError.build_error_result(value, schema) + end + end +end diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index 2d92cf4..6d87b40 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -4,6 +4,7 @@ require_relative 'spec_validator/rules/exclusive_maximum' require_relative 'spec_validator/rules/nullable_deprecation' require_relative 'spec_validator/rules/example_singular_deprecation' +require_relative 'spec_validator/rules/type_null_in_30' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -55,6 +56,7 @@ def rules Rules::ExclusiveMaximum, Rules::NullableDeprecation, Rules::ExampleSingularDeprecation, + Rules::TypeNullIn30, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/type_null_in_30.rb b/lib/openapi_parser/spec_validator/rules/type_null_in_30.rb new file mode 100644 index 0000000..43b8631 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/type_null_in_30.rb @@ -0,0 +1,26 @@ +module OpenAPIParser + class SpecValidator + module Rules + # `type: "null"` was added by 3.1; 3.0 only allowed the original six + # primitive type names. The parse layer accepts the literal anyway, + # so we report the mismatch here. Array-form types like + # `["string", "null"]` are covered by a separate rule. + class TypeNullIn30 < Rule + def check(root) + return [] unless version == :v3_0 + + violations = [] + each_schema(root) do |schema| + next unless schema.type == 'null' + + violations << violation( + path: schema.object_reference, + message: '`type: "null"` is a 3.1 addition; 3.0 documents have no such primitive', + ) + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 1c7fcb6..69a617e 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -52,6 +52,10 @@ module OpenAPIParser class ExampleSingularDeprecation < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class TypeNullIn30 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/openapi_parser/spec_validator/rules/type_null_in_30_spec.rb b/spec/openapi_parser/spec_validator/rules/type_null_in_30_spec.rb index 7dc472f..8a7ae47 100644 --- a/spec/openapi_parser/spec_validator/rules/type_null_in_30_spec.rb +++ b/spec/openapi_parser/spec_validator/rules/type_null_in_30_spec.rb @@ -16,19 +16,40 @@ def run_rule_for(root) end context 'with a 3.1 document using type: "null"' do - it 'reports no violation' + it 'reports no violation' do + root = schema_with_type_null('3.1.0') + expect(run_rule_for(root)).to eq [] + end end context 'with a 3.0 document using type: "null"' do - it 'reports one violation pointing at the offending schema' + it 'reports one violation pointing at the offending schema' do + root = schema_with_type_null('3.0.0') + 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 :type_null_in30 + end end context 'with a 3.0 document using a normal type' 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' => 'string' } } }, + } + root = OpenAPIParser.parse(raw, strict_reference_validation: false) + expect(run_rule_for(root)).to eq [] + end end context 'with an :unknown version document using type: "null"' do - it 'reports no violation (rule skipped)' + it 'reports no violation (rule skipped)' do + root = schema_with_type_null('4.0.0') + expect(run_rule_for(root)).to eq [] + end end end @@ -45,10 +66,17 @@ def run_rule_for(root) end context 'when value is nil' do - it 'passes validation without nullable' + it 'passes validation without nullable' do + result = OpenAPIParser::SchemaValidator.validate(nil, schema, options) + expect(result).to eq nil + end end context 'when value is not nil' do - it 'raises a type-mismatch error' + it 'raises a type-mismatch error' do + expect do + OpenAPIParser::SchemaValidator.validate('not nil', schema, options) + end.to raise_error(OpenAPIParser::ValidateError) + end end end From ab73d82beb0c0296af3c3b30c53d0f976a9913a0 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 30 May 2026 16:44:01 +0900 Subject: [PATCH 3/4] Integration test: type "null" new in 3.1 type: "null" on a 3.0 document warns and raises (3.0 has no such primitive); the same type on a 3.1 document stays clean. --- spec/data/openapi_3_1/type_null_30.yaml | 24 +++++++++++++++++++ spec/data/openapi_3_1/type_null_31.yaml | 24 +++++++++++++++++++ .../spec_validator/integration_3_1_spec.rb | 15 ++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 spec/data/openapi_3_1/type_null_30.yaml create mode 100644 spec/data/openapi_3_1/type_null_31.yaml diff --git a/spec/data/openapi_3_1/type_null_30.yaml b/spec/data/openapi_3_1/type_null_30.yaml new file mode 100644 index 0000000..59b0571 --- /dev/null +++ b/spec/data/openapi_3_1/type_null_30.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: + title: Telemetry API + version: '1.0' +paths: + /readings: + get: + summary: List sensor readings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Reading' +components: + schemas: + Reading: + type: object + properties: + # 3.1 primitive `type: "null"`. 3.0 only knows the original six + # primitive names, so this is a spec violation on a 3.0 document. + calibration: + type: "null" diff --git a/spec/data/openapi_3_1/type_null_31.yaml b/spec/data/openapi_3_1/type_null_31.yaml new file mode 100644 index 0000000..1b049f9 --- /dev/null +++ b/spec/data/openapi_3_1/type_null_31.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Telemetry API + version: '1.0' +paths: + /readings: + get: + summary: List sensor readings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Reading' +components: + schemas: + Reading: + type: object + properties: + # 3.1 introduced `type: "null"` as a primitive, so this is legitimate + # on a 3.1 document and no violation is expected. + calibration: + type: "null" 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 1d8ac7c..5ae47e1 100644 --- a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb @@ -101,4 +101,19 @@ def expect_clean(file) expect_clean('example_keyword_30.yaml') end end + + describe 'type: "null" (3.1 primitive absent from 3.0)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('type_null_30.yaml', [:type_null_in30]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('type_null_30.yaml', [:type_null_in30]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('type_null_31.yaml') + end + end + end From 875c77b7d31d6d3f9e43354663c1c361bddf6c6a Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 4 Jul 2026 10:54:12 +0900 Subject: [PATCH 4/4] Add CHANGELOG entry for type null support --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0481a52..a81e704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ * 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 * `ExclusiveMinimum` / `ExclusiveMaximum`: detect 3.0 Boolean vs 3.1 numeric form mismatch + * `TypeNullIn30`: detect `type: "null"` usage in 3.0 documents (3.1 primitive) * support 3.1-style numeric `exclusiveMinimum` / `exclusiveMaximum` in value validation (standalone bound, not a Boolean modifier on `minimum` / `maximum`) +* support `type: "null"` (3.1 primitive) in value validation ## 2.3.1 (2025-11-14) * add optional date coercion with behavior matching existing datetime coercion