From 650e7e55e3155eeda296480920f5036da5531de9 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Wed, 20 May 2026 01:44:02 +0900 Subject: [PATCH 1/4] TypeArrayIn30 rule + 3.1 type-Array runtime dispatch (3.1 strategy PR9) - Rules::TypeArrayIn30 flags array-form `type` on 3.0 documents - nil_validator also passes when `type` is an Array containing "null" - schema_validator picks the primitive in the Array that matches the value's class and dispatches to the existing per-primitive validator; values that match no primitive fall to NullTypeValidator which is now general-purpose for "no applicable type" --- lib/openapi_parser/schema_validator.rb | 33 +++++++- .../schema_validator/nil_validator.rb | 2 + .../schema_validator/null_type_validator.rb | 7 +- lib/openapi_parser/spec_validator.rb | 2 + .../spec_validator/rules/type_array_in_30.rb | 25 ++++++ sig/openapi_parser/spec_validator.rbs | 4 + .../rules/type_array_in_30_spec.rb | 81 +++++++++++++++++++ 7 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 lib/openapi_parser/spec_validator/rules/type_array_in_30.rb create mode 100644 spec/openapi_parser/spec_validator/rules/type_array_in_30_spec.rb diff --git a/lib/openapi_parser/schema_validator.rb b/lib/openapi_parser/schema_validator.rb index 1b653300..ba52bf2d 100644 --- a/lib/openapi_parser/schema_validator.rb +++ b/lib/openapi_parser/schema_validator.rb @@ -104,7 +104,20 @@ def validator(value, schema) return one_of_validator if schema.one_of return nil_validator if value.nil? - case schema.type + # 3.1: pick the type from an Array whose primitive class matches the + # value, then dispatch as if it were a single-type schema. If nothing + # matches we still pick a candidate so the relevant validator emits + # a sensible error rather than silently accepting via the unspecified + # fallback. + effective_type = schema.type + if effective_type.is_a?(Array) + matched = pick_array_type(value, effective_type) + return type_array_mismatch_validator if matched.nil? + + effective_type = matched + end + + case effective_type when 'string' string_validator when 'integer' @@ -131,6 +144,24 @@ def null_type_validator @null_type_validator ||= OpenAPIParser::SchemaValidator::NullTypeValidator.new(self, @coerce_value) end + def type_array_mismatch_validator + @type_array_mismatch_validator ||= OpenAPIParser::SchemaValidator::NullTypeValidator.new(self, @coerce_value) + end + + def pick_array_type(value, types) + types.find do |t| + case t + when 'string' then value.is_a?(String) + when 'integer' then value.is_a?(Integer) + when 'number' then value.is_a?(Numeric) + when 'boolean' then value == true || value == false + when 'array' then value.is_a?(Array) + when 'object' then value.is_a?(Hash) + when 'null' then value.nil? + end + end + 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 1537e15a..ab3ca691 100644 --- a/lib/openapi_parser/schema_validator/nil_validator.rb +++ b/lib/openapi_parser/schema_validator/nil_validator.rb @@ -6,6 +6,8 @@ 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' + # 3.1: `type: [..., "null"]` lets nil coexist with another primitive. + return [value, nil] if schema.type.is_a?(Array) && schema.type.include?('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 index 9aeb103a..64c0fe49 100644 --- a/lib/openapi_parser/schema_validator/null_type_validator.rb +++ b/lib/openapi_parser/schema_validator/null_type_validator.rb @@ -1,7 +1,8 @@ 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. + # Emits a ValidateError unconditionally. Used when the dispatcher has + # already decided that no primitive applies to the value, e.g.: + # - schema declares `type: "null"` and the value is non-nil (3.1) + # - schema declares `type: [t1, t2, ...]` and the value matches none class NullTypeValidator < Base def coerce_and_validate(value, schema, **_keyword_args) OpenAPIParser::ValidateError.build_error_result(value, schema) diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index 26068569..66820407 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -2,6 +2,7 @@ require_relative 'spec_validator/rule' require_relative 'spec_validator/rules/exclusive_minimum' require_relative 'spec_validator/rules/exclusive_maximum' +require_relative 'spec_validator/rules/type_array_in_30' require_relative 'spec_validator/rules/path_items_in_30' require_relative 'spec_validator/rules/nullable_deprecation' require_relative 'spec_validator/rules/example_singular_deprecation' @@ -55,6 +56,7 @@ def rules [ Rules::ExclusiveMinimum, Rules::ExclusiveMaximum, + Rules::TypeArrayIn30, Rules::PathItemsIn30, Rules::NullableDeprecation, Rules::ExampleSingularDeprecation, diff --git a/lib/openapi_parser/spec_validator/rules/type_array_in_30.rb b/lib/openapi_parser/spec_validator/rules/type_array_in_30.rb new file mode 100644 index 00000000..23dab065 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/type_array_in_30.rb @@ -0,0 +1,25 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.1 lets `type` be an Array of primitive type names (e.g. + # `["string", "null"]`). 3.0 only allowed a single String, so the + # array form on a 3.0 document is a spec violation. + class TypeArrayIn30 < Rule + def check(root) + return [] unless version == :v3_0 + + violations = [] + each_schema(root) do |schema| + next unless schema.type.is_a?(Array) + + violations << violation( + path: schema.object_reference, + message: '`type` as an Array of primitive names is a 3.1 addition; 3.0 expects a single type String', + ) + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 7ecc4546..776bb27f 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -45,6 +45,10 @@ module OpenAPIParser def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + class TypeArrayIn30 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end + class PathItemsIn30 < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end diff --git a/spec/openapi_parser/spec_validator/rules/type_array_in_30_spec.rb b/spec/openapi_parser/spec_validator/rules/type_array_in_30_spec.rb new file mode 100644 index 00000000..76c420cf --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/type_array_in_30_spec.rb @@ -0,0 +1,81 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::TypeArrayIn30' do + def schema_with_type_array(openapi_version_string, types) + raw = { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => { 'type' => types } } }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::TypeArrayIn30.new(root.openapi_version).check(root) + end + + context 'with a 3.1 document using type as Array' do + it 'reports no violation' do + root = schema_with_type_array('3.1.0', ['string', 'null']) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using type as Array' do + it 'reports one violation pointing at the offending schema' do + root = schema_with_type_array('3.0.0', ['string', 'null']) + 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_array_in30 + end + end + + context 'with a 3.0 document using type as plain string' do + it 'reports no violation' do + root = schema_with_type_array('3.0.0', 'string') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with an :unknown version document using type as Array' do + it 'reports no violation (rule skipped)' do + root = schema_with_type_array('4.0.0', ['string', 'null']) + expect(run_rule_for(root)).to eq [] + end + end +end + +RSpec.describe 'runtime: type as Array semantic in 3.1' do + let(:options) { ::OpenAPIParser::SchemaValidator::Options.new } + let(:schema) do + raw = { + 'openapi' => '3.1.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'NullableString' => { 'type' => ['string', 'null'] } } }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false).components.schemas['NullableString'] + end + + context 'when value is nil and array contains "null"' do + it 'passes validation' do + expect(OpenAPIParser::SchemaValidator.validate(nil, schema, options)).to eq nil + end + end + + context 'when value matches one of the listed types' do + it 'passes validation' do + expect(OpenAPIParser::SchemaValidator.validate('hello', schema, options)).to eq 'hello' + end + end + + context 'when value matches no listed type' do + it 'raises a type-mismatch error' do + expect do + OpenAPIParser::SchemaValidator.validate(42, schema, options) + end.to raise_error(OpenAPIParser::ValidateError) + end + end +end From 43c2bb353984562e9821f13ca38ccb2913ad7f24 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 30 May 2026 16:44:33 +0900 Subject: [PATCH 2/4] Integration test: type as an Array of names new in 3.1 An Array-valued type on a 3.0 document warns and raises (3.0 expects a single type String); the same form on a 3.1 document stays clean. --- spec/data/openapi_3_1/type_array_30.yaml | 24 +++++++++++++++++++ spec/data/openapi_3_1/type_array_31.yaml | 24 +++++++++++++++++++ .../spec_validator/integration_3_1_spec.rb | 14 +++++++++++ 3 files changed, 62 insertions(+) create mode 100644 spec/data/openapi_3_1/type_array_30.yaml create mode 100644 spec/data/openapi_3_1/type_array_31.yaml diff --git a/spec/data/openapi_3_1/type_array_30.yaml b/spec/data/openapi_3_1/type_array_30.yaml new file mode 100644 index 00000000..97ad7b14 --- /dev/null +++ b/spec/data/openapi_3_1/type_array_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 form: `type` as an Array of primitive names. 3.0 only allows a + # single type String, so the array form is a spec violation here. + label: + type: [string, "null"] diff --git a/spec/data/openapi_3_1/type_array_31.yaml b/spec/data/openapi_3_1/type_array_31.yaml new file mode 100644 index 00000000..9f11fb92 --- /dev/null +++ b/spec/data/openapi_3_1/type_array_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 allows `type` to be an Array of primitive names, so this is + # legitimate on a 3.1 document and no violation is expected. + label: + type: [string, "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 fd386bd2..5de0cb2f 100644 --- a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb @@ -74,6 +74,20 @@ def expect_clean(file) end end + describe 'type as an Array of names (3.1 form rejected by 3.0)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('type_array_30.yaml', [:type_array_in30]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('type_array_30.yaml', [:type_array_in30]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('type_array_31.yaml') + end + end + describe 'components.pathItems (3.1 addition)' do it 'warns on the version-mismatched document under :warn' do expect_mismatch_warns('path_items_30.yaml', [:path_items_in30]) From eecf38f1f0b4ff9550fc26d637ffad80cca4bda1 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 4 Jul 2026 12:07:04 +0900 Subject: [PATCH 3/4] Add CHANGELOG entries for type array support --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44792720..8f26716b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Unreleased * support `components.pathItems` so `$ref`s into it resolve, unblocking OpenAPI 3.1 documents that use reusable path items +* support array-form `type` (3.1) in value validation * add `SpecValidator` with `strict_specification_version` config (`:silent` / `:warn` / `:raise`) to detect version mismatches between declared OpenAPI version and actual field usage + * `TypeArrayIn30`: detect array-form `type` usage in 3.0 documents (3.1 form) * `NullableDeprecation`: detect `nullable` usage in 3.1 documents (removed in 3.1) * `ExampleSingularDeprecation`: detect singular `example` on schemas in 3.1 documents (deprecated in 3.1) * `PathItemsIn30`: detect `components.pathItems` usage in 3.0 documents (3.1 addition) From 92d8d013c9d888cc3a365f57558209c0df3efcd0 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Fri, 17 Jul 2026 09:03:27 +0900 Subject: [PATCH 4/4] Rename NullTypeValidator to TypeMismatchValidator The validator unconditionally reports a type mismatch and now serves two dispatch paths: non-nil values against `type: "null"`, and values matching none of an array-form type's primitives. Name it for what it does rather than its original null-only use case. --- lib/openapi_parser/schema_validator.rb | 14 +++++--------- ...ype_validator.rb => type_mismatch_validator.rb} | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) rename lib/openapi_parser/schema_validator/{null_type_validator.rb => type_mismatch_validator.rb} (92%) diff --git a/lib/openapi_parser/schema_validator.rb b/lib/openapi_parser/schema_validator.rb index ba52bf2d..5ac92db3 100644 --- a/lib/openapi_parser/schema_validator.rb +++ b/lib/openapi_parser/schema_validator.rb @@ -13,7 +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/type_mismatch_validator' require_relative 'schema_validator/unspecified_type_validator' class OpenAPIParser::SchemaValidator @@ -112,7 +112,7 @@ def validator(value, schema) effective_type = schema.type if effective_type.is_a?(Array) matched = pick_array_type(value, effective_type) - return type_array_mismatch_validator if matched.nil? + return type_mismatch_validator if matched.nil? effective_type = matched end @@ -134,18 +134,14 @@ def validator(value, schema) # 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 + type_mismatch_validator else unspecified_type_validator end end - def null_type_validator - @null_type_validator ||= OpenAPIParser::SchemaValidator::NullTypeValidator.new(self, @coerce_value) - end - - def type_array_mismatch_validator - @type_array_mismatch_validator ||= OpenAPIParser::SchemaValidator::NullTypeValidator.new(self, @coerce_value) + def type_mismatch_validator + @type_mismatch_validator ||= OpenAPIParser::SchemaValidator::TypeMismatchValidator.new(self, @coerce_value) end def pick_array_type(value, types) diff --git a/lib/openapi_parser/schema_validator/null_type_validator.rb b/lib/openapi_parser/schema_validator/type_mismatch_validator.rb similarity index 92% rename from lib/openapi_parser/schema_validator/null_type_validator.rb rename to lib/openapi_parser/schema_validator/type_mismatch_validator.rb index 64c0fe49..23707cfb 100644 --- a/lib/openapi_parser/schema_validator/null_type_validator.rb +++ b/lib/openapi_parser/schema_validator/type_mismatch_validator.rb @@ -3,7 +3,7 @@ class OpenAPIParser::SchemaValidator # already decided that no primitive applies to the value, e.g.: # - schema declares `type: "null"` and the value is non-nil (3.1) # - schema declares `type: [t1, t2, ...]` and the value matches none - class NullTypeValidator < Base + class TypeMismatchValidator < Base def coerce_and_validate(value, schema, **_keyword_args) OpenAPIParser::ValidateError.build_error_result(value, schema) end