diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index 8f52f37..89c6838 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/nullable_deprecation' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -51,6 +52,7 @@ def rules [ Rules::ExclusiveMinimum, Rules::ExclusiveMaximum, + Rules::NullableDeprecation, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/nullable_deprecation.rb b/lib/openapi_parser/spec_validator/rules/nullable_deprecation.rb new file mode 100644 index 0000000..29f7fb3 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/nullable_deprecation.rb @@ -0,0 +1,25 @@ +module OpenAPIParser + class SpecValidator + module Rules + # `nullable` is a 3.0 keyword that 3.1 removed in favor of + # `type: [..., "null"]`. The field's mere presence on a 3.1 document + # is a spec violation, regardless of true/false. + class NullableDeprecation < Rule + def check(root) + return [] unless version == :v3_1 + + violations = [] + each_schema(root) do |schema| + next unless schema.raw_schema.is_a?(Hash) && schema.raw_schema.key?('nullable') + + violations << violation( + path: schema.object_reference, + message: '`nullable` was removed in 3.1; use `type: [..., "null"]` instead', + ) + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index f680c79..fea9a7b 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -44,6 +44,10 @@ module OpenAPIParser class ExclusiveMaximum < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class NullableDeprecation < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_1/nullable_30.yaml b/spec/data/openapi_3_1/nullable_30.yaml new file mode 100644 index 0000000..42b986a --- /dev/null +++ b/spec/data/openapi_3_1/nullable_30.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.3 +info: + title: Profile API + version: '1.0' +paths: + /profiles/{id}: + get: + summary: Fetch a profile + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Profile' +components: + schemas: + Profile: + type: object + properties: + # 3.0 form: `nullable: true` is the legitimate way to allow null in + # 3.0, so no violation is expected on a 3.0 document. + nickname: + type: string + nullable: true diff --git a/spec/data/openapi_3_1/nullable_31.yaml b/spec/data/openapi_3_1/nullable_31.yaml new file mode 100644 index 0000000..afacdd8 --- /dev/null +++ b/spec/data/openapi_3_1/nullable_31.yaml @@ -0,0 +1,31 @@ +openapi: 3.1.0 +info: + title: Profile API + version: '1.0' +paths: + /profiles/{id}: + get: + summary: Fetch a profile + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Profile' +components: + schemas: + Profile: + type: object + properties: + # 3.0 form leaking into a 3.1 document: `nullable` was removed in 3.1 + # in favor of `type: [..., "null"]`, so its presence is a violation. + nickname: + type: string + nullable: true 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 3a0c4f1..143c3d3 100644 --- a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb @@ -73,4 +73,18 @@ def expect_clean(file) expect_clean('exclusive_maximum_31.yaml') end end + + describe 'nullable (3.0 keyword removed in 3.1)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('nullable_31.yaml', [:nullable_deprecation]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('nullable_31.yaml', [:nullable_deprecation]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('nullable_30.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/nullable_deprecation_spec.rb b/spec/openapi_parser/spec_validator/rules/nullable_deprecation_spec.rb new file mode 100644 index 0000000..f0d5974 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/nullable_deprecation_spec.rb @@ -0,0 +1,66 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::NullableDeprecation' do + def schema_with_nullable(openapi_version_string, nullable_value) + schema_payload = { 'type' => 'string' } + schema_payload['nullable'] = nullable_value unless nullable_value == :absent + 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::NullableDeprecation.new(root.openapi_version).check(root) + end + + context 'with a 3.0 document using nullable: true' do + it 'reports no violation' do + root = schema_with_nullable('3.0.0', true) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using nullable: false' do + it 'reports no violation' do + root = schema_with_nullable('3.0.0', false) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using nullable: true' do + it 'reports one violation pointing at the offending schema' do + root = schema_with_nullable('3.1.0', true) + 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 :nullable_deprecation + expect(violations.first.message).to include('removed in 3.1') + end + end + + context 'with a 3.1 document using nullable: false' do + it 'reports one violation (the field itself is removed in 3.1)' do + root = schema_with_nullable('3.1.0', false) + violations = run_rule_for(root) + expect(violations.size).to eq 1 + end + end + + context 'with a 3.1 document that does not use nullable' do + it 'reports no violation' do + root = schema_with_nullable('3.1.0', :absent) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with an :unknown version document' do + it 'reports no violation (rule skipped)' do + root = schema_with_nullable('4.0.0', true) + expect(run_rule_for(root)).to eq [] + end + end +end