From 9dd07b66d18132161f6d38bedab98fc8bcd810be Mon Sep 17 00:00:00 2001 From: jonathan schatz Date: Thu, 17 Sep 2026 18:37:33 -0700 Subject: [PATCH] Detect Tag Object summary/parent/kind in pre-3.2 documents (TagFieldsBefore32 rule) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + lib/openapi_parser/spec_validator.rb | 2 + .../rules/tag_fields_before_32.rb | 34 ++++++++ sig/openapi_parser/spec_validator.rbs | 5 ++ spec/data/openapi_3_2/tag_fields_31.yaml | 21 +++++ spec/data/openapi_3_2/tag_fields_32.yaml | 21 +++++ .../spec_validator/integration_3_2_spec.rb | 14 +++ .../rules/tag_fields_before_32_spec.rb | 86 +++++++++++++++++++ 8 files changed, 184 insertions(+) create mode 100644 lib/openapi_parser/spec_validator/rules/tag_fields_before_32.rb create mode 100644 spec/data/openapi_3_2/tag_fields_31.yaml create mode 100644 spec/data/openapi_3_2/tag_fields_32.yaml create mode 100644 spec/openapi_parser/spec_validator/rules/tag_fields_before_32_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b789529..b97104a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ * `DynamicAnchorIn30`: detect `$dynamicAnchor` usage in 3.0 documents (3.1 addition) * `ContentSchemaIn30`: detect `contentSchema` usage in 3.0 documents (3.1 addition) * `SelfBefore32`: detect `$self` usage in pre-3.2 documents (3.2 addition) + * `TagFieldsBefore32`: detect Tag Object `summary` / `parent` / `kind` usage in pre-3.2 documents (3.2 additions) * expose the declared version as `OpenAPI#openapi_version` (a `Gem::Version`, or nil when the field is missing or malformed) so `SpecValidator` rules compare version ranges; a 3.2 document is checked by the 3.1-or-later rules * 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 diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index 4cd7a23..ab3f8b8 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -17,6 +17,7 @@ require_relative 'spec_validator/rules/dynamic_anchor_in_30' require_relative 'spec_validator/rules/content_schema_in_30' require_relative 'spec_validator/rules/self_before_32' +require_relative 'spec_validator/rules/tag_fields_before_32' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -81,6 +82,7 @@ def rules Rules::DynamicAnchorIn30, Rules::ContentSchemaIn30, Rules::SelfBefore32, + Rules::TagFieldsBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/tag_fields_before_32.rb b/lib/openapi_parser/spec_validator/rules/tag_fields_before_32.rb new file mode 100644 index 0000000..5cfffec --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/tag_fields_before_32.rb @@ -0,0 +1,34 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.2 adds `summary`, `parent`, and `kind` to the Tag Object. + # Tags are not modeled by the parse layer, so this rule inspects the + # raw root-level `tags` array. + class TagFieldsBefore32 < Rule + NEW_FIELDS = %w[summary parent kind].freeze + + def check(root) + return [] unless version_before?('3.2') + + tags = root.raw_schema.is_a?(Hash) ? root.raw_schema['tags'] : nil + return [] unless tags.is_a?(Array) + + violations = [] + tags.each_with_index do |tag, index| + next unless tag.is_a?(Hash) + + NEW_FIELDS.each do |field| + next unless tag.key?(field) + + violations << violation( + path: "#/tags/#{index}/#{field}", + message: "`#{field}` on a Tag Object is a 3.2 addition; earlier documents have no such field", + ) + end + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 706e890..25ffb49 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -106,6 +106,11 @@ module OpenAPIParser class SelfBefore32 < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class TagFieldsBefore32 < Rule + NEW_FIELDS: Array[String] + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_2/tag_fields_31.yaml b/spec/data/openapi_3_2/tag_fields_31.yaml new file mode 100644 index 0000000..0d08315 --- /dev/null +++ b/spec/data/openapi_3_2/tag_fields_31.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +# `summary`, `parent`, and `kind` on a Tag Object are 3.2 additions; a 3.1 +# document has no such fields, so their use here is a spec violation. +tags: + - name: pets + summary: Pets + kind: nav + - name: pets/dogs + parent: pets +paths: + /pets: + get: + summary: List pets + tags: + - pets + responses: + '200': + description: OK diff --git a/spec/data/openapi_3_2/tag_fields_32.yaml b/spec/data/openapi_3_2/tag_fields_32.yaml new file mode 100644 index 0000000..3510b8d --- /dev/null +++ b/spec/data/openapi_3_2/tag_fields_32.yaml @@ -0,0 +1,21 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +# `summary`, `parent`, and `kind` are legitimate Tag Object fields under +# 3.2, so no violation is expected here. +tags: + - name: pets + summary: Pets + kind: nav + - name: pets/dogs + parent: pets +paths: + /pets: + get: + summary: List pets + tags: + - pets + responses: + '200': + description: OK diff --git a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb index 4519325..9c5bd65 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -52,4 +52,18 @@ def expect_clean(file) expect_clean('self_32.yaml') end end + + describe 'Tag Object summary/parent/kind (3.2 additions)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('tag_fields_31.yaml', [:tag_fields_before32, :tag_fields_before32, :tag_fields_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('tag_fields_31.yaml', [:tag_fields_before32, :tag_fields_before32, :tag_fields_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('tag_fields_32.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/tag_fields_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/tag_fields_before_32_spec.rb new file mode 100644 index 0000000..771320a --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/tag_fields_before_32_spec.rb @@ -0,0 +1,86 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::TagFieldsBefore32' do + def base_doc(openapi_version_string, tags) + doc = { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + } + doc['tags'] = tags if tags + doc + end + + def doc_with_tag_fields(openapi_version_string) + tags = [ + { 'name' => 'pets', 'summary' => 'Pets', 'kind' => 'nav' }, + { 'name' => 'pets/dogs', 'parent' => 'pets' }, + ] + OpenAPIParser.parse(base_doc(openapi_version_string, tags), strict_reference_validation: false) + end + + def doc_without_tag_fields(openapi_version_string) + tags = [{ 'name' => 'pets', 'description' => 'All pets' }] + OpenAPIParser.parse(base_doc(openapi_version_string, tags), strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::TagFieldsBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using the new tag fields' do + it 'reports no violation' do + root = doc_with_tag_fields('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without the new tag fields' do + it 'reports no violation' do + root = doc_without_tag_fields('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using the new tag fields' do + it 'reports one violation per offending field pointing at the tag' do + root = doc_with_tag_fields('3.1.0') + violations = run_rule_for(root) + expect(violations.size).to eq 3 + expect(violations.map(&:path)).to match_array [ + '#/tags/0/summary', + '#/tags/0/kind', + '#/tags/1/parent', + ] + expect(violations.first.rule_name).to eq :tag_fields_before32 + end + end + + context 'with a 3.1 document without the new tag fields' do + it 'reports no violation' do + root = doc_without_tag_fields('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using the new tag fields' do + it 'reports violations' do + root = doc_with_tag_fields('3.0.0') + expect(run_rule_for(root).size).to eq 3 + end + end + + context 'with a document that has no tags array' do + it 'reports no violation' do + root = OpenAPIParser.parse(base_doc('3.1.0', nil), strict_reference_validation: false) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_tag_fields('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end