From 82382ac81ff2faf9d55c18f92e4e5930f2085d04 Mon Sep 17 00:00:00 2001 From: jonathan schatz Date: Thu, 17 Sep 2026 18:36:36 -0700 Subject: [PATCH] Support root-level $self for OpenAPI 3.2 (parse + SelfBefore32 rule) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + lib/openapi_parser/schemas/openapi.rb | 4 + lib/openapi_parser/spec_validator.rb | 2 + .../spec_validator/rules/self_before_32.rb | 20 +++++ sig/openapi_parser/spec_validator.rbs | 4 + spec/data/openapi_3_2/self_31.yaml | 14 +++ spec/data/openapi_3_2/self_32.yaml | 14 +++ .../spec_validator/integration_3_2_spec.rb | 55 ++++++++++++ .../rules/self_before_32_spec.rb | 88 +++++++++++++++++++ 9 files changed, 203 insertions(+) create mode 100644 lib/openapi_parser/spec_validator/rules/self_before_32.rb create mode 100644 spec/data/openapi_3_2/self_31.yaml create mode 100644 spec/data/openapi_3_2/self_32.yaml create mode 100644 spec/openapi_parser/spec_validator/integration_3_2_spec.rb create mode 100644 spec/openapi_parser/spec_validator/rules/self_before_32_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index e6785f8..b789529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/openapi_parser/schemas/openapi.rb b/lib/openapi_parser/schemas/openapi.rb index 5e91688..41cc545 100644 --- a/lib/openapi_parser/schemas/openapi.rb +++ b/lib/openapi_parser/schemas/openapi.rb @@ -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) diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index f7e6957..4cd7a23 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -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 @@ -79,6 +80,7 @@ def rules Rules::DynamicRefIn30, Rules::DynamicAnchorIn30, Rules::ContentSchemaIn30, + Rules::SelfBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/self_before_32.rb b/lib/openapi_parser/spec_validator/rules/self_before_32.rb new file mode 100644 index 0000000..ccd0031 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/self_before_32.rb @@ -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 diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 9f20df4..706e890 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -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 diff --git a/spec/data/openapi_3_2/self_31.yaml b/spec/data/openapi_3_2/self_31.yaml new file mode 100644 index 0000000..9596c37 --- /dev/null +++ b/spec/data/openapi_3_2/self_31.yaml @@ -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 diff --git a/spec/data/openapi_3_2/self_32.yaml b/spec/data/openapi_3_2/self_32.yaml new file mode 100644 index 0000000..be7993e --- /dev/null +++ b/spec/data/openapi_3_2/self_32.yaml @@ -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 diff --git a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb new file mode 100644 index 0000000..4519325 --- /dev/null +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -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 diff --git a/spec/openapi_parser/spec_validator/rules/self_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/self_before_32_spec.rb new file mode 100644 index 0000000..6398483 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/self_before_32_spec.rb @@ -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