Skip to content

Commit

Permalink
Merge pull request #202 from ahx/next
Browse files Browse the repository at this point in the history
Establish request and response validation outside of middlewares
  • Loading branch information
ahx authored Jan 4, 2024
2 parents 33650bd + 78f6021 commit b51e2d5
Show file tree
Hide file tree
Showing 73 changed files with 2,186 additions and 1,760 deletions.
1 change: 0 additions & 1 deletion .tool-versions

This file was deleted.

15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,29 @@

## Unreleased

- Breaking: Moved rack middlewares to OpenapiFirst::Middlewares. Adding `OpenapiFirst::RequestValidation.new`, `OpenapiFirst::ResponseValidation.new` as shortcuts.
- Fix response header validation with Rack 3
- Add interface to validate requests / responses without middlewares (see "Manual validation" in README)
- Breaking: Rename OpenapiFirst::ResponseInvalid to OpenapiFirst::ResponseInvalidError
- Breaking: Remove OpenapiFirst::Router
- Add OpenapiFirst.configure
- Add OpenapiFirst.register, OpenapiFirst.plugin
- Breaking: Remove `env[OpenapiFirst::OPERATION]`. Use `env[OpenapiFirst::REQUEST]` instead.
- Replace `env[OpenapiFirst::REQUEST_BODY]`, `env[OpenapiFirst::PARAMS]` with `env[OpenapiFirst::REQUEST].body`, `env[OpenapiFirst::REQUEST].params`

## 1.0.0.beta6

- Fix: Make response header validation work with rack 3
- Refactor router
- Remove dependency hanami-router
- Remove dependency hanami-router
- PathItem and Operation for a request can be found by calling methods on the Definitnion
- Fixed https://github.com/ahx/openapi_first/issues/155
- Breaking / Regression: A paths like /pets/{from}-{to} if there is a path "/pets/{id}"

## 1.0.0.beta5

- Added: `OpenapiFirst::Config.default_options=` to set default options globally
- Added: You can define custom error responses by subclassing `OpenapiFirst::ErrorResponse` and register it via `OpenapiFirst::Plugins.register_error_response(name, MyCustomErrorResponse)`
- Added: You can define custom error responses by subclassing `OpenapiFirst::ErrorResponse` and register it via `OpenapiFirst.register_error_response(name, MyCustomErrorResponse)`

## 1.0.0.beta4

Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

source 'https://rubygems.org'

# Specify your gem's dependencies in openapi_first.gemspec
gemspec

gem 'rack', '>= 3.0.0'
Expand Down
17 changes: 9 additions & 8 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ PATH
specs:
openapi_first (1.0.0.beta6)
json_refs (~> 0.1, >= 0.1.7)
json_schemer (~> 2.0.0)
json_schemer (~> 2.1.0)
multi_json (~> 1.15)
mustermann-contrib (~> 3.0.0)
openapi_parameters (>= 0.3.2, < 2.0)
Expand All @@ -16,10 +16,10 @@ GEM
diff-lcs (1.5.0)
hana (1.3.7)
hansi (0.2.1)
json (2.6.3)
json (2.7.1)
json_refs (0.1.8)
hana
json_schemer (2.0.0)
json_schemer (2.1.1)
hana (~> 1.3)
regexp_parser (~> 2.0)
simpleidn (~> 0.2)
Expand All @@ -33,7 +33,7 @@ GEM
openapi_parameters (0.3.2)
rack (>= 2.2)
zeitwerk (~> 2.6)
parallel (1.23.0)
parallel (1.24.0)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
Expand All @@ -43,7 +43,7 @@ GEM
rack (>= 1.3)
rainbow (3.1.1)
rake (13.1.0)
regexp_parser (2.8.2)
regexp_parser (2.8.3)
rexml (3.2.6)
rspec (3.12.0)
rspec-core (~> 3.12.0)
Expand All @@ -58,15 +58,15 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (3.12.1)
rubocop (1.57.2)
rubocop (1.59.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.30.0)
Expand All @@ -77,12 +77,13 @@ GEM
unf (~> 0.1.4)
unf (0.1.4)
unf_ext
unf_ext (0.0.9)
unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
zeitwerk (2.6.12)

PLATFORMS
arm64-darwin-21
arm64-darwin-22
x86_64-linux

DEPENDENCIES
Expand Down
99 changes: 99 additions & 0 deletions Gemfile.rack2.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
PATH
remote: .
specs:
openapi_first (1.0.0.beta6)
json_refs (~> 0.1, >= 0.1.7)
json_schemer (~> 2.1.0)
multi_json (~> 1.15)
mustermann-contrib (~> 3.0.0)
openapi_parameters (>= 0.3.2, < 2.0)
rack (>= 2.2, < 4.0)

GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
diff-lcs (1.5.0)
hana (1.3.7)
hansi (0.2.1)
json (2.7.1)
json_refs (0.1.8)
hana
json_schemer (2.1.1)
hana (~> 1.3)
regexp_parser (~> 2.0)
simpleidn (~> 0.2)
language_server-protocol (3.17.0.3)
multi_json (1.15.0)
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
mustermann-contrib (3.0.0)
hansi (~> 0.2.0)
mustermann (= 3.0.0)
openapi_parameters (0.3.2)
rack (>= 2.2)
zeitwerk (~> 2.6)
parallel (1.24.0)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
racc (1.7.3)
rack (2.2.8)
rack-test (2.1.0)
rack (>= 1.3)
rainbow (3.1.1)
rake (13.1.0)
regexp_parser (2.8.3)
rexml (3.2.6)
rspec (3.12.0)
rspec-core (~> 3.12.0)
rspec-expectations (~> 3.12.0)
rspec-mocks (~> 3.12.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (3.12.1)
rubocop (1.59.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.30.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
simpleidn (0.2.1)
unf (~> 0.1.4)
unf (0.1.4)
unf_ext
unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
zeitwerk (2.6.12)

PLATFORMS
arm64-darwin-22
ruby
x86_64-linux

DEPENDENCIES
bundler
openapi_first!
rack (< 3.0.0)
rack-test
rake
rspec
rubocop

BUNDLED WITH
2.5.3
101 changes: 42 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,31 @@ OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.open

It provides these Rack middlewares:

- [`OpenapiFirst::RequestValidation`](#request-validation) – Validates the request against the API description and returns 400 if the request is invalid.
- [`OpenapiFirst::ResponseValidation`](#response-validation) Validates the response and raises an exception if the response body is invalid.
- [`OpenapiFirst::Router`](#openapifirstrouter) – This internal middleware is added automatically when using request/response validation. It adds the OpenAPI operation for the current request to the Rack env.
- [`OpenapiFirst::Middlewares::RequestValidation`](#request-validation) – Validates the request against the API description and returns 4xx if the request is invalid.
- [`OpenapiFirst::Middlewares::ResponseValidation`](#response-validation) Validates the response and raises an exception if the response body is invalid.

Using Request and Response validation together ensures that your implementation follows exactly the API description. This enables you to use the API description as a single source of truth for your API, reason about details and use various tooling.
Using request and response validation together ensures that your implementation follows exactly the API description. This enables you to use the API description as a single source of truth for your API, reason about details and use various tooling.

## Request Validation
## Middlewares

The `OpenapiFirst::RequestValidation` middleware returns a 400 status code with a body that describes the error if the request is not valid.
`OpenapiFirst` offers one Rack middleware for request validation and one for response validation. Both add a _request_ object to the current Rack env at `env[OpenapiFirst::REQUEST]` (or `env['openapi.request']`), which is in an instance of `OpenapiFirst::RuntimeRequest`. This gives you access to the converted query and path parameters exaclty as described in your API instead of relying on Rack alone parse the request. This only includes the parameters that are defined in the API description. It supports every [`style` and `explode` value as described](https://spec.openapis.org/oas/latest.html#style-examples) in the OpenAPI 3.0 and 3.1 specs.

### Request validation

This middleware returns a 400 status code with a body that describes the error if the request is not valid.

```ruby
use OpenapiFirst::RequestValidation, spec: 'openapi.yaml'
```

It adds these fields to the Rack env:

- `env[OpenapiFirst::PARAMS]` – The parsed parameters (query, path) for the current request (string keyed)
- `env[OpenapiFirst::REQUEST_BODY]` – The parsed request body (string keyed)
- `env[OpenapiFirst::OPERATION]` (Added via Router) – The Operation object for the current request. This is an instance of `OpenapiFirst::Operation`.

### Options and defaults
#### Options and defaults

| Name | Possible values | Description | Default |
| :---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
| `raise_error:` | `false`, `true` | If set to true the middleware raises `OpenapiFirst::RequestInvalidError` instead of returning 4xx. | `false` (don't raise an exception) |
| `error_response:` | `:default`, `:json_api`, Your implementation of `ErrorResponse` | :default |

The error responses conform with [JSON:API](https://jsonapi.org).

Here's an example response body for a missing query parameter "search":

```json
Expand All @@ -54,33 +49,6 @@ content-type: "application/json"
}
```

### Parameters

The `RequestValidation` middleware adds `env[OpenapiFirst::PARAMS]` (or `env['openapi.params']` ) with the converted query and path parameters. This only includes the parameters that are defined in the API description. It supports every [`style` and `explode` value as described](https://spec.openapis.org/oas/latest.html#style-examples) in the OpenAPI 3.0 and 3.1 specs. So you can do things these:

```ruby
# GET /pets/filter[id]=1,2,3
env[OpenapiFirst::PARAMS] # => { 'filter[id]' => [1,2,3] }

# GET /colors/.blue.black.brown?format=csv
env[OpenapiFirst::PARAMS] # => { 'color_names' => ['blue', 'black', 'brown'], 'format' => 'csv' }

# And a lot more.
```

Integration for specific webframeworks is ongoing. Don't hesitate to create an issue with you specific needs.

### Request body validation

This middleware adds the parsed request body to `env[OpenapiFirst::REQUEST_BODY]`.

The middleware will return a status `415` if the requests content type does not match or `400` if the request body is invalid.

### Header, Cookie, Query and Path parameter validation

The `RequestValidation` middleware validates the request headers, cookies and path parameters as defined in you API description. It returns a `400` status code if the request is invalid. It adds the parsed merged _path_ and _query_ parameters to `env['openapi.params']`.
Separate parsed parameters are made available by location at `env['openapi.path_params']`, `env['openapi.query']`, `env['openapi.headers']`, `env['openapi.cookies']` as well if you need to access them separately.

### readOnly / writeOnly properties

Request validation fails if request includes a property with `readOnly: true`.
Expand All @@ -89,7 +57,7 @@ Response validation fails if response body includes a property with `writeOnly:

## Response validation

The `OpenapiFirst::ResponseValidation` middleware is especially useful when testing. It _always_ raises an error if the response is not valid.
This middleware is especially useful when testing. It _always_ raises an error if the response is not valid.

```ruby
use OpenapiFirst::ResponseValidation, spec: 'openapi.yaml' if ENV['RACK_ENV'] == 'test'
Expand All @@ -101,33 +69,48 @@ use OpenapiFirst::ResponseValidation, spec: 'openapi.yaml' if ENV['RACK_ENV'] ==
| :------ | --------------- | ---------------------------------------------------------------- | ------- |
| `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |

## OpenapiFirst::Router
## Global configuration

This middleware is used automatically, but you can add it to the top of your middleware stack if you want to customize the behavior via options.
You can configure default options globally:

```ruby
use OpenapiFirst::Router, spec: './openapi/openapi.yaml'
OpenapiFirst.configure do |config|
# Specify which plugin is used to render error responses returned by the request validation middleware (defaults to :default)
config.request_validation_error_response = :json_api
# Configure if the response validation middleware should raise an exception (defaults to false)
config.request_validation_raise_error = true
end
```

This middleware adds `env['openapi.operation']` which holds an instance of `OpenapiFirst::Operation` that responds to `#operation_id`, `#path` (and `#[]` to access raw fields).
## Plugins

### Options and defaults
OpenapiFirst offers a simple plugin system. See lib/openapi_first/plugins for details. (tbd.)

| Name | Possible values | Description | Default |
| :------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` | |
| `raise_error:` | `false`, `true` | If set to true the middleware raises `OpenapiFirst::NotFoundError` when a path or method was not found in the API description. This is useful during testing to spot an incomplete API description. | `false` (don't raise an exception) |
| `not_found:` | `:continue`, `:halt` | If set to `:continue` the middleware will not return 404 (405, 415), but just pass handling the request to the next middleware or application in the Rack stack. If combined with `raise_error: true` `raise_error` gets preference and an exception is raised. | `:halt` (return 4xx response) |
## Manual validation

## Global configuration
Instead of using the middlewares you can validate the request and response manually.

```ruby
require 'openapi_first'
definition = OpenapiFirst.load('petstore.yaml')

## Request validation
definition.request(Rack::Request.new(env)).validate # returns nil if request is valid, OpenapiFirst::RequestValidation::Failure if not
# or
definition.request(Rack::Request.new(env)).validate! # returns nil if request is valid, raises an exception if not

You can configure default options gobally via `OpenapiFirst::Config`:
## Response validation
response = app.call(env)
definition.request(Rack::Request.new(env)).response(response).validate! # returns nil if request is valid, raises an exception if not
```

## Handling only certain paths

You can filter the URIs that should be handled by passing `only` to `OpenapiFirst.load`:

```ruby
OpenapiFirst::Config.default_options = {
error_response: :json_api,
request_validation_raise_error: true
}
spec = OpenapiFirst.load('./openapi/openapi.yaml', only: { |path| path.starts_with? '/pets' })
use OpenapiFirst::RequestValidation, spec: spec
```

## Alternatives
Expand Down
Loading

0 comments on commit b51e2d5

Please sign in to comment.