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) diff --git a/lib/openapi_parser/schema_validator.rb b/lib/openapi_parser/schema_validator.rb index 1b653300..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 @@ -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_mismatch_validator if matched.nil? + + effective_type = matched + end + + case effective_type when 'string' string_validator when 'integer' @@ -121,14 +134,28 @@ 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) + def type_mismatch_validator + @type_mismatch_validator ||= OpenAPIParser::SchemaValidator::TypeMismatchValidator.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 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 deleted file mode 100644 index 9aeb103a..00000000 --- a/lib/openapi_parser/schema_validator/null_type_validator.rb +++ /dev/null @@ -1,10 +0,0 @@ -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/schema_validator/type_mismatch_validator.rb b/lib/openapi_parser/schema_validator/type_mismatch_validator.rb new file mode 100644 index 00000000..23707cfb --- /dev/null +++ b/lib/openapi_parser/schema_validator/type_mismatch_validator.rb @@ -0,0 +1,11 @@ +class OpenAPIParser::SchemaValidator + # 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 TypeMismatchValidator < 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 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/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]) 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