Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/openapi_parser/spec_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +84,7 @@ def rules
Rules::ContentSchemaIn30,
Rules::SelfBefore32,
Rules::TagFieldsBefore32,
Rules::ServerNameBefore32,
]
end
end
Expand Down
10 changes: 7 additions & 3 deletions lib/openapi_parser/spec_validator/rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions lib/openapi_parser/spec_validator/rules/server_name_before_32.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions sig/openapi_parser/spec_validator.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions spec/data/openapi_3_2/server_name_31.yaml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions spec/data/openapi_3_2/server_name_32.yaml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions spec/openapi_parser/spec_validator/integration_3_2_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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