diff --git a/CHANGELOG.md b/CHANGELOG.md index 44792720..cc2a32c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,10 @@ * `PathItemsIn30`: detect `components.pathItems` usage in 3.0 documents (3.1 addition) * `ExclusiveMinimum` / `ExclusiveMaximum`: detect 3.0 Boolean vs 3.1 numeric form mismatch * `TypeNullIn30`: detect `type: "null"` usage in 3.0 documents (3.1 primitive) + * `WebhooksIn30`: detect root-level `webhooks` usage in 3.0 documents (3.1 addition) * 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 ## 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 af0244ca..4fa2b747 100644 --- a/lib/openapi_parser/schemas/openapi.rb +++ b/lib/openapi_parser/schemas/openapi.rb @@ -46,6 +46,10 @@ def openapi_version # @return [Info, nil] openapi_attr_object :info, Info, reference: false + # @!attribute [r] webhooks + # @return [Hash{String => PathItem}, nil] webhook path items (OpenAPI 3.1+) + openapi_attr_hash_object :webhooks, PathItem, reference: true + # @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 26068569..509afec6 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -6,6 +6,7 @@ require_relative 'spec_validator/rules/nullable_deprecation' require_relative 'spec_validator/rules/example_singular_deprecation' require_relative 'spec_validator/rules/type_null_in_30' +require_relative 'spec_validator/rules/webhooks_in_30' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -59,6 +60,7 @@ def rules Rules::NullableDeprecation, Rules::ExampleSingularDeprecation, Rules::TypeNullIn30, + Rules::WebhooksIn30, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/webhooks_in_30.rb b/lib/openapi_parser/spec_validator/rules/webhooks_in_30.rb new file mode 100644 index 00000000..c4c03508 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/webhooks_in_30.rb @@ -0,0 +1,18 @@ +module OpenAPIParser + class SpecValidator + 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 root.raw_schema.is_a?(Hash) && root.raw_schema.key?('webhooks') + + [violation( + path: '#/webhooks', + message: '`webhooks` is a 3.1 root-level addition; 3.0 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 7ecc4546..787a32f1 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -60,6 +60,10 @@ module OpenAPIParser class TypeNullIn30 < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class WebhooksIn30 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_1/webhooks_30.yaml b/spec/data/openapi_3_1/webhooks_30.yaml new file mode 100644 index 00000000..7a50102e --- /dev/null +++ b/spec/data/openapi_3_1/webhooks_30.yaml @@ -0,0 +1,20 @@ +openapi: 3.0.3 +info: + title: Billing API + version: '1.0' +paths: + /invoices: + get: + summary: List invoices + responses: + '200': + description: OK +# `webhooks` is a 3.1 root-level addition; 3.0 has no such field, so its +# presence on a 3.0 document is a spec violation. +webhooks: + invoicePaid: + post: + summary: Notify that an invoice was paid + responses: + '200': + description: Acknowledged diff --git a/spec/data/openapi_3_1/webhooks_31.yaml b/spec/data/openapi_3_1/webhooks_31.yaml new file mode 100644 index 00000000..b2011935 --- /dev/null +++ b/spec/data/openapi_3_1/webhooks_31.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Billing API + version: '1.0' +paths: + /invoices: + get: + summary: List invoices + responses: + '200': + description: OK +# `webhooks` is a legitimate root-level field in 3.1, so no violation is +# expected here. +webhooks: + invoicePaid: + post: + summary: Notify that an invoice was paid + responses: + '200': + description: Acknowledged diff --git a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb index fd386bd2..70405902 100644 --- a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb @@ -130,4 +130,17 @@ def expect_clean(file) end end + describe 'webhooks (3.1 root-level addition)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('webhooks_30.yaml', [:webhooks_in30]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('webhooks_30.yaml', [:webhooks_in30]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('webhooks_31.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/webhooks_in_30_spec.rb b/spec/openapi_parser/spec_validator/rules/webhooks_in_30_spec.rb new file mode 100644 index 00000000..a8365861 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/webhooks_in_30_spec.rb @@ -0,0 +1,84 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::WebhooksIn30' do + def base_doc(openapi_version_string) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + } + end + + def doc_with_webhooks(openapi_version_string) + raw = base_doc(openapi_version_string) + raw['webhooks'] = { + 'newPet' => { 'post' => { 'responses' => { '200' => { 'description' => 'ok' } } } }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def doc_without_webhooks(openapi_version_string) + OpenAPIParser.parse(base_doc(openapi_version_string), strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::WebhooksIn30.new(root.openapi_version).check(root) + end + + context 'with a 3.1 document declaring webhooks' do + it 'reports no violation' do + root = doc_with_webhooks('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document without webhooks' do + it 'reports no violation' do + root = doc_without_webhooks('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document declaring webhooks' do + it 'reports one violation pointing at #/webhooks' do + root = doc_with_webhooks('3.0.0') + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/webhooks' + expect(violations.first.rule_name).to eq :webhooks_in30 + end + end + + context 'with a 3.0 document without webhooks' do + it 'reports no violation' do + root = doc_without_webhooks('3.0.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with an :unknown version document declaring webhooks' do + it 'reports no violation (rule skipped)' do + root = doc_with_webhooks('4.0.0') + expect(run_rule_for(root)).to eq [] + end + end +end + +RSpec.describe 'OpenAPI#webhooks parse layer' do + let(:root) do + raw = { + 'openapi' => '3.1.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'webhooks' => { + 'newPet' => { 'post' => { 'responses' => { '200' => { 'description' => 'ok' } } } }, + }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + it 'exposes webhooks as a Hash of PathItem' do + expect(root.webhooks).to be_a(Hash) + expect(root.webhooks['newPet']).to be_a(OpenAPIParser::Schemas::PathItem) + end +end