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. 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 diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index 29054e50..82db85e5 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -52,7 +52,11 @@ def detect_meta_schema(document, filepath) version = document['openapi'] case version when /\A3\.1\.\d+\z/ - document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s } + 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,39 +64,57 @@ def detect_meta_schema(document, filepath) end end - def router # rubocop:disable Metrics/MethodLength + 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| 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/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 0effb8a6..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') @@ -28,6 +36,90 @@ def build_request(path, method: 'GET') end 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 = 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 + 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 + 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({