From 61924a384f73bb524af524197f773249efe57d5d Mon Sep 17 00:00:00 2001 From: jonathan schatz Date: Thu, 17 Sep 2026 18:38:34 -0700 Subject: [PATCH] Detect Server Object name in pre-3.2 documents (ServerNameBefore32 rule) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + lib/openapi_parser/spec_validator.rb | 2 + lib/openapi_parser/spec_validator/rule.rb | 10 ++- .../rules/server_name_before_32.rb | 35 ++++++++ sig/openapi_parser/spec_validator.rbs | 5 ++ spec/data/openapi_3_2/server_name_31.yaml | 16 ++++ spec/data/openapi_3_2/server_name_32.yaml | 16 ++++ .../spec_validator/integration_3_2_spec.rb | 14 ++++ .../rules/server_name_before_32_spec.rb | 82 +++++++++++++++++++ 9 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 lib/openapi_parser/spec_validator/rules/server_name_before_32.rb create mode 100644 spec/data/openapi_3_2/server_name_31.yaml create mode 100644 spec/data/openapi_3_2/server_name_32.yaml create mode 100644 spec/openapi_parser/spec_validator/rules/server_name_before_32_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b97104a..ff5c8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ * `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) + * `ServerNameBefore32`: detect Server Object `name` usage in pre-3.2 documents (3.2 addition) * 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 ab3f8b8..b038113 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -18,6 +18,7 @@ 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' +require_relative 'spec_validator/rules/server_name_before_32' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -83,6 +84,7 @@ def rules Rules::ContentSchemaIn30, Rules::SelfBefore32, Rules::TagFieldsBefore32, + Rules::ServerNameBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rule.rb b/lib/openapi_parser/spec_validator/rule.rb index 56eae76..3007195 100644 --- a/lib/openapi_parser/spec_validator/rule.rb +++ b/lib/openapi_parser/spec_validator/rule.rb @@ -46,9 +46,13 @@ def violation(path:, message:) def each_schema(root, &block) return enum_for(:each_schema, root) unless block - visited = {} - walk(root, visited) do |node| - yield node if node.is_a?(OpenAPIParser::Schemas::Schema) + each_node(root, OpenAPIParser::Schemas::Schema, &block) + end + + # yields every parsed object reachable from root that is one of klasses + def each_node(root, *klasses) + walk(root, {}) do |node| + yield node if klasses.any? { |klass| node.is_a?(klass) } end end diff --git a/lib/openapi_parser/spec_validator/rules/server_name_before_32.rb b/lib/openapi_parser/spec_validator/rules/server_name_before_32.rb new file mode 100644 index 0000000..5850ca4 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/server_name_before_32.rb @@ -0,0 +1,35 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.2 adds `name` to the Server Object. Servers are not modeled by + # the parse layer, so this rule inspects the raw `servers` arrays on + # the root, Path Item, and Operation objects. Servers under callbacks + # and Link Objects are not reached. + class ServerNameBefore32 < Rule + def check(root) + return [] unless version_before?('3.2') + + violations = [] + node_classes = [OpenAPIParser::Schemas::OpenAPI, OpenAPIParser::Schemas::PathItem, OpenAPIParser::Schemas::Operation] + each_node(root, *node_classes) do |node| + raw = node.raw_schema + next unless raw.is_a?(Hash) + + servers = raw['servers'] + next unless servers.is_a?(Array) + + servers.each_with_index do |server, index| + next unless server.is_a?(Hash) && server.key?('name') + + violations << violation( + path: "#{node.object_reference}/servers/#{index}/name", + message: '`name` on a Server 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 25ffb49..3b0e482 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -35,6 +35,7 @@ module OpenAPIParser def version_at_least?: (String boundary) -> bool private def violation: (path: String, message: String) -> SpecViolation private def each_schema: (untyped root) ?{ (untyped) -> void } -> untyped + private def each_node: (untyped root, *Class klasses) { (untyped) -> void } -> void private def walk: (untyped node, Hash[Integer, bool] visited) { (untyped) -> void } -> void end @@ -111,6 +112,10 @@ module OpenAPIParser NEW_FIELDS: Array[String] def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class ServerNameBefore32 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_2/server_name_31.yaml b/spec/data/openapi_3_2/server_name_31.yaml new file mode 100644 index 0000000..cf7829d --- /dev/null +++ b/spec/data/openapi_3_2/server_name_31.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +# `name` on a Server Object is a 3.2 addition; a 3.1 document has no such +# field, so its use here is a spec violation. +servers: + - url: https://api.example.com + name: production +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK diff --git a/spec/data/openapi_3_2/server_name_32.yaml b/spec/data/openapi_3_2/server_name_32.yaml new file mode 100644 index 0000000..b480481 --- /dev/null +++ b/spec/data/openapi_3_2/server_name_32.yaml @@ -0,0 +1,16 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +# `name` is a legitimate Server Object field under 3.2, so no violation is +# expected here. +servers: + - url: https://api.example.com + name: production +paths: + /pets: + get: + summary: List 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 9c5bd65..43c882e 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -66,4 +66,18 @@ def expect_clean(file) expect_clean('tag_fields_32.yaml') end end + + describe 'Server Object name (3.2 addition)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('server_name_31.yaml', [:server_name_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('server_name_31.yaml', [:server_name_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('server_name_32.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/server_name_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/server_name_before_32_spec.rb new file mode 100644 index 0000000..e83b885 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/server_name_before_32_spec.rb @@ -0,0 +1,82 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::ServerNameBefore32' do + def base_doc(openapi_version_string) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => { + '/pets' => { + 'get' => { 'responses' => { '200' => { 'description' => 'OK' } } }, + }, + }, + } + end + + def doc_with_server_name(openapi_version_string) + raw = base_doc(openapi_version_string) + raw['servers'] = [{ 'url' => 'https://api.example.com', 'name' => 'production' }] + raw['paths']['/pets']['servers'] = [{ 'url' => 'https://path.example.com', 'name' => 'path' }] + raw['paths']['/pets']['get']['servers'] = [{ 'url' => 'https://alt.example.com', 'name' => 'alt' }] + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def doc_without_server_name(openapi_version_string) + raw = base_doc(openapi_version_string) + raw['servers'] = [{ 'url' => 'https://api.example.com' }] + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::ServerNameBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using server name' do + it 'reports no violation' do + root = doc_with_server_name('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without server name' do + it 'reports no violation' do + root = doc_without_server_name('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using server name at root, path item, and operation level' do + it 'reports one violation per offending server' do + root = doc_with_server_name('3.1.0') + violations = run_rule_for(root) + expect(violations.size).to eq 3 + expect(violations.map(&:path)).to match_array [ + '#/servers/0/name', + '#/paths/~1pets/servers/0/name', + '#/paths/~1pets/get/servers/0/name', + ] + expect(violations.first.rule_name).to eq :server_name_before32 + end + end + + context 'with a 3.1 document without server name' do + it 'reports no violation' do + root = doc_without_server_name('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using server name' do + it 'reports violations' do + root = doc_with_server_name('3.0.0') + expect(run_rule_for(root).size).to eq 3 + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_server_name('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end