openapi_first 1.4.2 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -1
- data/README.md +105 -28
- data/lib/openapi_first/body_parser.rb +8 -11
- data/lib/openapi_first/builder.rb +81 -0
- data/lib/openapi_first/configuration.rb +24 -3
- data/lib/openapi_first/definition.rb +44 -100
- data/lib/openapi_first/error_response.rb +2 -2
- data/lib/openapi_first/error_responses/default.rb +73 -0
- data/lib/openapi_first/error_responses/jsonapi.rb +59 -0
- data/lib/openapi_first/errors.rb +26 -4
- data/lib/openapi_first/failure.rb +29 -26
- data/lib/openapi_first/json_refs.rb +1 -3
- data/lib/openapi_first/middlewares/request_validation.rb +2 -2
- data/lib/openapi_first/middlewares/response_validation.rb +4 -3
- data/lib/openapi_first/request.rb +92 -0
- data/lib/openapi_first/request_parser.rb +35 -0
- data/lib/openapi_first/request_validator.rb +25 -0
- data/lib/openapi_first/response.rb +57 -0
- data/lib/openapi_first/response_parser.rb +49 -0
- data/lib/openapi_first/response_validator.rb +27 -0
- data/lib/openapi_first/router/find_content.rb +17 -0
- data/lib/openapi_first/router/find_response.rb +45 -0
- data/lib/openapi_first/{definition → router}/path_template.rb +9 -1
- data/lib/openapi_first/router.rb +100 -0
- data/lib/openapi_first/schema/validation_error.rb +16 -10
- data/lib/openapi_first/schema/validation_result.rb +8 -6
- data/lib/openapi_first/schema.rb +4 -8
- data/lib/openapi_first/test/methods.rb +21 -0
- data/lib/openapi_first/test.rb +19 -0
- data/lib/openapi_first/validated_request.rb +81 -0
- data/lib/openapi_first/validated_response.rb +33 -0
- data/lib/openapi_first/validators/request_body.rb +39 -0
- data/lib/openapi_first/validators/request_parameters.rb +61 -0
- data/lib/openapi_first/validators/response_body.rb +30 -0
- data/lib/openapi_first/validators/response_headers.rb +25 -0
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +40 -21
- metadata +35 -24
- data/lib/openapi_first/definition/operation.rb +0 -197
- data/lib/openapi_first/definition/path_item.rb +0 -40
- data/lib/openapi_first/definition/request_body.rb +0 -46
- data/lib/openapi_first/definition/response.rb +0 -32
- data/lib/openapi_first/definition/responses.rb +0 -87
- data/lib/openapi_first/plugins/default/error_response.rb +0 -74
- data/lib/openapi_first/plugins/default.rb +0 -11
- data/lib/openapi_first/plugins/jsonapi/error_response.rb +0 -60
- data/lib/openapi_first/plugins/jsonapi.rb +0 -11
- data/lib/openapi_first/plugins.rb +0 -25
- data/lib/openapi_first/request_validation/request_body_validator.rb +0 -41
- data/lib/openapi_first/request_validation/validator.rb +0 -82
- data/lib/openapi_first/response_validation/validator.rb +0 -98
- data/lib/openapi_first/runtime_request.rb +0 -166
- data/lib/openapi_first/runtime_response.rb +0 -124
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d712aa1e9c42b4d42bc048fa0cc5c793af9d8e5c4f3afd34deda3afe2136eb70
|
4
|
+
data.tar.gz: 8a220208d4eb6755a066db142f20271101c97e7c7c26e000d0519770300c0f39
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d5424ea246ae643962375a22adddac25ec2de49222aed0a3e6cabc4d8efbbd49faf65e569c219a758b62261a085afd06ee9b6c75f453f0c30397610e9460a793
|
7
|
+
data.tar.gz: 9599b92d69766a231c7a24f708cabfcaff5f3724117b3f1948d1b9e76c1408f72bafceebaf17752a30e58d752375b807de57ab53dad38abf84720f192c5a3180
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,51 @@
|
|
2
2
|
|
3
3
|
## Unreleased
|
4
4
|
|
5
|
+
## 2.0
|
6
|
+
|
7
|
+
### New Features
|
8
|
+
- Test Assertions! 📋 You can now use `assert_api_conform` for contract testing in your rack-test / Rails integration tests. See Readme for details.
|
9
|
+
|
10
|
+
- New option for `Middlewares::ResponseValidation`: `:raise_error` (default: true). If set to `false`, the middleware will not aise an error if the response is invalid. 🤫
|
11
|
+
|
12
|
+
- Hooks 🪝🪝 (see Readme for details). You can use these to collect metrics, write error logs etc.:
|
13
|
+
- `after_request_validation`
|
14
|
+
- `after_response_validation`
|
15
|
+
- `after_request_body_property_validation`
|
16
|
+
- `after_request_parameter_property_validation`
|
17
|
+
|
18
|
+
- Exceptions such as `OpenapiFirst::ResponseInvalidError` not respond to `#request` to get information about the validated request 💁🏻
|
19
|
+
|
20
|
+
- Performance improvements 🚴🏻♀️
|
21
|
+
|
22
|
+
- Validation failures returned by `ValidatedRequest#error` always returns a `#message`. So you can call `my_validated_request.error.message if validated_request.invalid?` and always get a human-readable error message. 😴
|
23
|
+
|
24
|
+
### Breaking Changes
|
25
|
+
|
26
|
+
#### Manual validation
|
27
|
+
- `Definition#request.validate` was removed. Please use `Definition#validate_request` instead.
|
28
|
+
- `Definition#validate_request` returns a `ValidatedRequest` which delgates all methods to the original (rack) request, except for `#valid?` `#parsed_body`. `#parsed_query`, `#operation` etc. See Readme for details.
|
29
|
+
- The `Operation` class was removed. `ValidatedRequest#operation` now returns the OpenAPI 3 operation object as a plain Hash. So you can still call `ValidatedRequest#operation['x-foo']`. You can call `ValidatedRequest#operation_id` if you just need the _operationId_.
|
30
|
+
-
|
31
|
+
|
32
|
+
#### Inspecting OpenAPI files
|
33
|
+
|
34
|
+
- `Definition#operations` has been removed. Please use `Definition#routes`, which returns a list of routes. Routes have a `#path`, `#request_method`, `#requests` and `#responses`.
|
35
|
+
A route has one path and one request method, but can have multiple requests (one for each supported content-type) and responses (statuses + content-type).
|
36
|
+
|
37
|
+
- Several internal changes to make the code more maintainable, more performant , support hooks and prepare for OpenAPI 4. If you have monkey-patched OpenapiFirst, you might need to adjust your code. Please contact me if you need help.
|
38
|
+
|
39
|
+
### Deprecations
|
40
|
+
|
41
|
+
#### Custom error responses
|
42
|
+
|
43
|
+
- `ValidationError#error`, `#instance_location` and `#schema_location` have been deprecated. Use `ValidationError#message`, `#data_pointer` and `#schema_pointer` instead.
|
44
|
+
- `Failure#error_type` has been deprecated. Use `#type` instead
|
45
|
+
|
46
|
+
## 1.4.3
|
47
|
+
|
48
|
+
- Allow using json_schemer 2...3
|
49
|
+
|
5
50
|
## 1.4.2
|
6
51
|
|
7
52
|
- Fix Rack 2 compatibility
|
@@ -23,7 +68,7 @@ Some redundant methods to validate or inspect requests/responses will be removed
|
|
23
68
|
|
24
69
|
## 1.3.6
|
25
70
|
|
26
|
-
-
|
71
|
+
- Fixed Rack 2 / Rails 6 compatibility ([#246](https://github.com/ahx/openapi_first/issues/246)
|
27
72
|
|
28
73
|
## 1.3.5
|
29
74
|
|
@@ -33,6 +78,7 @@ Some redundant methods to validate or inspect requests/responses will be removed
|
|
33
78
|
|
34
79
|
- Fixed handling "binary" format in optional multipart file uploads
|
35
80
|
- Cache the resolved OAD. This especially makes things run faster in tests.
|
81
|
+
- Internally used `Operation#query_parameters`, `Operation#path_parameters` etc. now only returns parameters that are defined on the operation level not on the PathItem. Use `PathItem#query_parameters` to get those.
|
36
82
|
|
37
83
|
## 1.3.3 (yanked)
|
38
84
|
|
data/README.md
CHANGED
@@ -9,11 +9,13 @@ OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.open
|
|
9
9
|
- [Rack Middlewares](#rack-middlewares)
|
10
10
|
- [Request validation](#request-validation)
|
11
11
|
- [Response validation](#response-validation)
|
12
|
+
- [Test assertions](#test-assertions)
|
12
13
|
- [Manual use](#manual-use)
|
13
14
|
- [Validate request](#validate-request)
|
14
15
|
- [Validate response](#validate-response)
|
15
|
-
- [Configuration](#configuration)
|
16
16
|
- [Framework integration](#framework-integration)
|
17
|
+
- [Configuration](#configuration)
|
18
|
+
- [Hooks](#hooks)
|
17
19
|
- [Alternatives](#alternatives)
|
18
20
|
- [Development](#development)
|
19
21
|
- [Benchmarks](#benchmarks)
|
@@ -23,13 +25,9 @@ OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.open
|
|
23
25
|
|
24
26
|
## Rack Middlewares
|
25
27
|
|
26
|
-
All middlewares add a _request_ object to the current Rack env at `env[OpenapiFirst::REQUEST]`), which is in an instance of `OpenapiFirst::RuntimeRequest` that responds to `.params`, `.parsed_body` etc.
|
27
|
-
|
28
|
-
This gives you access to the converted request parameters and body exaclty as described in your API description instead of relying on Rack alone to parse the request. This only includes query 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.
|
29
|
-
|
30
28
|
### Request validation
|
31
29
|
|
32
|
-
The request validation middleware returns a 4xx if the request is invalid or not defined in the API description.
|
30
|
+
The request validation middleware returns a 4xx if the request is invalid or not defined in the API description. It adds a request object to the current Rack environment at `env[OpenapiFirst::REQUEST]` with the request parameters parsed exaclty as described in your API description plus access to meta information from your API description. See _[Manual use](#manual-use)_ for more details about that object.
|
33
31
|
|
34
32
|
```ruby
|
35
33
|
use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml'
|
@@ -123,6 +121,7 @@ use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml, error_res
|
|
123
121
|
#### Custom error responses
|
124
122
|
|
125
123
|
You can build your own custom error response with `error_response: MyCustomClass` that implements `OpenapiFirst::ErrorResponse`.
|
124
|
+
You can define custom error responses globally by including / implementing `OpenapiFirst::ErrorResponse` and register it via `OpenapiFirst.register_error_response(my_name, MyCustomErrorResponse)` and set `error_response: my_name`.
|
126
125
|
|
127
126
|
#### readOnly / writeOnly properties
|
128
127
|
|
@@ -132,7 +131,7 @@ Response validation fails if response body includes a property with `writeOnly:
|
|
132
131
|
|
133
132
|
### Response validation
|
134
133
|
|
135
|
-
This middleware is especially useful when testing. It
|
134
|
+
This middleware is especially useful when testing. It raises an error by default if the response is not valid.
|
136
135
|
|
137
136
|
```ruby
|
138
137
|
use OpenapiFirst::Middlewares::ResponseValidation, spec: 'openapi.yaml' if ENV['RACK_ENV'] == 'test'
|
@@ -143,6 +142,37 @@ use OpenapiFirst::Middlewares::ResponseValidation, spec: 'openapi.yaml' if ENV['
|
|
143
142
|
| Name | Possible values | Description |
|
144
143
|
| :------ | --------------- | ---------------------------------------------------------------- |
|
145
144
|
| `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
|
145
|
+
| `raise_error:` | `true` (default), `false` | If set to true the middleware raises `OpenapiFirst::ResponseInvalidError` or `OpenapiFirst::ResonseNotFoundError` if the response does not match the API description. |
|
146
|
+
|
147
|
+
## Test assertions
|
148
|
+
|
149
|
+
openapi_first ships with a simple but powerful Test module to run request and response validation in your tests without using the middlewares. This is designed to be used with rack-test or Ruby on Rails integration tests or request specs.
|
150
|
+
|
151
|
+
Here is how to set it up for Rails integration tests:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
# test_helper.rb
|
155
|
+
require 'openapi_first/test'
|
156
|
+
OpenapiFirst::Test.register('openapi/v1.openapi.yaml')
|
157
|
+
```
|
158
|
+
|
159
|
+
Inside your test:
|
160
|
+
```ruby
|
161
|
+
# test/integration/trips_api_test.rb
|
162
|
+
require 'test_helper'
|
163
|
+
|
164
|
+
class TripsApiTest < ActionDispatch::IntegrationTest
|
165
|
+
include OpenapiFirst::Test::Methods
|
166
|
+
|
167
|
+
test 'GET /trips' do
|
168
|
+
get '/trips',
|
169
|
+
params: { origin: 'efdbb9d1-02c2-4bc3-afb7-6788d8782b1e', destination: 'b2e783e1-c824-4d63-b37a-d8d698862f1d',
|
170
|
+
date: '2024-07-02T09:00:00Z' }
|
171
|
+
|
172
|
+
assert_api_conform(status: 200)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
```
|
146
176
|
|
147
177
|
## Manual use
|
148
178
|
|
@@ -159,23 +189,28 @@ definition = OpenapiFirst.load('openapi.yaml')
|
|
159
189
|
```ruby
|
160
190
|
# Find and validate request
|
161
191
|
rack_request = Rack::Request.new(env)
|
162
|
-
|
192
|
+
validated_request = definition.validate_request(rack_request)
|
163
193
|
# Or raise an exception if validation fails:
|
164
|
-
|
194
|
+
definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
|
165
195
|
|
166
196
|
# Inspect the request and access parsed parameters
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
197
|
+
validated_request.known? # Is the request defined in the API description?
|
198
|
+
validated_request.valid? # => true / false
|
199
|
+
validated_request.invalid? # => true / false
|
200
|
+
validated_request.error # => Failure object if request is invalid
|
201
|
+
validated_request.parsed_params # Merged parsed path, query parameters and request body
|
202
|
+
validated_request.parsed_body
|
203
|
+
validated_request.parsed_path_parameters # => { "pet_id" => 42 }
|
204
|
+
validated_request.parsed_headers
|
205
|
+
validated_request.parsed_cookies
|
206
|
+
validated_request.parsed_query
|
207
|
+
|
208
|
+
# Access the Openapi 3 Operation Object Hash
|
209
|
+
validated_request.operation['x-foo']
|
210
|
+
validated_request.operation['operationId']
|
211
|
+
# or the whole request definition
|
212
|
+
validated_request.request_definition.path # => "/pets/{petId}"
|
213
|
+
validated_request.request_definition.operation_id # => "showPetById"
|
179
214
|
```
|
180
215
|
|
181
216
|
### Validate response
|
@@ -183,21 +218,19 @@ request.path # => "/pets/42"
|
|
183
218
|
```ruby
|
184
219
|
# Find and validate the response
|
185
220
|
rack_response = Rack::Response[*app.call(env)]
|
186
|
-
|
221
|
+
validated_response = definition.validate_response(rack_request, rack_response)
|
187
222
|
|
188
223
|
# Raise an exception if validation fails:
|
189
|
-
|
190
|
-
# Or you can also call a method on the request object mentioned above
|
191
|
-
request.validate_response(rack_response)
|
224
|
+
definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
|
192
225
|
|
193
226
|
# Inspect the response and access parsed parameters and
|
194
227
|
response.known? # Is the response defined in the API description?
|
195
228
|
response.valid? # => true / false
|
229
|
+
response.invalid? # => true / false
|
196
230
|
response.error # => Failure object if response is invalid
|
197
|
-
response.body
|
198
|
-
request.headers
|
199
231
|
response.status # => 200
|
200
|
-
response.
|
232
|
+
response.parsed_body
|
233
|
+
response.parsed_headers
|
201
234
|
```
|
202
235
|
|
203
236
|
OpenapiFirst uses [`multi_json`](https://rubygems.org/gems/multi_json).
|
@@ -215,6 +248,50 @@ OpenapiFirst.configure do |config|
|
|
215
248
|
end
|
216
249
|
```
|
217
250
|
|
251
|
+
or configure per instance:
|
252
|
+
|
253
|
+
```ruby
|
254
|
+
OpenapiFirst.load('openapi.yaml') do |config|
|
255
|
+
config.request_validation_error_response = :jsonapi
|
256
|
+
end
|
257
|
+
```
|
258
|
+
|
259
|
+
## Hooks
|
260
|
+
|
261
|
+
You can integrate your code at certain points during request/response validation via hooks.
|
262
|
+
|
263
|
+
Available hooks:
|
264
|
+
|
265
|
+
- `after_request_validation`
|
266
|
+
- `after_response_validation`
|
267
|
+
- `after_request_parameter_property_validation`
|
268
|
+
- `after_request_body_property_validation`
|
269
|
+
|
270
|
+
Setup per per instance:
|
271
|
+
|
272
|
+
```ruby
|
273
|
+
OpenapiFirst.load('openapi.yaml') do |config|
|
274
|
+
config.after_request_validation do |validated_request|
|
275
|
+
validated_request.valid? # => true / false
|
276
|
+
end
|
277
|
+
config.after_response_validation do |validated_response, request|
|
278
|
+
if validated_response.invalid?
|
279
|
+
warn "#{request.request_method} #{request.path}: #{validated_response.error.message}"
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
283
|
+
```
|
284
|
+
|
285
|
+
Setup globally:
|
286
|
+
|
287
|
+
```ruby
|
288
|
+
OpenapiFirst.configure do |config|
|
289
|
+
config.after_request_parameter_property_validation do |data, property, property_schema|
|
290
|
+
data[property] = Date.iso8601(data[property]) if propert_schema['format'] == 'date'
|
291
|
+
end
|
292
|
+
end
|
293
|
+
```
|
294
|
+
|
218
295
|
## Framework integration
|
219
296
|
|
220
297
|
Using rack middlewares is supported in probably all Ruby web frameworks.
|
@@ -5,30 +5,27 @@ require 'multi_json'
|
|
5
5
|
module OpenapiFirst
|
6
6
|
# @!visibility private
|
7
7
|
class BodyParser
|
8
|
-
def
|
9
|
-
|
10
|
-
warn 'DEPRECATION WARNING: OpenapiFirst::BodyParser::ParsingError is deprecated. ' \
|
11
|
-
'Use OpenapiFirst::ParseError instead.'
|
12
|
-
OpenapiFirst::ParseError
|
8
|
+
def initialize(content_type)
|
9
|
+
@is_json = :json if /json/i.match?(content_type)
|
13
10
|
end
|
14
11
|
|
15
|
-
def parse(request
|
12
|
+
def parse(request)
|
16
13
|
body = read_body(request)
|
17
|
-
return if body.empty?
|
14
|
+
return if body.nil? || body.empty?
|
18
15
|
|
19
|
-
return MultiJson.load(body) if
|
16
|
+
return MultiJson.load(body) if @is_json
|
20
17
|
return request.POST if request.form_data?
|
21
18
|
|
22
19
|
body
|
23
20
|
rescue MultiJson::ParseError
|
24
|
-
|
21
|
+
Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
|
25
22
|
end
|
26
23
|
|
27
24
|
private
|
28
25
|
|
29
26
|
def read_body(request)
|
30
|
-
body = request.body
|
31
|
-
request.body.rewind
|
27
|
+
body = request.body&.read
|
28
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
32
29
|
body
|
33
30
|
end
|
34
31
|
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
# Builds parts of a Definition
|
5
|
+
# This knows how to read a resolved OpenAPI document and build {Request} and {Response} objects.
|
6
|
+
class Builder
|
7
|
+
REQUEST_METHODS = %w[get head post put patch delete trace options].freeze
|
8
|
+
|
9
|
+
# Builds a router from a resolved OpenAPI document.
|
10
|
+
# @param resolved [Hash] The resolved OpenAPI document.
|
11
|
+
# @param config [OpenapiFirst::Configuration] The configuration object.
|
12
|
+
def self.build_router(resolved, config)
|
13
|
+
openapi_version = (resolved['openapi'] || resolved['swagger'])[0..2]
|
14
|
+
new(resolved, config, openapi_version).router
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(resolved, config, openapi_version)
|
18
|
+
@resolved = resolved
|
19
|
+
@config = config
|
20
|
+
@openapi_version = openapi_version
|
21
|
+
end
|
22
|
+
|
23
|
+
attr_reader :resolved, :openapi_version, :config
|
24
|
+
|
25
|
+
def router # rubocop:disable Metrics/MethodLength
|
26
|
+
router = OpenapiFirst::Router.new
|
27
|
+
resolved['paths'].each do |path, path_item_object|
|
28
|
+
path_item_object.slice(*REQUEST_METHODS).keys.map do |request_method|
|
29
|
+
operation_object = path_item_object[request_method]
|
30
|
+
build_requests(path, request_method, operation_object, path_item_object).each do |request|
|
31
|
+
router.add_request(
|
32
|
+
request,
|
33
|
+
request_method:,
|
34
|
+
path:,
|
35
|
+
content_type: request.content_type
|
36
|
+
)
|
37
|
+
end
|
38
|
+
build_responses(operation_object).each do |response|
|
39
|
+
router.add_response(
|
40
|
+
response,
|
41
|
+
request_method:,
|
42
|
+
path:,
|
43
|
+
status: response.status,
|
44
|
+
response_content_type: response.content_type
|
45
|
+
)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
router
|
50
|
+
end
|
51
|
+
|
52
|
+
def build_requests(path, request_method, operation_object, path_item_object)
|
53
|
+
hooks = config.hooks
|
54
|
+
path_item_parameters = path_item_object['parameters']
|
55
|
+
parameters = operation_object['parameters'].to_a.chain(path_item_parameters.to_a)
|
56
|
+
required_body = operation_object.dig('requestBody', 'required') == true
|
57
|
+
result = operation_object.dig('requestBody', 'content')&.map do |content_type, content|
|
58
|
+
Request.new(path:, request_method:, operation_object:, parameters:, content_type:,
|
59
|
+
content_schema: content['schema'], required_body:, hooks:, openapi_version:)
|
60
|
+
end || []
|
61
|
+
return result if required_body
|
62
|
+
|
63
|
+
result << Request.new(
|
64
|
+
path:, request_method:, operation_object:,
|
65
|
+
parameters:, content_type: nil, content_schema: nil,
|
66
|
+
required_body:, hooks:, openapi_version:
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
def build_responses(operation_object)
|
71
|
+
Array(operation_object['responses']).flat_map do |status, response_object|
|
72
|
+
headers = response_object['headers']
|
73
|
+
response_object['content']&.map do |content_type, content_object|
|
74
|
+
content_schema = content_object['schema']
|
75
|
+
Response.new(status:, headers:, content_type:, content_schema:, openapi_version:)
|
76
|
+
end || Response.new(status:, headers:, content_type: nil,
|
77
|
+
content_schema: nil, openapi_version:)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -3,13 +3,34 @@
|
|
3
3
|
module OpenapiFirst
|
4
4
|
# Global configuration. Currently only used for the request validation middleware.
|
5
5
|
class Configuration
|
6
|
+
HOOKS = %i[
|
7
|
+
after_request_validation
|
8
|
+
after_response_validation
|
9
|
+
after_request_parameter_property_validation
|
10
|
+
after_request_body_property_validation
|
11
|
+
].freeze
|
12
|
+
|
6
13
|
def initialize
|
7
|
-
@request_validation_error_response = OpenapiFirst.
|
14
|
+
@request_validation_error_response = OpenapiFirst.find_error_response(:default)
|
8
15
|
@request_validation_raise_error = false
|
16
|
+
@response_validation_raise_error = true
|
17
|
+
@hooks = (HOOKS.map { [_1, []] }).to_h
|
9
18
|
end
|
10
19
|
|
11
|
-
attr_reader :request_validation_error_response
|
12
|
-
attr_accessor :request_validation_raise_error
|
20
|
+
attr_reader :request_validation_error_response, :hooks
|
21
|
+
attr_accessor :request_validation_raise_error, :response_validation_raise_error
|
22
|
+
|
23
|
+
def clone
|
24
|
+
copy = super
|
25
|
+
copy.instance_variable_set(:@hooks, @hooks&.transform_values(&:clone))
|
26
|
+
copy
|
27
|
+
end
|
28
|
+
|
29
|
+
HOOKS.each do |hook|
|
30
|
+
define_method(hook) do |&block|
|
31
|
+
hooks[hook] << block
|
32
|
+
end
|
33
|
+
end
|
13
34
|
|
14
35
|
def request_validation_error_response=(mod)
|
15
36
|
@request_validation_error_response = if mod.is_a?(Symbol)
|
@@ -1,125 +1,69 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative '
|
4
|
-
require_relative '
|
5
|
-
require_relative '
|
6
|
-
require_relative '
|
3
|
+
require_relative 'failure'
|
4
|
+
require_relative 'router'
|
5
|
+
require_relative 'request'
|
6
|
+
require_relative 'response'
|
7
|
+
require_relative 'builder'
|
7
8
|
|
8
9
|
module OpenapiFirst
|
9
10
|
# Represents an OpenAPI API Description document
|
10
11
|
# This is returned by OpenapiFirst.load.
|
11
12
|
class Definition
|
12
|
-
attr_reader :filepath, :paths, :
|
13
|
+
attr_reader :filepath, :config, :paths, :router
|
13
14
|
|
14
15
|
# @param resolved [Hash] The resolved OpenAPI document.
|
15
16
|
# @param filepath [String] The file path of the OpenAPI document.
|
16
17
|
def initialize(resolved, filepath = nil)
|
17
18
|
@filepath = filepath
|
18
|
-
@
|
19
|
-
@
|
19
|
+
@config = OpenapiFirst.configuration.clone
|
20
|
+
yield @config if block_given?
|
21
|
+
@config.freeze
|
22
|
+
@router = Builder.build_router(resolved, @config)
|
23
|
+
@paths = resolved['paths'].keys # TODO: Move into builder as well
|
24
|
+
end
|
25
|
+
|
26
|
+
def routes
|
27
|
+
@router.routes
|
20
28
|
end
|
21
29
|
|
22
30
|
# Validates the request against the API description.
|
23
|
-
# @param
|
24
|
-
# @param
|
25
|
-
# @return [
|
26
|
-
def validate_request(
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
31
|
+
# @param [Rack::Request] rack_request The Rack request object.
|
32
|
+
# @param [Boolean] raise_error Whether to raise an error if validation fails.
|
33
|
+
# @return [ValidatedRequest] The validated request object.
|
34
|
+
def validate_request(request, raise_error: false)
|
35
|
+
route = @router.match(request.request_method, request.path, content_type: request.content_type)
|
36
|
+
validated = if route.error
|
37
|
+
ValidatedRequest.new(request, error: route.error)
|
38
|
+
else
|
39
|
+
route.request_definition.validate(request, route_params: route.params)
|
40
|
+
end
|
41
|
+
@config.hooks[:after_request_validation].each { |hook| hook.call(validated, self) }
|
42
|
+
raise validated.error.exception(validated) if validated.error && raise_error
|
43
|
+
|
44
|
+
validated
|
33
45
|
end
|
34
46
|
|
35
47
|
# Validates the response against the API description.
|
36
48
|
# @param rack_request [Rack::Request] The Rack request object.
|
37
49
|
# @param rack_response [Rack::Response] The Rack response object.
|
38
50
|
# @param raise_error [Boolean] Whether to raise an error if validation fails.
|
39
|
-
# @return [
|
51
|
+
# @return [ValidatedResponse] The validated response object.
|
40
52
|
def validate_response(rack_request, rack_response, raise_error: false)
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
RuntimeRequest.new(
|
56
|
-
request: rack_request,
|
57
|
-
path_item:,
|
58
|
-
operation:,
|
59
|
-
path_params:
|
60
|
-
)
|
61
|
-
end
|
62
|
-
|
63
|
-
# Builds a RuntimeResponse object based on the Rack request and response.
|
64
|
-
# @param rack_request [Rack::Request] The Rack request object.
|
65
|
-
# @param rack_response [Rack::Response] The Rack response object.
|
66
|
-
# @return [RuntimeResponse] The RuntimeResponse object.
|
67
|
-
def response(rack_request, rack_response)
|
68
|
-
runtime_request = request(rack_request)
|
69
|
-
RuntimeResponse.new(runtime_request.operation, rack_response)
|
70
|
-
end
|
71
|
-
|
72
|
-
# Gets all the operations defined in the API description.
|
73
|
-
# @return [Array<Operation>] An array of Operation objects.
|
74
|
-
def operations
|
75
|
-
@operations ||= path_items.flat_map(&:operations)
|
76
|
-
end
|
77
|
-
|
78
|
-
# Gets the PathItem object for the specified path.
|
79
|
-
# @param pathname [String] The path template string.
|
80
|
-
# @return [PathItem] The PathItem object.
|
81
|
-
# Example:
|
82
|
-
# definition.path('/pets/{id}')
|
83
|
-
def path(pathname)
|
84
|
-
return unless paths.key?(pathname)
|
85
|
-
|
86
|
-
PathItem.new(pathname, paths[pathname], openapi_version:)
|
87
|
-
end
|
88
|
-
|
89
|
-
private
|
90
|
-
|
91
|
-
# Gets all the PathItem objects defined in the API description.
|
92
|
-
# @return [Array] An array of PathItem objects.
|
93
|
-
def path_items
|
94
|
-
@path_items ||= paths.flat_map do |path, path_item_object|
|
95
|
-
PathItem.new(path, path_item_object, openapi_version:)
|
96
|
-
end
|
97
|
-
end
|
98
|
-
|
99
|
-
def find_path_item_and_params(request_path)
|
100
|
-
if paths.key?(request_path)
|
101
|
-
return [
|
102
|
-
PathItem.new(request_path, paths[request_path], openapi_version:),
|
103
|
-
{}
|
104
|
-
]
|
105
|
-
end
|
106
|
-
search_for_path_item(request_path)
|
107
|
-
end
|
108
|
-
|
109
|
-
def search_for_path_item(request_path)
|
110
|
-
path_items.find do |path_item|
|
111
|
-
path_params = path_item.match(request_path)
|
112
|
-
next unless path_params
|
113
|
-
|
114
|
-
return [
|
115
|
-
path_item,
|
116
|
-
path_params
|
117
|
-
]
|
118
|
-
end
|
119
|
-
end
|
120
|
-
|
121
|
-
def detect_version(resolved)
|
122
|
-
(resolved['openapi'] || resolved['swagger'])[0..2]
|
53
|
+
route = @router.match(rack_request.request_method, rack_request.path, content_type: rack_request.content_type)
|
54
|
+
return if route.error # Skip response validation for unknown requests
|
55
|
+
|
56
|
+
response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
|
57
|
+
error = response_match.error
|
58
|
+
validated = if error
|
59
|
+
ValidatedResponse.new(rack_response, error:)
|
60
|
+
else
|
61
|
+
response_match.response.validate(rack_response)
|
62
|
+
end
|
63
|
+
@config.hooks[:after_response_validation]&.each { |hook| hook.call(validated, rack_request, self) }
|
64
|
+
raise validated.error.exception(validated) if raise_error && validated.invalid?
|
65
|
+
|
66
|
+
validated
|
123
67
|
end
|
124
68
|
end
|
125
69
|
end
|
@@ -12,12 +12,12 @@ module OpenapiFirst
|
|
12
12
|
|
13
13
|
# The response body
|
14
14
|
def body
|
15
|
-
raise
|
15
|
+
raise "#{self.class} must implement the method #{__method__}"
|
16
16
|
end
|
17
17
|
|
18
18
|
# The response content-type
|
19
19
|
def content_type
|
20
|
-
raise
|
20
|
+
raise "#{self.class} must implement the method #{__method__}"
|
21
21
|
end
|
22
22
|
|
23
23
|
STATUS = {
|