From 6f2ee3311b87414612eede481ee725a9a844fc2a Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 12 Aug 2026 13:04:32 +0200 Subject: [PATCH 1/5] feat: add OpenAPI 3.2 support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI 3.2.0 uses the same JSON Schema dialect as 3.1 — no breaking changes, only additive features (structured tags, streaming, OAuth device flow). The version regex in Builder#detect_meta_schema now accepts 3.2.x versions and routes them through the existing 3.1 codepath. Fixes #469 Authored by: Aaron Lippold --- lib/openapi_first/builder.rb | 2 +- spec/definition_spec.rb | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index 29054e50..cce89a9c 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -51,7 +51,7 @@ def detect_meta_schema(document, filepath) # Copied from JSONSchemer 🙇🏻‍♂️ version = document['openapi'] case version - when /\A3\.1\.\d+\z/ + when /\A3\.[12]\.\d+\z/ document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s } when /\A3\.0\.\d+\z/ JSONSchemer::OpenAPI30::BASE_URI.to_s diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 0effb8a6..5ba541a2 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -28,6 +28,27 @@ def build_request(path, method: 'GET') end end + describe 'OpenAPI 3.2 support' do + it 'parses a 3.2.0 document without error' do + definition = OpenapiFirst.parse({ + 'openapi' => '3.2.0', + 'info' => { 'title' => 'Test API', 'version' => '1.0' }, + 'paths' => { + '/widgets' => { + 'get' => { + 'operationId' => 'listWidgets', + 'responses' => { + '200' => { 'description' => 'OK' } + } + } + } + } + }) + expect(definition.title).to eq('Test API') + expect(definition.paths).to eq(['/widgets']) + end + end + describe '#title' do it 'returns the title from info.title' do definition = OpenapiFirst.parse({ From 7a066d37caa971d9d71fb901e5261aa4357df0a2 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 26 May 2026 09:22:21 -0400 Subject: [PATCH 2/5] fix: add git submodule init to bin/setup bin/setup runs bundle install but doesn't initialize the spec/data/train-travel-api submodule. Developers cloning the repo and running bin/setup then bundle exec rake see 6 test failures from missing fixture files. Adding git submodule update --init --recursive to bin/setup prevents this. Authored by: Aaron Lippold --- bin/setup | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bin/setup b/bin/setup index dce67d86..fcabd548 100755 --- a/bin/setup +++ b/bin/setup @@ -3,6 +3,5 @@ set -euo pipefail IFS=$'\n\t' set -vx +git submodule update --init --recursive bundle install - -# Do any other automated setup that you need to do here From 97748c2bb75dd566f7914a7a43535849bff117e1 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 3 Jun 2026 12:02:43 -0400 Subject: [PATCH 3/5] feat: add OAS 3.2 additionalOperations support Process operations defined under path_item.additionalOperations (OAS 3.2.0 field for non-standard HTTP methods like COPY, LINK). Extract register_operation helper from the REQUEST_METHODS loop to share logic between standard methods and additionalOperations. Method keys from additionalOperations are downcased to match the router's internal UPPERCASE convention (router.route_at upcases). 3 tests: COPY method routed, GET still works, undefined LINK rejected. 570 examples, 0 failures, 100% line + branch coverage. Authored by: Aaron Lippold --- lib/openapi_first/builder.rb | 60 ++++++++++++++++++++++-------------- spec/definition_spec.rb | 44 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index cce89a9c..594af4cc 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -60,39 +60,53 @@ def detect_meta_schema(document, filepath) end end - def router # rubocop:disable Metrics/MethodLength + def router router = OpenapiFirst::Router.new @contents.fetch('paths').each do |path, path_item_object| path_parameters = path_item_object['parameters'] || [] path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method| operation_object = path_item_object[request_method] - operation_parameters = operation_object['parameters'] || [] - parameters = parse_parameters(operation_parameters.chain(path_parameters)) - - build_requests(path:, request_method:, operation_object:, - parameters:).each do |request| - router.add_request( - request, - request_method:, - path:, - content_type: request.content_type, - allow_empty_content: request.allow_empty_content? - ) - build_responses(request:, responses: operation_object['responses']).each do |response| - router.add_response( - response, - request_method:, - path:, - status: response.status, - response_content_type: response.content_type - ) - end - end + register_operation(router, path:, request_method:, operation_object:, path_parameters:) + end + + path_item_object['additionalOperations']&.each do |request_method, operation_object| + register_operation(router, path:, request_method: request_method.downcase, + operation_object:, path_parameters:) end end router end + def register_operation(router, path:, request_method:, operation_object:, path_parameters:) + operation_parameters = operation_object['parameters'] || [] + parameters = parse_parameters(operation_parameters.chain(path_parameters)) + + build_requests(path:, request_method:, operation_object:, + parameters:).each do |request| + router.add_request( + request, + request_method:, + path:, + content_type: request.content_type, + allow_empty_content: request.allow_empty_content? + ) + register_responses(router, request:, path:, request_method:, + responses: operation_object['responses']) + end + end + + def register_responses(router, request:, path:, request_method:, responses:) + build_responses(request:, responses:).each do |response| + router.add_response( + response, + request_method:, + path:, + status: response.status, + response_content_type: response.content_type + ) + end + end + def parse_parameters(parameters) grouped_parameters = group_parameters(parameters) ParsedParameters.new( diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 5ba541a2..1876cd1d 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -49,6 +49,50 @@ def build_request(path, method: 'GET') end end + describe 'OAS 3.2 additionalOperations' do + let(:definition) do + OpenapiFirst.parse({ + 'openapi' => '3.2.0', + 'info' => { 'title' => 'Test', 'version' => '1.0' }, + 'paths' => { + '/files/{id}' => { + 'get' => { + 'operationId' => 'getFile', + 'responses' => { '200' => { 'description' => 'OK' } } + }, + 'additionalOperations' => { + 'COPY' => { + 'operationId' => 'copyFile', + 'responses' => { '200' => { 'description' => 'Copied' } } + } + } + } + } + }) + end + + it 'routes requests using non-standard HTTP methods from additionalOperations' do + request = build_request('/files/123', method: 'COPY') + validated = definition.validate_request(request) + expect(validated.error).to be_nil + expect(validated.operation_id).to eq('copyFile') + end + + it 'does not break standard method routing alongside additionalOperations' do + request = build_request('/files/123', method: 'GET') + validated = definition.validate_request(request) + expect(validated.error).to be_nil + expect(validated.operation_id).to eq('getFile') + end + + it 'returns method_not_allowed for undefined additional methods' do + request = build_request('/files/123', method: 'LINK') + validated = definition.validate_request(request) + expect(validated.error).not_to be_nil + expect(validated.error.type).to eq(:method_not_allowed) + end + end + describe '#title' do it 'returns the title from info.title' do definition = OpenapiFirst.parse({ From c8b9032d44d0b14ba69702122a7bef5ad920921a Mon Sep 17 00:00:00 2001 From: Andreas Haller Date: Wed, 12 Aug 2026 13:09:53 +0200 Subject: [PATCH 4/5] Warn when loading an OpenAPI 3.2 document 3.2 documents are handled using the OpenAPI 3.1 rules, so features introduced in 3.2 may be ignored. Say so at load time instead of letting the document look fully supported. Co-Authored-By: Claude Opus 5 (1M context) --- lib/openapi_first/builder.rb | 12 ++++- spec/data/openapi-3.2.yaml | 23 +++++++++ spec/definition_spec.rb | 91 +++++++++++++++++++++++------------- 3 files changed, 92 insertions(+), 34 deletions(-) create mode 100644 spec/data/openapi-3.2.yaml diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index 594af4cc..82db85e5 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -51,8 +51,12 @@ def detect_meta_schema(document, filepath) # Copied from JSONSchemer 🙇🏻‍♂️ version = document['openapi'] case version - when /\A3\.[12]\.\d+\z/ - document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s } + when /\A3\.1\.\d+\z/ + openapi31_meta_schema(document) + when /\A3\.2\.\d+\z/ + warn "OpenAPI 3.2 is not fully supported. #{filepath || 'This API description'} is handled " \ + 'using the OpenAPI 3.1 rules, so features introduced in 3.2 may be ignored.' + openapi31_meta_schema(document) when /\A3\.0\.\d+\z/ JSONSchemer::OpenAPI30::BASE_URI.to_s else @@ -60,6 +64,10 @@ def detect_meta_schema(document, filepath) end end + def openapi31_meta_schema(document) + document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s } + end + def router router = OpenapiFirst::Router.new @contents.fetch('paths').each do |path, path_item_object| diff --git a/spec/data/openapi-3.2.yaml b/spec/data/openapi-3.2.yaml new file mode 100644 index 00000000..52ecfbda --- /dev/null +++ b/spec/data/openapi-3.2.yaml @@ -0,0 +1,23 @@ +openapi: "3.2.0" +info: + version: 1.0.0 + title: OpenAPI 3.2 example +paths: + /files/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + operationId: getFile + responses: + "200": + description: "OK" + additionalOperations: + COPY: + operationId: copyFile + responses: + "200": + description: "Copied" diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 1876cd1d..af1a5469 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -5,6 +5,14 @@ def build_request(path, method: 'GET') Rack::Request.new(Rack::MockRequest.env_for(path, method:)) end + def parse_quietly(document) + original_stderr = $stderr + $stderr = StringIO.new + OpenapiFirst.parse(document) + ensure + $stderr = original_stderr + end + describe '#config' do it 'returns a frozen configuration' do definition = OpenapiFirst.load('./spec/data/petstore.yaml') @@ -29,46 +37,65 @@ def build_request(path, method: 'GET') end describe 'OpenAPI 3.2 support' do + let(:document) do + { + 'openapi' => '3.2.0', + 'info' => { 'title' => 'Test API', 'version' => '1.0' }, + 'paths' => { + '/widgets' => { + 'get' => { + 'operationId' => 'listWidgets', + 'responses' => { + '200' => { 'description' => 'OK' } + } + } + } + } + } + end + it 'parses a 3.2.0 document without error' do - definition = OpenapiFirst.parse({ - 'openapi' => '3.2.0', - 'info' => { 'title' => 'Test API', 'version' => '1.0' }, - 'paths' => { - '/widgets' => { - 'get' => { - 'operationId' => 'listWidgets', - 'responses' => { - '200' => { 'description' => 'OK' } - } - } - } - } - }) + definition = parse_quietly(document) expect(definition.title).to eq('Test API') expect(definition.paths).to eq(['/widgets']) end + + it 'warns that OpenAPI 3.2 is not fully supported' do + expect { OpenapiFirst.parse(document) } + .to output(/OpenAPI 3.2 is not fully supported\. This API description is handled using the OpenAPI 3.1 rules/) + .to_stderr + end + + it 'names the file it was loaded from in the warning' do + expect { OpenapiFirst.load('./spec/data/openapi-3.2.yaml') } + .to output(%r{OpenAPI 3.2 is not fully supported\. \./spec/data/openapi-3\.2\.yaml is handled}).to_stderr + end + + it 'does not warn for 3.1 documents' do + expect { OpenapiFirst.parse(document.merge('openapi' => '3.1.0')) }.not_to output.to_stderr + end end describe 'OAS 3.2 additionalOperations' do let(:definition) do - OpenapiFirst.parse({ - 'openapi' => '3.2.0', - 'info' => { 'title' => 'Test', 'version' => '1.0' }, - 'paths' => { - '/files/{id}' => { - 'get' => { - 'operationId' => 'getFile', - 'responses' => { '200' => { 'description' => 'OK' } } - }, - 'additionalOperations' => { - 'COPY' => { - 'operationId' => 'copyFile', - 'responses' => { '200' => { 'description' => 'Copied' } } - } - } - } - } - }) + parse_quietly({ + 'openapi' => '3.2.0', + 'info' => { 'title' => 'Test', 'version' => '1.0' }, + 'paths' => { + '/files/{id}' => { + 'get' => { + 'operationId' => 'getFile', + 'responses' => { '200' => { 'description' => 'OK' } } + }, + 'additionalOperations' => { + 'COPY' => { + 'operationId' => 'copyFile', + 'responses' => { '200' => { 'description' => 'Copied' } } + } + } + } + } + }) end it 'routes requests using non-standard HTTP methods from additionalOperations' do From cab2f435e1ee16c433f6075ab69a1deeccf3b7f1 Mon Sep 17 00:00:00 2001 From: Andreas Haller Date: Wed, 12 Aug 2026 13:09:59 +0200 Subject: [PATCH 5/5] Changelog: OpenAPI 3.2 documents are accepted, but not fully supported Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d55047df..736c5306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added: OpenAPI 3.2 documents are accepted, but not fully supported yet. They are handled using the OpenAPI 3.1 rules, so features introduced in 3.2 may be ignored. Loading such a document prints a warning. Operations defined under `additionalOperations` are routed. See #469. - Changed: Don't hide covered endpoints in HTML coverage reporter - Added: Filter un/covered endpoints in HTML coverage reporter - Changed: Reduced memory retained by a loaded `Definition`. Response headers with a schema no longer keep the whole raw document node alive, and a couple of build-time-only hashes were replaced with more compact structures.