Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions bin/setup
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 46 additions & 24 deletions lib/openapi_first/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,47 +52,69 @@ 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
raise Error, "Unsupported OpenAPI version #{version.inspect} #{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(
Expand Down
23 changes: 23 additions & 0 deletions spec/data/openapi-3.2.yaml
Original file line number Diff line number Diff line change
@@ -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"
92 changes: 92 additions & 0 deletions spec/definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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({
Expand Down
Loading