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
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ You can use openapi_first on production for [request validation](#request-valida
- [Configuration](#configuration)
- [Hooks](#hooks)
- [Alternatives](#alternatives)
- [Frequently Asked Questions](#frequently-asked-questions)
- [Development](#development)
- [Benchmarks](#benchmarks)
- [Contributing](#contributing)
Expand Down Expand Up @@ -327,6 +328,74 @@ That aside, closer integration with specific frameworks like Sinatra, Hanami, Ro
This gem was inspired by [committee](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
Here is a [feature comparison between openapi_first and committee](https://gist.github.com/ahx/1538c31f0652f459861713b5259e366a).

## Frequently Asked Questions

### How can I adapt request paths that don't match my schema?

If your API is deployed at a different path than what's defined in your OpenAPI schema, you can use `env[OpenapiFirst::PATH]` to override the path used for schema matching.

Let's say you have `openapi.yaml` like this:

```yaml
servers:
- url: https://yourhost/api
paths:
# The actual endpoint URL is https://yourhost/api/resource
/resource:
```

Here your OpenAPI schema defines endpoints starting with `/resource` but your actual application is mounted at `/api/resource`. You can bridge the gap by transforming the path via the `path:` configuration:

```ruby
oad = OpenapiFirst.load('openapi.yaml') do |config|
config.path = ->(req) { request.path.delete_prefix('/api') }
end
# Add your custom middleware
use OpenapiFirst::Middlewares::RequestValidation, oad

# You can add ResponseValidation without any customization.
use OpenapiFirst::Middlewares::ResponseValidation, 'openapi.yaml'
```

In this case, you might want to serve APIs on `/api` while serving rendered pages on other paths which are not managed by OpenAPI schema in a single application.

You can add some lines to selectively validate only paths under `/api` while bypassing others:

```diff
env[OpenapiFirst::PATH] = request.path.to_s.sub(%r"^/api", "")

+ # Only validate paths under /api/
+ if request.path.start_with?('/api/')
super
+ else
+ @app.call(env)
+ end
end
```

And the final code is:

```ruby
class CustomOpenAPIValidation < OpenapiFirst::Middlewares::RequestValidation
def call(env)
request = Rack::Request.new(env)

# Strip the "/api" prefix for schema matching
env[OpenapiFirst::PATH] = request.path.to_s.sub(%r"^/api", "")

# Only validate paths under /api/
if request.path.start_with?('/api/')
super
else
@app.call(env)
end
end
end

use CustomOpenAPIValidation, 'openapi.yaml'
use OpenapiFirst::Middlewares::ResponseValidation, 'openapi.yaml'
```

## Development

Run `bin/setup` to install dependencies.
Expand Down
3 changes: 2 additions & 1 deletion lib/openapi_first/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ def initialize
@request_validation_raise_error = false
@response_validation_raise_error = true
@hooks = (HOOKS.map { [_1, Set.new] }).to_h
@path = nil
end

attr_reader :request_validation_error_response, :hooks
attr_accessor :request_validation_raise_error, :response_validation_raise_error
attr_accessor :request_validation_raise_error, :response_validation_raise_error, :path

def clone
copy = super
Expand Down
4 changes: 3 additions & 1 deletion lib/openapi_first/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ def validate_response(rack_request, rack_response, raise_error: false)
private

def resolve_path(rack_request)
rack_request.env[PATH] || rack_request.path
return rack_request.path unless @config.path

@config.path.call(rack_request)
end
end
end
112 changes: 61 additions & 51 deletions spec/definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,26 +70,30 @@ def build_request(path, method: 'GET')
end

describe '#validate_request' do
let(:definition_contents) do
{
'openapi' => '3.1.0',
'paths' => {
'/stuff/{id}' => {
'get' => {
'parameters' => [
{
'name' => 'id',
'in' => 'path',
'required' => true,
'schema' => {
'type' => 'integer'
}
}
]
}
}
}
}
end

let(:definition) do
OpenapiFirst.parse({
'openapi' => '3.1.0',
'paths' => {
'/stuff/{id}' => {
'get' => {
'parameters' => [
{
'name' => 'id',
'in' => 'path',
'required' => true,
'schema' => {
'type' => 'integer'
}
}
]
}
}
}
})
OpenapiFirst.parse(definition_contents)
end

context 'when request is valid' do
Expand Down Expand Up @@ -256,13 +260,14 @@ def build_request(path, method: 'GET')
end

context 'with an alternate path used for schema matching' do
let(:request) do
build_request('/prefix/stuff/42').tap do |req|
req.env[OpenapiFirst::PATH] = '/stuff/42'
let(:definition) do
OpenapiFirst.parse(definition_contents) do |config|
config.path = ->(req) { req.path.delete_prefix('/prefix') }
end
end

it 'returns a valid request' do
request = build_request('/prefix/stuff/42')
validated = definition.validate_request(request)
expect(validated).to be_valid
expect(validated.parsed_path_parameters).to eq({ 'id' => 42 })
Expand All @@ -271,33 +276,37 @@ def build_request(path, method: 'GET')
end

describe '#validate_response' do
let(:definition_contents) do
{
'openapi' => '3.1.0',
'paths' => {
'/stuff' => {
'get' => {
'responses' => {
'200' => {
'description' => 'OK',
'content' => {
'application/json' => {
'schema' => {
'type' => 'object',
'properties' => {
'id' => {
'type' => 'integer'
}
}
}
}
}
}
}
}
}
}
}
end

let(:definition) do
OpenapiFirst.parse({
'openapi' => '3.1.0',
'paths' => {
'/stuff' => {
'get' => {
'responses' => {
'200' => {
'description' => 'OK',
'content' => {
'application/json' => {
'schema' => {
'type' => 'object',
'properties' => {
'id' => {
'type' => 'integer'
}
}
}
}
}
}
}
}
}
}
})
OpenapiFirst.parse(definition_contents)
end

let(:request) { build_request('/stuff') }
Expand Down Expand Up @@ -353,11 +362,12 @@ def build_request(path, method: 'GET')
end

context 'with an alternate path used for schema matching' do
let(:request) do
build_request('/prefix/stuff/42').tap do |req|
req.env[OpenapiFirst::PATH] = '/stuff'
let(:definition) do
OpenapiFirst.parse(definition_contents) do |config|
config.path = ->(req) { req.path.delete_prefix('/prefix') }
end
end

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

it 'returns a valid response' do
Expand Down