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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
* `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)
* `SelfBefore32`: detect `$self` 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
* support root-level `webhooks` (OpenAPI 3.1) in the parse layer
* support `const` (OpenAPI 3.1) with exact-equality value validation
* support `contentSchema` (OpenAPI 3.1) in the parse layer
* support root-level `$self` (OpenAPI 3.2) in the parse layer (`self_uri`); it is not yet used as the base URI for `$ref` resolution

## 2.3.1 (2025-11-14)
* add optional date coercion with behavior matching existing datetime coercion
Expand Down
4 changes: 4 additions & 0 deletions lib/openapi_parser/schemas/openapi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ def openapi_version
# @return [String, nil] dialect URI for embedded JSON Schemas (OpenAPI 3.1+)
openapi_attr_value :json_schema_dialect, schema_key: :jsonSchemaDialect

# @!attribute [r] self_uri
# @return [String, nil] the document's own URI, `$self` (OpenAPI 3.2+)
openapi_attr_value :self_uri, schema_key: :'$self'

# @return [OpenAPIParser::RequestOperation, nil]
def request_operation(http_method, request_path)
OpenAPIParser::RequestOperation.create(http_method, request_path, @path_item_finder, @config)
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 @@ -16,6 +16,7 @@
require_relative 'spec_validator/rules/dynamic_ref_in_30'
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'

module OpenAPIParser
class SpecViolationError < OpenAPIError
Expand Down Expand Up @@ -79,6 +80,7 @@ def rules
Rules::DynamicRefIn30,
Rules::DynamicAnchorIn30,
Rules::ContentSchemaIn30,
Rules::SelfBefore32,
]
end
end
Expand Down
20 changes: 20 additions & 0 deletions lib/openapi_parser/spec_validator/rules/self_before_32.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module OpenAPIParser
class SpecValidator
module Rules
# `$self` is a 3.2 root-level addition declaring the document's own
# URI for reference resolution. The parse layer accepts it
# permissively; this rule reports the version mismatch.
class SelfBefore32 < Rule
def check(root)
return [] unless version_before?('3.2')
return [] unless root.raw_schema.is_a?(Hash) && root.raw_schema.key?('$self')

[violation(
path: '#/$self',
message: '`$self` is a 3.2 root-level addition; earlier documents have no such field',
)]
end
end
end
end
end
4 changes: 4 additions & 0 deletions sig/openapi_parser/spec_validator.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ module OpenAPIParser
class ContentSchemaIn30 < Rule
def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation]
end

class SelfBefore32 < Rule
def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation]
end
end
end

Expand Down
14 changes: 14 additions & 0 deletions spec/data/openapi_3_2/self_31.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
openapi: 3.1.0
# `$self` is a 3.2 root-level addition declaring the document's own URI;
# a 3.1 document has no such field, so its use here is a spec violation.
$self: https://example.com/api/openapi.yaml
info:
title: Pet API
version: '1.0'
paths:
/pets:
get:
summary: List pets
responses:
'200':
description: OK
14 changes: 14 additions & 0 deletions spec/data/openapi_3_2/self_32.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
openapi: 3.2.0
# `$self` is legitimate at the root of a 3.2 document, so no violation is
# expected here.
$self: https://example.com/api/openapi.yaml
info:
title: Pet API
version: '1.0'
paths:
/pets:
get:
summary: List pets
responses:
'200':
description: OK
55 changes: 55 additions & 0 deletions spec/openapi_parser/spec_validator/integration_3_2_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
require_relative '../../spec_helper'

RSpec.describe 'OpenAPIParser 3.2 spec validator (integration)' do
DATA_DIR_32 = './spec/data/openapi_3_2'.freeze

def load_doc(file, policy)
OpenAPIParser.load(
"#{DATA_DIR_32}/#{file}",
strict_reference_validation: false,
strict_specification_version: policy,
)
end

def capture_stderr
original = $stderr
$stderr = StringIO.new
yield
$stderr.string
ensure
$stderr = original
end

def expect_mismatch_warns(file, rule_names)
stderr = capture_stderr { load_doc(file, :warn) }
rule_names.each { |rule_name| expect(stderr).to include("[#{rule_name}]") }
expect(stderr.lines.size).to eq rule_names.size
end

def expect_mismatch_raises(file, rule_names)
expect { load_doc(file, :raise) }
.to raise_error(OpenAPIParser::SpecViolationError) do |error|
expect(error.violations.map(&:rule_name)).to match_array(rule_names)
end
end

def expect_clean(file)
expect(OpenAPIParser::SpecValidator.run(load_doc(file, :silent))).to eq []
expect { load_doc(file, :warn) }.not_to output.to_stderr
expect { load_doc(file, :raise) }.not_to raise_error
end

describe '$self (3.2 root-level document URI)' do
it 'warns on the version-mismatched document under :warn' do
expect_mismatch_warns('self_31.yaml', [:self_before32])
end

it 'raises SpecViolationError on the version-mismatched document under :raise' do
expect_mismatch_raises('self_31.yaml', [:self_before32])
end

it 'stays clean on the correctly-versioned document' do
expect_clean('self_32.yaml')
end
end
end
88 changes: 88 additions & 0 deletions spec/openapi_parser/spec_validator/rules/self_before_32_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
require_relative '../../../spec_helper'

RSpec.describe 'OpenAPIParser::SpecValidator::Rules::SelfBefore32' do
def base_doc(openapi_version_string)
{
'openapi' => openapi_version_string,
'info' => { 'title' => 'test', 'version' => '1.0' },
'paths' => {},
}
end

def doc_with_self(openapi_version_string)
raw = base_doc(openapi_version_string).merge('$self' => 'https://example.com/openapi.yaml')
OpenAPIParser.parse(raw, strict_reference_validation: false)
end

def doc_without_self(openapi_version_string)
OpenAPIParser.parse(base_doc(openapi_version_string), strict_reference_validation: false)
end

def run_rule_for(root)
OpenAPIParser::SpecValidator::Rules::SelfBefore32.new(root.openapi_version).check(root)
end

context 'with a 3.2 document using $self' do
it 'reports no violation' do
root = doc_with_self('3.2.0')
expect(run_rule_for(root)).to eq []
end
end

context 'with a 3.2 document without $self' do
it 'reports no violation' do
root = doc_without_self('3.2.0')
expect(run_rule_for(root)).to eq []
end
end

context 'with a 3.1 document using $self' do
it 'reports one violation pointing at the root field' do
root = doc_with_self('3.1.0')
violations = run_rule_for(root)
expect(violations.size).to eq 1
expect(violations.first.path).to eq '#/$self'
expect(violations.first.rule_name).to eq :self_before32
end
end

context 'with a 3.1 document without $self' do
it 'reports no violation' do
root = doc_without_self('3.1.0')
expect(run_rule_for(root)).to eq []
end
end

context 'with a 3.0 document using $self' do
it 'reports one violation' do
root = doc_with_self('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_self('not-a-version')
expect(run_rule_for(root)).to eq []
end
end
end

RSpec.describe 'OpenAPI#self_uri parse layer' do
it 'exposes $self as a String' do
raw = {
'openapi' => '3.2.0',
'info' => { 'title' => 'test', 'version' => '1.0' },
'paths' => {},
'$self' => 'https://example.com/openapi.yaml',
}
root = OpenAPIParser.parse(raw, strict_reference_validation: false)
expect(root.self_uri).to eq 'https://example.com/openapi.yaml'
end

it 'is nil when $self is absent' do
raw = { 'openapi' => '3.2.0', 'info' => { 'title' => 'test', 'version' => '1.0' }, 'paths' => {} }
root = OpenAPIParser.parse(raw, strict_reference_validation: false)
expect(root.self_uri).to be_nil
end
end