From 022e2f7a25269b888614fbb3f293f84f332d2871 Mon Sep 17 00:00:00 2001 From: jonathan schatz Date: Thu, 17 Sep 2026 18:40:32 -0700 Subject: [PATCH] Detect XML nodeType in pre-3.2 documents and deprecated attribute/wrapped in 3.2 documents (XmlNodeTypeBefore32 + deprecation rules) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + lib/openapi_parser/spec_validator.rb | 6 ++ .../rules/xml_attribute_deprecation.rb | 29 +++++++ .../rules/xml_node_type_before_32.rb | 29 +++++++ .../rules/xml_wrapped_deprecation.rb | 29 +++++++ sig/openapi_parser/spec_validator.rbs | 12 +++ .../openapi_3_2/xml_deprecated_fields_31.yaml | 28 +++++++ .../openapi_3_2/xml_deprecated_fields_32.yaml | 30 +++++++ spec/data/openapi_3_2/xml_node_type_31.yaml | 23 ++++++ spec/data/openapi_3_2/xml_node_type_32.yaml | 23 ++++++ .../spec_validator/integration_3_2_spec.rb | 28 +++++++ .../rules/xml_attribute_deprecation_spec.rb | 80 +++++++++++++++++++ .../rules/xml_node_type_before_32_spec.rb | 71 ++++++++++++++++ .../rules/xml_wrapped_deprecation_spec.rb | 80 +++++++++++++++++++ 14 files changed, 470 insertions(+) create mode 100644 lib/openapi_parser/spec_validator/rules/xml_attribute_deprecation.rb create mode 100644 lib/openapi_parser/spec_validator/rules/xml_node_type_before_32.rb create mode 100644 lib/openapi_parser/spec_validator/rules/xml_wrapped_deprecation.rb create mode 100644 spec/data/openapi_3_2/xml_deprecated_fields_31.yaml create mode 100644 spec/data/openapi_3_2/xml_deprecated_fields_32.yaml create mode 100644 spec/data/openapi_3_2/xml_node_type_31.yaml create mode 100644 spec/data/openapi_3_2/xml_node_type_32.yaml create mode 100644 spec/openapi_parser/spec_validator/rules/xml_attribute_deprecation_spec.rb create mode 100644 spec/openapi_parser/spec_validator/rules/xml_node_type_before_32_spec.rb create mode 100644 spec/openapi_parser/spec_validator/rules/xml_wrapped_deprecation_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2c95c..81f269c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ * `TagFieldsBefore32`: detect Tag Object `summary` / `parent` / `kind` usage in pre-3.2 documents (3.2 additions) * `ServerNameBefore32`: detect Server Object `name` usage in pre-3.2 documents (3.2 addition) * `ExampleValueFieldsBefore32`: detect Example Object `dataValue` / `serializedValue` usage in pre-3.2 documents (3.2 additions) + * `XmlNodeTypeBefore32`: detect XML Object `nodeType` usage in pre-3.2 documents (3.2 addition) + * `XmlAttributeDeprecation` / `XmlWrappedDeprecation`: detect XML Object `attribute: true` / `wrapped: true` in 3.2 documents (deprecated in 3.2 in favor of `nodeType`) * 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 5d0df2d..b8f25ea 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -20,6 +20,9 @@ require_relative 'spec_validator/rules/tag_fields_before_32' require_relative 'spec_validator/rules/server_name_before_32' require_relative 'spec_validator/rules/example_value_fields_before_32' +require_relative 'spec_validator/rules/xml_node_type_before_32' +require_relative 'spec_validator/rules/xml_attribute_deprecation' +require_relative 'spec_validator/rules/xml_wrapped_deprecation' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -87,6 +90,9 @@ def rules Rules::TagFieldsBefore32, Rules::ServerNameBefore32, Rules::ExampleValueFieldsBefore32, + Rules::XmlNodeTypeBefore32, + Rules::XmlAttributeDeprecation, + Rules::XmlWrappedDeprecation, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/xml_attribute_deprecation.rb b/lib/openapi_parser/spec_validator/rules/xml_attribute_deprecation.rb new file mode 100644 index 0000000..aed5c13 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/xml_attribute_deprecation.rb @@ -0,0 +1,29 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.2 deprecates `attribute: true` on the XML Object in favor of + # `nodeType: attribute`. The field is still allowed but discouraged, + # so we report it as a violation on 3.2 documents. + class XmlAttributeDeprecation < Rule + def check(root) + return [] unless version_at_least?('3.2') + + violations = [] + each_schema(root) do |schema| + raw = schema.raw_schema + next unless raw.is_a?(Hash) + + xml = raw['xml'] + next unless xml.is_a?(Hash) && xml['attribute'] == true + + violations << violation( + path: schema.object_reference, + message: '`attribute` on an XML Object is deprecated in 3.2; use `nodeType: attribute`', + ) + end + violations + end + end + end + end +end diff --git a/lib/openapi_parser/spec_validator/rules/xml_node_type_before_32.rb b/lib/openapi_parser/spec_validator/rules/xml_node_type_before_32.rb new file mode 100644 index 0000000..3f139af --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/xml_node_type_before_32.rb @@ -0,0 +1,29 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.2 adds `nodeType` to the XML Object (replacing `attribute` and + # `wrapped`). XML Objects are not modeled by the parse layer, so this + # rule inspects the raw `xml` field on schemas. + class XmlNodeTypeBefore32 < Rule + def check(root) + return [] unless version_before?('3.2') + + violations = [] + each_schema(root) do |schema| + raw = schema.raw_schema + next unless raw.is_a?(Hash) + + xml = raw['xml'] + next unless xml.is_a?(Hash) && xml.key?('nodeType') + + violations << violation( + path: schema.object_reference, + message: '`nodeType` on an XML Object is a 3.2 addition; earlier documents have no such field', + ) + end + violations + end + end + end + end +end diff --git a/lib/openapi_parser/spec_validator/rules/xml_wrapped_deprecation.rb b/lib/openapi_parser/spec_validator/rules/xml_wrapped_deprecation.rb new file mode 100644 index 0000000..f910f8e --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/xml_wrapped_deprecation.rb @@ -0,0 +1,29 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.2 deprecates `wrapped: true` on the XML Object in favor of + # `nodeType: element`. The field is still allowed but discouraged, + # so we report it as a violation on 3.2 documents. + class XmlWrappedDeprecation < Rule + def check(root) + return [] unless version_at_least?('3.2') + + violations = [] + each_schema(root) do |schema| + raw = schema.raw_schema + next unless raw.is_a?(Hash) + + xml = raw['xml'] + next unless xml.is_a?(Hash) && xml['wrapped'] == true + + violations << violation( + path: schema.object_reference, + message: '`wrapped` on an XML Object is deprecated in 3.2; use `nodeType: element`', + ) + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index eb78f9e..2a5f81f 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -122,6 +122,18 @@ module OpenAPIParser NEW_FIELDS: Array[String] def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class XmlNodeTypeBefore32 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end + + class XmlAttributeDeprecation < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end + + class XmlWrappedDeprecation < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_2/xml_deprecated_fields_31.yaml b/spec/data/openapi_3_2/xml_deprecated_fields_31.yaml new file mode 100644 index 0000000..6c8fde2 --- /dev/null +++ b/spec/data/openapi_3_2/xml_deprecated_fields_31.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + xml: + # `attribute` and `wrapped` are legitimate XML Object fields + # before 3.2, so no violation is expected here. + attribute: true + tags: + type: array + items: + type: string + xml: + wrapped: true diff --git a/spec/data/openapi_3_2/xml_deprecated_fields_32.yaml b/spec/data/openapi_3_2/xml_deprecated_fields_32.yaml new file mode 100644 index 0000000..595aee2 --- /dev/null +++ b/spec/data/openapi_3_2/xml_deprecated_fields_32.yaml @@ -0,0 +1,30 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + xml: + # `attribute` on an XML Object is deprecated in 3.2 in favor of + # `nodeType: attribute`, so its use here is a spec violation. + attribute: true + tags: + type: array + items: + type: string + xml: + # `wrapped` is likewise deprecated in 3.2 in favor of + # `nodeType: element`. + wrapped: true diff --git a/spec/data/openapi_3_2/xml_node_type_31.yaml b/spec/data/openapi_3_2/xml_node_type_31.yaml new file mode 100644 index 0000000..8e729d8 --- /dev/null +++ b/spec/data/openapi_3_2/xml_node_type_31.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + xml: + name: pet + # `nodeType` on an XML Object is a 3.2 addition; a 3.1 document has + # no such field, so its use here is a spec violation. + nodeType: element diff --git a/spec/data/openapi_3_2/xml_node_type_32.yaml b/spec/data/openapi_3_2/xml_node_type_32.yaml new file mode 100644 index 0000000..7552645 --- /dev/null +++ b/spec/data/openapi_3_2/xml_node_type_32.yaml @@ -0,0 +1,23 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + xml: + name: pet + # `nodeType` is a legitimate XML Object field under 3.2, so no + # violation is expected here. + nodeType: element 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 9c0c61d..edd33fb 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -94,4 +94,32 @@ def expect_clean(file) expect_clean('example_value_fields_32.yaml') end end + + describe 'XML Object nodeType (3.2 addition)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('xml_node_type_31.yaml', [:xml_node_type_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('xml_node_type_31.yaml', [:xml_node_type_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('xml_node_type_32.yaml') + end + end + + describe 'XML Object attribute/wrapped (deprecated in 3.2)' do + it 'warns on the 3.2 document still using the deprecated fields under :warn' do + expect_mismatch_warns('xml_deprecated_fields_32.yaml', [:xml_attribute_deprecation, :xml_wrapped_deprecation]) + end + + it 'raises SpecViolationError on the 3.2 document under :raise' do + expect_mismatch_raises('xml_deprecated_fields_32.yaml', [:xml_attribute_deprecation, :xml_wrapped_deprecation]) + end + + it 'stays clean on the 3.1 document (fields legitimate before 3.2)' do + expect_clean('xml_deprecated_fields_31.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/xml_attribute_deprecation_spec.rb b/spec/openapi_parser/spec_validator/rules/xml_attribute_deprecation_spec.rb new file mode 100644 index 0000000..1472fcf --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/xml_attribute_deprecation_spec.rb @@ -0,0 +1,80 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::XmlAttributeDeprecation' do + def base_doc(openapi_version_string, sample_schema) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => sample_schema } }, + } + end + + def doc_with_attribute(openapi_version_string) + raw = base_doc(openapi_version_string, { 'type' => 'string', 'xml' => { 'attribute' => true } }) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def doc_without_attribute(openapi_version_string) + raw = base_doc(openapi_version_string, { 'type' => 'string', 'xml' => { 'nodeType' => 'attribute' } }) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::XmlAttributeDeprecation.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using xml attribute' do + it 'reports one violation pointing at the offending schema' do + root = doc_with_attribute('3.2.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 :xml_attribute_deprecation + expect(violations.first.message).to include('deprecated in 3.2') + end + end + + context 'with a 3.2 document using nodeType instead' do + it 'reports no violation' do + root = doc_without_attribute('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document using xml attribute: false' do + it 'reports no violation (only the true form is deprecated)' do + raw = base_doc('3.2.0', { 'type' => 'string', 'xml' => { 'attribute' => false } }) + root = OpenAPIParser.parse(raw, strict_reference_validation: false) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using xml attribute' do + it 'reports no violation' do + root = doc_with_attribute('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document without xml attribute' do + it 'reports no violation' do + root = doc_without_attribute('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using xml attribute' do + it 'reports no violation' do + root = doc_with_attribute('3.0.0') + 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_attribute('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end diff --git a/spec/openapi_parser/spec_validator/rules/xml_node_type_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/xml_node_type_before_32_spec.rb new file mode 100644 index 0000000..5113b15 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/xml_node_type_before_32_spec.rb @@ -0,0 +1,71 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::XmlNodeTypeBefore32' do + def base_doc(openapi_version_string, sample_schema) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => sample_schema } }, + } + end + + def doc_with_node_type(openapi_version_string) + raw = base_doc(openapi_version_string, { 'type' => 'object', 'xml' => { 'name' => 'sample', 'nodeType' => 'element' } }) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def doc_without_node_type(openapi_version_string) + raw = base_doc(openapi_version_string, { 'type' => 'object', 'xml' => { 'name' => 'sample' } }) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::XmlNodeTypeBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using xml nodeType' do + it 'reports no violation' do + root = doc_with_node_type('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without xml nodeType' do + it 'reports no violation' do + root = doc_without_node_type('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using xml nodeType' do + it 'reports one violation pointing at the offending schema' do + root = doc_with_node_type('3.1.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 :xml_node_type_before32 + end + end + + context 'with a 3.1 document without xml nodeType' do + it 'reports no violation' do + root = doc_without_node_type('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using xml nodeType' do + it 'reports one violation' do + root = doc_with_node_type('3.0.0') + expect(run_rule_for(root).size).to eq 1 + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_node_type('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end diff --git a/spec/openapi_parser/spec_validator/rules/xml_wrapped_deprecation_spec.rb b/spec/openapi_parser/spec_validator/rules/xml_wrapped_deprecation_spec.rb new file mode 100644 index 0000000..4826f03 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/xml_wrapped_deprecation_spec.rb @@ -0,0 +1,80 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::XmlWrappedDeprecation' do + def base_doc(openapi_version_string, sample_schema) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Sample' => sample_schema } }, + } + end + + def doc_with_wrapped(openapi_version_string) + raw = base_doc(openapi_version_string, { 'type' => 'array', 'items' => { 'type' => 'string' }, 'xml' => { 'wrapped' => true } }) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def doc_without_wrapped(openapi_version_string) + raw = base_doc(openapi_version_string, { 'type' => 'array', 'items' => { 'type' => 'string' }, 'xml' => { 'nodeType' => 'element' } }) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::XmlWrappedDeprecation.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using xml wrapped' do + it 'reports one violation pointing at the offending schema' do + root = doc_with_wrapped('3.2.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 :xml_wrapped_deprecation + expect(violations.first.message).to include('deprecated in 3.2') + end + end + + context 'with a 3.2 document using nodeType instead' do + it 'reports no violation' do + root = doc_without_wrapped('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document using xml wrapped: false' do + it 'reports no violation (only the true form is deprecated)' do + raw = base_doc('3.2.0', { 'type' => 'string', 'xml' => { 'wrapped' => false } }) + root = OpenAPIParser.parse(raw, strict_reference_validation: false) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using xml wrapped' do + it 'reports no violation' do + root = doc_with_wrapped('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document without xml wrapped' do + it 'reports no violation' do + root = doc_without_wrapped('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using xml wrapped' do + it 'reports no violation' do + root = doc_with_wrapped('3.0.0') + 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_wrapped('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end