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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@
# rspec failure tracking
.rspec_status
Gemfile.*.lock

# MacOS
.DS_Store
11 changes: 7 additions & 4 deletions lib/openapi_first.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,25 +55,28 @@ def self.find_error_response(name)

# Load and dereference an OpenAPI spec file or return the Definition if it's already loaded
# @param filepath_or_definition [String, Definition] The path to the file or a Definition object
# @param only [Proc, nil] An optional proc to filter paths. It is called with the path string and should return
# true/false
# @param path_prefix [String, nil] An optional path prefix, that is not documented, that all requests begin with.
# @return [Definition]
def self.load(filepath_or_definition, only: nil, &)
def self.load(filepath_or_definition, only: nil, path_prefix: nil, &)
return filepath_or_definition if filepath_or_definition.is_a?(Definition)
return self[filepath_or_definition] if filepath_or_definition.is_a?(Symbol)

filepath = filepath_or_definition
raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath)

contents = FileLoader.load(filepath)
parse(contents, only:, filepath:, &)
parse(contents, only:, filepath:, path_prefix:, &)
end

# Parse a dereferenced Hash
# @return [Definition]
# TODO: This needs to work with unresolved contents as well
def self.parse(contents, only: nil, filepath: nil, &)
def self.parse(contents, only: nil, filepath: nil, path_prefix: nil, &)
contents = ::JSON.parse(::JSON.generate(contents)) # Deeply stringify keys, because of YAML. See https://github.com/ahx/openapi_first/issues/367
contents['paths'].filter!(&->(key, _) { only.call(key) }) if only
Definition.new(contents, filepath, &)
Definition.new(contents, filepath, path_prefix, &)
end
end

Expand Down
26 changes: 15 additions & 11 deletions lib/openapi_first/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Definition

# @return [String,nil]
attr_reader :filepath
# @return [String,nil]
attr_reader :path_prefix
# @return [Configuration]
attr_reader :config
# @return [Enumerable[String]]
Expand All @@ -20,8 +22,10 @@ class Definition

# @param contents [Hash] The OpenAPI document.
# @param filepath [String] The file path of the OpenAPI document.
def initialize(contents, filepath = nil)
# @param path_prefix [String,nil] An optional path prefix, that is not documented, that all requests begin with.
def initialize(contents, filepath = nil, path_prefix = nil)
@filepath = filepath
@path_prefix = path_prefix
@config = OpenapiFirst.configuration.child
yield @config if block_given?
@config.freeze
Expand Down Expand Up @@ -79,23 +83,22 @@ def validate_request(request, raise_error: false)
end

# Validates the response against the API description.
# @param rack_request [Rack::Request] The Rack request object.
# @param rack_response [Rack::Response] The Rack response object.
# @param raise_error [Boolean] Whethir to raise an error if validation fails.
# @param request [Rack::Request] The Rack request object.
# @param response [Rack::Response] The Rack response object.
Comment on lines +86 to +87
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive by re-naming to align with what validate_request already calls them since it is less verbose

# @param raise_error [Boolean] Whether to raise an error if validation fails.
# @return [ValidatedResponse] The validated response object.
def validate_response(rack_request, rack_response, raise_error: false)
route = @router.match(rack_request.request_method, resolve_path(rack_request),
content_type: rack_request.content_type)
def validate_response(request, response, raise_error: false)
route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
return if route.error # Skip response validation for unknown requests

response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
response_match = route.match_response(status: response.status, content_type: response.content_type)
error = response_match.error
validated = if error
ValidatedResponse.new(rack_response, error:)
ValidatedResponse.new(response, error:)
else
response_match.response.validate(rack_response)
response_match.response.validate(response)
end
@config.after_response_validation&.each { |hook| hook.call(validated, rack_request, self) }
@config.after_response_validation&.each { |hook| hook.call(validated, request, self) }
raise validated.error.exception(validated) if raise_error && validated.invalid?

validated
Expand All @@ -104,6 +107,7 @@ def validate_response(rack_request, rack_response, raise_error: false)
private

def resolve_path(rack_request)
return rack_request.path.delete_prefix(path_prefix) if path_prefix && rack_request.path.start_with?(path_prefix)
return rack_request.path unless @config.path

