Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.1.0
3.3.12
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* `DynamicRefIn30`: detect `$dynamicRef` usage in 3.0 documents (3.1 addition)
* `DynamicAnchorIn30`: detect `$dynamicAnchor` usage in 3.0 documents (3.1 addition)
* `ContentSchemaIn30`: detect `contentSchema` usage in 3.0 documents (3.1 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
* support root-level `webhooks` (OpenAPI 3.1) in the parse layer
Expand Down
19 changes: 9 additions & 10 deletions lib/openapi_parser/schemas/openapi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,16 @@ def initialize(raw_schema, config, uri: nil, schema_registry: {})
# @return [String, nil]
openapi_attr_values :openapi

# @return [Symbol] :v3_0 / :v3_1 / :unknown
# Declared OpenAPI version as a comparable value.
# A prerelease tag is dropped ("3.1.0-rc1" => 3.1.0) because it does not
# change which version's rules the document is written against.
# @return [Gem::Version, nil] nil when the field is missing or not a
# major.minor[.patch] version string
def openapi_version
return :unknown unless openapi.is_a?(String)

if openapi.start_with?('3.0')
:v3_0
elsif openapi.start_with?('3.1')
:v3_1
else
:unknown
end
return nil unless openapi.is_a?(String)
return nil unless openapi.match?(/\A\d+\.\d+/) && Gem::Version.correct?(openapi)

Gem::Version.new(openapi).release
end

# @!attribute [r] paths
Expand Down
13 changes: 13 additions & 0 deletions lib/openapi_parser/spec_validator/rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,25 @@ def initialize(version)
@version = version
end

# @return [Gem::Version, nil] declared OpenAPI version, nil when unknown
attr_reader :version

def check(_root)
raise NotImplementedError
end

# @param [String] boundary like '3.1'
# @return [Boolean] false when the version is unknown
def version_before?(boundary)
!version.nil? && version < Gem::Version.new(boundary)
end

# @param [String] boundary like '3.1'
# @return [Boolean] false when the version is unknown
def version_at_least?(boundary)
!version.nil? && version >= Gem::Version.new(boundary)
end

private

def violation(path:, message:)
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_parser/spec_validator/rules/const_in_30.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# literal `const: null` (deliberate) still flags.
class ConstIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Rules
# 3.1. Metadata only, no runtime side-effects.
class ContentEncodingIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# report version mismatches.
class ContentMediaTypeIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# version mismatch on 3.0 documents.
class ContentSchemaIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# equivalent; the rule flags it on 3.0 documents.
class DynamicAnchorIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# value as raw schema data; this rule reports the version mismatch.
class DynamicRefIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# allowed but discouraged, so we report it as a violation.
class ExampleSingularDeprecation < Rule
def check(root)
return [] unless version == :v3_1
return [] unless version_at_least?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
17 changes: 7 additions & 10 deletions lib/openapi_parser/spec_validator/rules/exclusive_maximum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,25 @@ class SpecValidator
module Rules
class ExclusiveMaximum < Rule
def check(root)
return [] if version == :unknown
return [] if version.nil?

violations = []
each_schema(root) do |schema|
value = schema.exclusiveMaximum
next if value.nil?

case version
when :v3_0
if version_before?('3.1')
if value.is_a?(Numeric)
violations << violation(
path: schema.object_reference,
message: 'numeric exclusiveMaximum is a 3.1-only form; in 3.0 use a Boolean modifier paired with `maximum`',
)
end
when :v3_1
if value == true || value == false
violations << violation(
path: schema.object_reference,
message: 'Boolean exclusiveMaximum is a 3.0-only form; in 3.1 use a standalone numeric bound',
)
end
elsif value == true || value == false
violations << violation(
path: schema.object_reference,
message: 'Boolean exclusiveMaximum is a 3.0-only form; in 3.1 use a standalone numeric bound',
)
end
end
violations
Expand Down
17 changes: 7 additions & 10 deletions lib/openapi_parser/spec_validator/rules/exclusive_minimum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,25 @@ class SpecValidator
module Rules
class ExclusiveMinimum < Rule
def check(root)
return [] if version == :unknown
return [] if version.nil?

violations = []
each_schema(root) do |schema|
value = schema.exclusiveMinimum
next if value.nil?

case version
when :v3_0
if version_before?('3.1')
if value.is_a?(Numeric)
violations << violation(
path: schema.object_reference,
message: 'numeric exclusiveMinimum is a 3.1-only form; in 3.0 use a Boolean modifier paired with `minimum`',
)
end
when :v3_1
if value == true || value == false
violations << violation(
path: schema.object_reference,
message: 'Boolean exclusiveMinimum is a 3.0-only form; in 3.1 use a standalone numeric bound',
)
end
elsif value == true || value == false
violations << violation(
path: schema.object_reference,
message: 'Boolean exclusiveMinimum is a 3.0-only form; in 3.1 use a standalone numeric bound',
)
end
end
violations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Rules
# equivalent.
class JsonSchemaDialectIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')
return [] unless root.raw_schema.is_a?(Hash) && root.raw_schema.key?('jsonSchemaDialect')

[violation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# is a spec violation, regardless of true/false.
class NullableDeprecation < Rule
def check(root)
return [] unless version == :v3_1
return [] unless version_at_least?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# mismatch.
class PathItemsIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

components = root.components
return [] unless components
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# mismatch the validator should report.
class PrefixItemsIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Rules
# array form on a 3.0 document is a spec violation.
class TypeArrayIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_parser/spec_validator/rules/type_null_in_30.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module Rules
# `["string", "null"]` are covered by a separate rule.
class TypeNullIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')

violations = []
each_schema(root) do |schema|
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_parser/spec_validator/rules/webhooks_in_30.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Rules
# `webhooks` is a 3.1 root-level addition; 3.0 has no equivalent.
class WebhooksIn30 < Rule
def check(root)
return [] unless version == :v3_0
return [] unless version_before?('3.1')
return [] unless root.raw_schema.is_a?(Hash) && root.raw_schema.key?('webhooks')

[violation(
Expand Down
2 changes: 1 addition & 1 deletion sig/openapi_parser.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module OpenAPIParser
class OpenAPI
def initialize: (Hash[bot, bot] hash, untyped config, uri: OpenAPIParser::readable_uri?, schema_registry: Hash[bot, bot]) -> OpenAPIParser::Schemas::OpenAPI
def openapi: () -> String?
def openapi_version: () -> (:v3_0 | :v3_1 | :unknown)
def openapi_version: () -> Gem::Version?
end
end
end
10 changes: 6 additions & 4 deletions sig/openapi_parser/spec_validator.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ module OpenAPIParser

class SpecValidator
@root: OpenAPIParser::Schemas::OpenAPI
@version: (:v3_0 | :v3_1 | :unknown)
@version: Gem::Version?

def self.run: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecViolation]
def self.run!: (OpenAPIParser::Schemas::OpenAPI root, policy: (:silent | :warn | :raise)) -> void
Expand All @@ -25,12 +25,14 @@ module OpenAPIParser
end

class Rule
@version: (:v3_0 | :v3_1 | :unknown)
attr_reader version: (:v3_0 | :v3_1 | :unknown)
@version: Gem::Version?
attr_reader version: Gem::Version?

def self.rule_name: () -> Symbol
def initialize: ((:v3_0 | :v3_1 | :unknown) version) -> void
def initialize: (Gem::Version? version) -> void
def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecViolation]
def version_before?: (String boundary) -> bool
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 walk: (untyped node, Hash[Integer, bool] visited) { (untyped) -> void } -> void
Expand Down
50 changes: 31 additions & 19 deletions spec/openapi_parser/schemas/open_api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,50 +30,62 @@ def parse_with_openapi_field(value, present: true)
end

context 'with a typical 3.0.x version like "3.0.0"' do
it 'returns :v3_0' do
expect(parse_with_openapi_field('3.0.0').openapi_version).to eq :v3_0
it 'returns Gem::Version 3.0.0' do
expect(parse_with_openapi_field('3.0.0').openapi_version).to eq Gem::Version.new('3.0.0')
end
end

context 'with a typical 3.1.x version like "3.1.0"' do
it 'returns :v3_1' do
expect(parse_with_openapi_field('3.1.0').openapi_version).to eq :v3_1
it 'returns Gem::Version 3.1.0' do
expect(parse_with_openapi_field('3.1.0').openapi_version).to eq Gem::Version.new('3.1.0')
end
end

context 'with a minor-only version "3.0"' do
it 'returns :v3_0' do
expect(parse_with_openapi_field('3.0').openapi_version).to eq :v3_0
context 'with a 3.2.x version like "3.2.0"' do
it 'returns Gem::Version 3.2.0' do
expect(parse_with_openapi_field('3.2.0').openapi_version).to eq Gem::Version.new('3.2.0')
end
end

context 'with a minor-only version "3.1"' do
it 'returns :v3_1' do
expect(parse_with_openapi_field('3.1').openapi_version).to eq :v3_1
it 'returns a version equal to 3.1.0' do
expect(parse_with_openapi_field('3.1').openapi_version).to eq Gem::Version.new('3.1.0')
end
end

context 'with a prerelease tag like "3.0.0-rc1"' do
it 'returns :v3_0 by prefix match' do
expect(parse_with_openapi_field('3.0.0-rc1').openapi_version).to eq :v3_0
context 'with a prerelease tag like "3.1.0-rc1"' do
it 'returns the release version 3.1.0' do
expect(parse_with_openapi_field('3.1.0-rc1').openapi_version).to eq Gem::Version.new('3.1.0')
end
end

context 'with an unknown major version like "4.0.0"' do
it 'returns :unknown' do
expect(parse_with_openapi_field('4.0.0').openapi_version).to eq :unknown
context 'with a major version beyond 3 like "4.0.0"' do
it 'returns Gem::Version 4.0.0 without special-casing it' do
expect(parse_with_openapi_field('4.0.0').openapi_version).to eq Gem::Version.new('4.0.0')
end
end

context 'when the openapi field is missing' do
it 'returns :unknown' do
expect(parse_with_openapi_field(nil, present: false).openapi_version).to eq :unknown
it 'returns nil' do
expect(parse_with_openapi_field(nil, present: false).openapi_version).to be_nil
end
end

context 'with a non-string openapi field' do
it 'returns :unknown' do
expect(parse_with_openapi_field(31).openapi_version).to eq :unknown
it 'returns nil' do
expect(parse_with_openapi_field(31).openapi_version).to be_nil
end
end

context 'with a string that is not a version like "three"' do
it 'returns nil' do
expect(parse_with_openapi_field('three').openapi_version).to be_nil
end
end

context 'with a major-only version "3"' do
it 'returns nil (OpenAPI versions are at least major.minor)' do
expect(parse_with_openapi_field('3').openapi_version).to be_nil
end
end
end
Expand Down
Loading