@config.path.call(rack_request)
Expand Down
37 changes: 35 additions & 2 deletions spec/definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ def build_request(path, method: 'GET')
end
end

context 'with an alternate path used for schema matching' do
context 'with an alternate path for schema matching, from global config' do
let(:definition) do
OpenapiFirst.parse(definition_contents) do |config|
config.path = ->(req) { req.path.delete_prefix('/prefix') }
Expand All @@ -280,6 +280,21 @@ def build_request(path, method: 'GET')
expect(validated.parsed_path_parameters).to eq({ 'id' => 42 })
end
end

context 'with an alternate path for schema matching, from path_prefix' do
let(:definition) do
OpenapiFirst.parse(definition_contents, path_prefix: '/static_prefix') do |config|
config.path = ->(req) { req.path.delete_prefix('/config_prefix') }
end
end

it 'returns a valid request, and path_prefix takes precedence over config' do
request = build_request('/static_prefix/stuff/42')
validated = definition.validate_request(request)
expect(validated).to be_valid
expect(validated.parsed_path_parameters).to eq({ 'id' => 42 })
end
end
end

describe '#validate_response' do
Expand Down Expand Up @@ -368,7 +383,7 @@ def build_request(path, method: 'GET')
end
end

context 'with an alternate path used for schema matching' do
context 'with an alternate path used for schema matching, from global config' do
let(:definition) do
OpenapiFirst.parse(definition_contents) do |config|
config.path = ->(req) { req.path.delete_prefix('/prefix') }
Expand All @@ -378,6 +393,24 @@ def build_request(path, method: 'GET')
let(:response) { Rack::Response.new(JSON.generate({ 'id' => 42 }), 200, { 'Content-Type' => 'application/json' }) }

it 'returns a valid response' do
request = build_request('/prefix/stuff')
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one wasn't actually testing because it was passing a request defined above that didn't have /prefix in the path

validated = definition.validate_response(request, response)
expect(validated).to be_valid
expect(validated.parsed_body).to eq({ 'id' => 42 })
end
end

context 'with an alternate path for schema matching, from path_prefix' do
let(:definition) do
OpenapiFirst.parse(definition_contents, path_prefix: '/static_prefix') do |config|
config.path = ->(req) { req.path.delete_prefix('/config_prefix') }
end
end

let(:response) { Rack::Response.new(JSON.generate({ 'id' => 42 }), 200, { 'Content-Type' => 'application/json' }) }

it 'returns a valid response, and path_prefix takes precedence over config' do
request = build_request('/static_prefix/stuff')
validated = definition.validate_response(request, response)
expect(validated).to be_valid
expect(validated.parsed_body).to eq({ 'id' => 42 })
Expand Down
21 changes: 19 additions & 2 deletions spec/openapi_first_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
require 'rack/test'
require 'openapi_first'

# frozen_string_literal: true

RSpec.describe OpenapiFirst do
it 'has a version number' do
expect(OpenapiFirst::VERSION).not_to be nil
Expand All @@ -31,6 +29,13 @@
expect(paths).not_to include('/pets/{petId}')
end

it 'supports :path_prefix' do
hash = YAML.safe_load_file('./spec/data/petstore.yaml')
path_prefix = '/api/v1'
definition = OpenapiFirst.parse(hash, path_prefix:)
expect(definition.path_prefix).to eq(path_prefix)
end

it 'loads a Hash' do
definition = OpenapiFirst.parse(YAML.safe_load_file('./spec/data/petstore.yaml'))
expect(definition.paths).to include('/pets')
Expand Down Expand Up @@ -126,5 +131,17 @@
expect(definition.paths).to eq expected
end
end

describe 'path_prefix option' do
specify 'without a path prefix' do
definition = OpenapiFirst.load(spec_path, path_prefix: nil)
expect(definition.path_prefix).to be_nil
end

specify 'with a path prefix' do
definition = OpenapiFirst.load(spec_path, path_prefix: '/api/v1')
expect(definition.path_prefix).to eq('/api/v1')
end
end
end
end
Loading