openapi_first 3.4.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +95 -42
- data/lib/openapi_first/builder.rb +85 -50
- data/lib/openapi_first/child_configuration.rb +0 -2
- data/lib/openapi_first/configuration.rb +0 -23
- data/lib/openapi_first/definition.rb +35 -2
- data/lib/openapi_first/failure.rb +5 -1
- data/lib/openapi_first/middlewares/request_validation.rb +1 -1
- data/lib/openapi_first/middlewares/response_validation.rb +1 -1
- data/lib/openapi_first/parameter/converter/array_converter.rb +42 -0
- data/lib/openapi_first/parameter/converter/object_converter.rb +60 -0
- data/lib/openapi_first/parameter/converter.rb +69 -0
- data/lib/openapi_first/parameter/unpackers.rb +132 -0
- data/lib/openapi_first/parameter.rb +70 -0
- data/lib/openapi_first/parameter_content_parsers.rb +55 -0
- data/lib/openapi_first/parameters_parser.rb +23 -0
- data/lib/openapi_first/query_string_parser.rb +93 -0
- data/lib/openapi_first/ref_resolver.rb +56 -3
- data/lib/openapi_first/request.rb +11 -10
- data/lib/openapi_first/request_body_parsers.rb +11 -7
- data/lib/openapi_first/request_headers.rb +27 -0
- data/lib/openapi_first/request_validator.rb +4 -1
- data/lib/openapi_first/response_header.rb +9 -0
- data/lib/openapi_first/response_parser.rb +3 -10
- data/lib/openapi_first/router.rb +27 -12
- data/lib/openapi_first/schema/hash.rb +0 -1
- data/lib/openapi_first/sinatra.rb +217 -0
- data/lib/openapi_first/test/configuration.rb +0 -34
- data/lib/openapi_first/test/coverage/html_reporter/context.rb +24 -17
- data/lib/openapi_first/test/coverage/html_reporter.css +214 -67
- data/lib/openapi_first/test/coverage/html_reporter.html.erb +39 -11
- data/lib/openapi_first/test/coverage/html_reporter.rb +11 -1
- data/lib/openapi_first/test/coverage/plan.rb +30 -10
- data/lib/openapi_first/test/coverage/request_task.rb +7 -2
- data/lib/openapi_first/test/coverage/response_task.rb +6 -1
- data/lib/openapi_first/test/coverage/route_task.rb +23 -1
- data/lib/openapi_first/test/coverage/skipped_summary.rb +22 -0
- data/lib/openapi_first/test/coverage/terminal_reporter.rb +23 -11
- data/lib/openapi_first/test/coverage.rb +9 -3
- data/lib/openapi_first/test.rb +69 -11
- data/lib/openapi_first/validators/multipart_request_body.rb +57 -0
- data/lib/openapi_first/validators/request_body.rb +20 -7
- data/lib/openapi_first/validators/request_parameters.rb +5 -4
- data/lib/openapi_first/version.rb +1 -1
- metadata +15 -23
- data/lib/openapi_first/header.rb +0 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47dffdd527985dd188f08ea57ff1104f3e51203746175cf4a964c757af8ebd6e
|
|
4
|
+
data.tar.gz: c67d1e706d644caadd9397bfdb2eef046317e2c4ac86bfd8e261b60ad7136bf7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5e16ce4c6c4756517e9bff345eb7182937b239be36a038444680f61b169790aef3c5d10c3c1d8b2be0e3332b72784a3722ba3ea99de0e7712f4c1e48284c0ec
|
|
7
|
+
data.tar.gz: fa631d3327bbcf640645d2b762d00ac8baa4bf5c87276c9c00cc280e58d1985a419a64ccd4b8521f411f9d5f47442ae9efc31f0bf959b5a8d4f24fb8205de7a7
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 4.0.0
|
|
6
|
+
|
|
7
|
+
This release has no stricter or less strict request validation. It changes mostly internal stuff and adds a Sinatra integration. It's a major version, but it should be safe to upgrade.
|
|
8
|
+
|
|
9
|
+
#### Breaking changes
|
|
10
|
+
- Uploaded files are no longer read during request validation. Before, the whole content of every `multipart/form-data` part that was sent as a file was read into memory, which allowed a single large upload to any documented multipart route to exhaust the memory of the server process. Such a field is now passed through as Rack parsed it (`{ filename:, type:, name:, tempfile:, head: }`), which is the same shape that Sinatra and Hanami hand to your application. Use `parsed_body['file'][:tempfile]` to read or stream the file.
|
|
11
|
+
- The content of these fields is not validated anymore, so `minLength`, `maxLength` or `pattern` on a field that was sent as a file are ignored.
|
|
12
|
+
- An `after_request_body_property_validation` hook sees an empty String instead of the file.
|
|
13
|
+
- Fields that were not sent as a file, and fields with a JSON `contentType` in the `encoding` map, are read and validated as before.
|
|
14
|
+
- The `openapi_parameters` gem was merged into openapi_first and is not a dependency anymore. Parameter parsing now lives in openapi_first itself. If you registered a parser for parameters that use a `content` field, use `OpenapiFirst::ParameterContentParsers.register` instead of `OpenapiParameters::ContentParsers.register`.
|
|
15
|
+
- Changed: `OpenapiFirst::ResponseHeader` (returned by `Response#headers`, renamed from `OpenapiFirst::Header`) exposes `parameter`, an `OpenapiFirst::Parameter`, instead of `node`.
|
|
16
|
+
- Changed: `OpenapiFirst::Request#parameters` returns the parameters that are defined for a request as `OpenapiFirst::Parameter` objects, which expose `name`, `location`, `schema`, `required?`, `deprecated?`, `style`, `explode?` and `media_type`.
|
|
17
|
+
|
|
18
|
+
#### Removed deprecations
|
|
19
|
+
- Removed: `OpenapiFirst::Configuration#request_validation_raise_error` and `#response_validation_raise_error` (both reader and writer), deprecated since 3.0.0. Pass `raise_error:` to middlewares instead.
|
|
20
|
+
- Removed: `OpenapiFirst::Test::Configuration#coverage_formatter`, `#coverage_formatter=`, `#coverage_formatter_options` and `#coverage_formatter_options=`, deprecated since 3.4.0. Use `#coverage_reporter` / `#coverage_reporter_options` instead.
|
|
21
|
+
- Removed: `OpenapiFirst::Test::Coverage::TerminalFormatter`, deprecated since 3.4.0. Use `OpenapiFirst::Test::Coverage::TerminalReporter` instead.
|
|
22
|
+
- Removed: The `formatter:` keyword of `OpenapiFirst::Test.report_coverage`, deprecated since 3.4.0. Use `reporter:` instead.
|
|
23
|
+
- Removed: `OpenapiFirst::Test::Coverage::TerminalReporter#format`, deprecated since 3.4.0. Use `#report` instead.
|
|
24
|
+
|
|
25
|
+
#### Added
|
|
26
|
+
- Added: API coverage now reports skipped requests and responses.
|
|
27
|
+
- 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](https://github.com/ahx/openapi_first/issues/469).
|
|
28
|
+
- Added: Show all covered endpoints in HTML coverage reporter and filter covered/uncovered endpoints.
|
|
29
|
+
- Added: Sinatra integration (OpenapiFirst::Sinatra)
|
|
30
|
+
A Sinatra extension to define routes by referencing OpenAPI operations:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require 'openapi_first/sinatra'
|
|
34
|
+
|
|
35
|
+
class PetsApi < Sinatra::Base
|
|
36
|
+
register OpenapiFirst::Sinatra
|
|
37
|
+
openapi 'openapi.yaml'
|
|
38
|
+
|
|
39
|
+
operation :index_pets do |params|
|
|
40
|
+
json index_pets(params[:filter])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
operation :create_pet do
|
|
44
|
+
pet = create_pet(parsed_body)
|
|
45
|
+
headers['Location'] = operation_url(:show_pet, petId: pet.id)
|
|
46
|
+
status :created
|
|
47
|
+
json pet
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
operation :show_pet do |params|
|
|
51
|
+
json show_pet(params[:petId])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The HTTP method and path for each route come from the operationId.
|
|
57
|
+
Request validation is called automatically for these operations.
|
|
58
|
+
|
|
59
|
+
#### Fixed
|
|
60
|
+
- Fixed: Validating against a schema from a referenced file raised `ArgumentError` in OpenAPI 3.0 documents when a top-level key of that file collides with a JSON Schema keyword, such as `$ref: 'parameters.yaml#/id'`. The containing file is no longer parsed as a schema itself, so such keys work like any other now. See [#348](https://github.com/ahx/openapi_first/issues/348).
|
|
61
|
+
- Fixed: `$ref`s nested inside the schema of a parameter or a response header are resolved now, so these values are unpacked and converted as described. Before, only a `$ref` at the top level of the schema was resolved. See #450.
|
|
62
|
+
- Fixed: The JSON schema of a parameter that uses a `content` field with a `$ref`'d schema is resolved now.
|
|
63
|
+
- Fixed: Loading a document no longer raises `NoMethodError` when a parameter has neither `schema` nor `content`.
|
|
64
|
+
- Fixed: Repeated values for a query parameter that describes an object or uses `content` (`?filter=a&filter=b`) raised a `NoMethodError` or `TypeError`. The values are validated against the schema now, which returns an `:invalid_query` failure.
|
|
65
|
+
- Fixed: A parameter with `style: matrix` raised a `NoMethodError` if its value did not contain the parameter name, or contained it more than once. Such values are parsed like their `explode` counterpart now.
|
|
66
|
+
- Fixed: A parameter with `style: matrix`, or a path parameter that describes an object, raised an `ArgumentError` if its value had an invalid `%`-encoding. Such values are validated against the schema now.
|
|
67
|
+
- Fixed: A path, header or cookie parameter that uses a `content` field with a value that could not be parsed as that media type (e.g. `007` as `application/json`) was converted using the parameter's schema type anyway, which could make an invalid value pass schema validation (e.g. as the integer `7`). Such values are left as they are now, so schema validation rejects them as before.
|
|
68
|
+
- Fixed: 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.
|
|
69
|
+
|
|
70
|
+
|
|
5
71
|
## 3.4.3
|
|
6
72
|
|
|
7
73
|
Fixed: Loading a document no longer raises `NoMethodError: undefined method 'schema' for nil` when a Media Type Object has no `schema` (e.g. it only declares an `example`). `schema` is optional in a Media Type Object; such media types now impose no body-schema constraint.
|
data/README.md
CHANGED
|
@@ -49,7 +49,7 @@ end
|
|
|
49
49
|
|
|
50
50
|
## Configuration
|
|
51
51
|
|
|
52
|
-
You should register OADs globally so you don't have to load the file multiple times
|
|
52
|
+
You should register OADs globally so you don't have to load the file multiple times.
|
|
53
53
|
```ruby
|
|
54
54
|
OpenapiFirst.configure do |config|
|
|
55
55
|
config.register('openapi/openapi.yaml') # :default
|
|
@@ -57,7 +57,7 @@ OpenapiFirst.configure do |config|
|
|
|
57
57
|
end
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
You can configure
|
|
60
|
+
You can configure defaults globally:
|
|
61
61
|
|
|
62
62
|
```ruby
|
|
63
63
|
OpenapiFirst.configure do |config|
|
|
@@ -119,7 +119,7 @@ content-type: "application/problem+json"
|
|
|
119
119
|
openapi_first offers a [JSON:API](https://jsonapi.org/) error response by passing `error_response: :jsonapi`:
|
|
120
120
|
|
|
121
121
|
```ruby
|
|
122
|
-
use OpenapiFirst::Middlewares::RequestValidation,
|
|
122
|
+
use OpenapiFirst::Middlewares::RequestValidation, error_response: :jsonapi
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
<details>
|
|
@@ -166,9 +166,28 @@ use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml', error_response
|
|
|
166
166
|
You can build your own custom error response with `error_response: MyCustomClass` that implements `OpenapiFirst::ErrorResponse`.
|
|
167
167
|
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`.
|
|
168
168
|
|
|
169
|
+
#### Multipart file uploads
|
|
170
|
+
|
|
171
|
+
Uploaded files are not read during request validation. A `multipart/form-data` field that was sent as a file is passed through as Rack parsed it – the same shape that Sinatra and Hanami hand to your application:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
file = validated_request.parsed_body['file']
|
|
175
|
+
file[:filename] # => "cat.jpg"
|
|
176
|
+
file[:type] # => "image/jpeg"
|
|
177
|
+
file[:tempfile] # => #<Tempfile …> Read or stream this in your application.
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The tempfile is only usable while the request is being handled, because Rack removes it afterwards.
|
|
181
|
+
|
|
182
|
+
This means the _content_ of these fields is not validated, so `minLength`, `maxLength` or `pattern` on a field that was sent as a file are ignored. Fields that were not sent as a file are read and validated as usual, and a field with `contentType: application/json` in the `encoding` map is still parsed as JSON.
|
|
183
|
+
|
|
169
184
|
### Response validation
|
|
170
185
|
|
|
186
|
+
> [!WARNING]
|
|
187
|
+
> You should use [Contract Testing](#contract-testing) instead of this middleware.
|
|
188
|
+
|
|
171
189
|
This middleware raises an error by default if the response is not valid.
|
|
190
|
+
|
|
172
191
|
This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
|
|
173
192
|
|
|
174
193
|
```ruby
|
|
@@ -178,7 +197,7 @@ use OpenapiFirst::Middlewares::ResponseValidation if ENV['RACK_ENV'] == 'test'
|
|
|
178
197
|
use OpenapiFirst::Middlewares::ResponseValidation, raise_error: false
|
|
179
198
|
```
|
|
180
199
|
|
|
181
|
-
If you are adopting OpenAPI you can use these options together with [hooks](#hooks) to get notified about requests/responses that do match your API description.
|
|
200
|
+
If you are adopting OpenAPI you can use these options together with [hooks](#hooks) to get notified about requests/responses that do not match your API description.
|
|
182
201
|
|
|
183
202
|
## Contract Testing
|
|
184
203
|
|
|
@@ -192,36 +211,17 @@ Here is how to set it up:
|
|
|
192
211
|
require 'openapi_first'
|
|
193
212
|
OpenapiFirst::Test.setup
|
|
194
213
|
```
|
|
195
|
-
2. Observe your application.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
end
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
Or do this by creating a Module and including it to add an "app" method.
|
|
210
|
-
|
|
211
|
-
```ruby
|
|
212
|
-
RSpec.configure do |config|
|
|
213
|
-
config.include OpenapiFirst::Test::Methods[MyApp], type: :request
|
|
214
|
-
end
|
|
215
|
-
```
|
|
216
|
-
3. Run your tests. The Coverage feature will tell you about missing or invalid requests/responses:
|
|
217
|
-
```
|
|
218
|
-
✓ GET /stations
|
|
219
|
-
✓ 200(application/json)
|
|
220
|
-
❌ 200(application/xml) – No responses tracked!
|
|
221
|
-
❌ 400(application/problem+json) – No responses tracked!
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
Now add tests for all those "❌" to make them "✓" and you're green!
|
|
214
|
+
2. Observe your application.
|
|
215
|
+
If you are using plain rack-test (sinatra, roda, hanami, etc.), this will add an `app` method that wraps your application with silent request / response validation:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
RSpec.configure do |config|
|
|
219
|
+
config.include OpenapiFirst::Test::Methods[MyApp], type: :request
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
There are other ways to monitor your app, see `OpenapiFirst::Test.observe` (rails) or `OpenapiFirst::Test.app`.
|
|
224
|
+
3. Run your tests. The Coverage feature will produce a HTML report at `coverage/openapi_coverage.html` with missing or invalid requests/responses.
|
|
225
225
|
|
|
226
226
|
> [!NOTE]
|
|
227
227
|
> Check out [faraday-openapi](https://codeberg.org/ahx/faraday-openapi) to have your API _client_ validate request/responses against an OAD, which is useful to validate HTTP mocks during testing.
|
|
@@ -239,12 +239,11 @@ OpenapiFirst::Test.setup do |test|
|
|
|
239
239
|
|
|
240
240
|
test.ignore_response_error do |validated_response, rack_request|
|
|
241
241
|
# Ignore invalid response bodies on certain paths
|
|
242
|
-
|
|
242
|
+
rack_request.path.start_with?('/api/legacy/stuff') && validated_response.error.type == :invalid_body
|
|
243
243
|
end
|
|
244
244
|
end
|
|
245
245
|
```
|
|
246
246
|
|
|
247
|
-
|
|
248
247
|
OpenapiFirst::Test raises an error when a response status is not defined except for 404 and 500. You can change this:
|
|
249
248
|
|
|
250
249
|
```ruby
|
|
@@ -276,7 +275,7 @@ Skip coverage for a request and all responses alltogether of a route with `skip_
|
|
|
276
275
|
```ruby
|
|
277
276
|
OpenapiFirst::Test.setup do |test|
|
|
278
277
|
test.skip_coverage do |path, request_method|
|
|
279
|
-
path == '/bookings/{bookingId}' &&
|
|
278
|
+
path == '/bookings/{bookingId}' && request_method == 'DELETE'
|
|
280
279
|
end
|
|
281
280
|
end
|
|
282
281
|
```
|
|
@@ -351,6 +350,18 @@ validated_request.operation['operationId'] => "getStuff"
|
|
|
351
350
|
validated_request.request_definition.path # => "/pets/{petId}"
|
|
352
351
|
validated_request.request_definition.operation_id # => "showPetById"
|
|
353
352
|
|
|
353
|
+
# Inspect the parameters that are defined for this request, in the order path, query, header, cookie.
|
|
354
|
+
# Parameters that openapi_first ignores (Content-Type, Accept, Authorization) are not included.
|
|
355
|
+
parameter = validated_request.request_definition.parameters.first
|
|
356
|
+
parameter.name # => "petId"
|
|
357
|
+
parameter.location # => "path" ("path", "query", "header" or "cookie")
|
|
358
|
+
parameter.schema # => { "type" => "integer" } (the JSON Schema, with $refs resolved)
|
|
359
|
+
parameter.required? # => true
|
|
360
|
+
parameter.deprecated? # => false
|
|
361
|
+
parameter.style # => "simple"
|
|
362
|
+
parameter.explode? # => false
|
|
363
|
+
parameter.media_type # => "application/json" if the parameter uses `content`, otherwise nil
|
|
364
|
+
|
|
354
365
|
# Or you can raise an exception if validation fails:
|
|
355
366
|
definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
|
|
356
367
|
```
|
|
@@ -360,7 +371,7 @@ definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFir
|
|
|
360
371
|
```ruby
|
|
361
372
|
validated_response = definition.validate_response(rack_request, rack_response)
|
|
362
373
|
|
|
363
|
-
# Inspect the response and access parsed parameters
|
|
374
|
+
# Inspect the response and access parsed parameters
|
|
364
375
|
validated_response.valid?
|
|
365
376
|
validated_response.invalid?
|
|
366
377
|
validated_response.error # => Failure object or nil
|
|
@@ -369,7 +380,7 @@ validated_response.parsed_body
|
|
|
369
380
|
validated_response.parsed_headers
|
|
370
381
|
|
|
371
382
|
# Or you can raise an exception if validation fails:
|
|
372
|
-
definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
|
|
383
|
+
definition.validate_response(rack_request, rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
|
|
373
384
|
```
|
|
374
385
|
|
|
375
386
|
## Hooks
|
|
@@ -416,12 +427,54 @@ Using rack middlewares is supported in probably all Ruby web frameworks.
|
|
|
416
427
|
|
|
417
428
|
The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.
|
|
418
429
|
|
|
419
|
-
That aside, closer integration with specific frameworks like
|
|
430
|
+
That aside, closer integration with specific frameworks like Hanami, Roda or others would be great. If you have ideas, pain points or PRs, please don't hesitate to [share](https://github.com/ahx/openapi_first/discussions).
|
|
431
|
+
|
|
432
|
+
### Sinatra
|
|
433
|
+
|
|
434
|
+
`OpenapiFirst::Sinatra` is a Sinatra extension to define routes by referencing operations in your OpenAPI description. The URL and HTTP method for each route are read from the OAD by `operationId`, so they are never repeated in your code – the API description stays the single source of truth for your routes.
|
|
435
|
+
|
|
436
|
+
```ruby
|
|
437
|
+
require 'sinatra/base'
|
|
438
|
+
require 'openapi_first/sinatra'
|
|
439
|
+
|
|
440
|
+
class PetsApi < Sinatra::Base
|
|
441
|
+
register OpenapiFirst::Sinatra
|
|
442
|
+
openapi 'openapi.yaml'
|
|
443
|
+
|
|
444
|
+
operation :index_pets do |params|
|
|
445
|
+
json index_pets(params[:filter])
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
operation :create_pet do
|
|
449
|
+
json create_pet(parsed_body[:data])
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
In a **classic** (top-level) app the extension registers itself, so requiring the file is enough:
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
require 'sinatra'
|
|
458
|
+
require 'openapi_first/sinatra'
|
|
459
|
+
|
|
460
|
+
openapi 'openapi.yaml'
|
|
461
|
+
|
|
462
|
+
operation :create_pet do
|
|
463
|
+
json create_pet(parsed_body)
|
|
464
|
+
end
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Each `operation` route validates its request against the description before the block runs, so contract violations return `400`/`415` and the block is not reached. Validation reuses Sinatra's own routing (the operation's path and method are known when the route is defined), so openapi_first does not run its own router – there is no request-validation middleware.
|
|
468
|
+
|
|
469
|
+
Because routing is left to Sinatra, requests to paths without an `operation` block fall through to Sinatra's normal handling (a `404` by default), and you can add plain Sinatra routes (health checks, assets, …) alongside `operation` blocks. Likewise, an undocumented method on a documented path returns Sinatra's `404`.
|
|
470
|
+
|
|
471
|
+
Use a String for operationIds that are not valid Ruby symbols, e.g. `operation 'pets.list'`.
|
|
472
|
+
|
|
473
|
+
See [`sinatra.rb`](lib/openapi_first/sinatra.rb) for a more details.
|
|
420
474
|
|
|
421
475
|
## Alternatives
|
|
422
476
|
|
|
423
477
|
This gem was inspired by [committee](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
|
|
424
|
-
Here is a [feature comparison between openapi_first and committee](https://gist.github.com/ahx/1538c31f0652f459861713b5259e366a).
|
|
425
478
|
|
|
426
479
|
## Frequently Asked Questions
|
|
427
480
|
|
|
@@ -441,7 +494,7 @@ Here your OpenAPI schema defines endpoints starting with `/resource` but your ac
|
|
|
441
494
|
|
|
442
495
|
```ruby
|
|
443
496
|
oad = OpenapiFirst.load('openapi.yaml') do |config|
|
|
444
|
-
config.path = ->(req) {
|
|
497
|
+
config.path = ->(req) { req.path.delete_prefix('/api') }
|
|
445
498
|
end
|
|
446
499
|
use OpenapiFirst::Middlewares::RequestValidation, oad
|
|
447
500
|
```
|
|
@@ -4,7 +4,10 @@ require 'json_schemer'
|
|
|
4
4
|
|
|
5
5
|
require_relative 'failure'
|
|
6
6
|
require_relative 'router'
|
|
7
|
-
require_relative '
|
|
7
|
+
require_relative 'response_header'
|
|
8
|
+
require_relative 'parameter'
|
|
9
|
+
require_relative 'parameters_parser'
|
|
10
|
+
require_relative 'query_string_parser'
|
|
8
11
|
require_relative 'request'
|
|
9
12
|
require_relative 'response'
|
|
10
13
|
require_relative 'schema/hash'
|
|
@@ -52,7 +55,11 @@ module OpenapiFirst
|
|
|
52
55
|
version = document['openapi']
|
|
53
56
|
case version
|
|
54
57
|
when /\A3\.1\.\d+\z/
|
|
55
|
-
document
|
|
58
|
+
openapi31_meta_schema(document)
|
|
59
|
+
when /\A3\.2\.\d+\z/
|
|
60
|
+
warn "OpenAPI 3.2 is not fully supported. #{filepath || 'This API description'} is handled " \
|
|
61
|
+
'using the OpenAPI 3.1 rules, so features introduced in 3.2 may be ignored.'
|
|
62
|
+
openapi31_meta_schema(document)
|
|
56
63
|
when /\A3\.0\.\d+\z/
|
|
57
64
|
JSONSchemer::OpenAPI30::BASE_URI.to_s
|
|
58
65
|
else
|
|
@@ -60,60 +67,86 @@ module OpenapiFirst
|
|
|
60
67
|
end
|
|
61
68
|
end
|
|
62
69
|
|
|
63
|
-
def
|
|
70
|
+
def openapi31_meta_schema(document)
|
|
71
|
+
document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def router
|
|
64
75
|
router = OpenapiFirst::Router.new
|
|
65
76
|
@contents.fetch('paths').each do |path, path_item_object|
|
|
66
77
|
path_parameters = path_item_object['parameters'] || []
|
|
67
78
|
path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
|
|
68
79
|
operation_object = path_item_object[request_method]
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
request,
|
|
76
|
-
request_method:,
|
|
77
|
-
path:,
|
|
78
|
-
content_type: request.content_type,
|
|
79
|
-
allow_empty_content: request.allow_empty_content?
|
|
80
|
-
)
|
|
81
|
-
build_responses(request:, responses: operation_object['responses']).each do |response|
|
|
82
|
-
router.add_response(
|
|
83
|
-
response,
|
|
84
|
-
request_method:,
|
|
85
|
-
path:,
|
|
86
|
-
status: response.status,
|
|
87
|
-
response_content_type: response.content_type
|
|
88
|
-
)
|
|
89
|
-
end
|
|
90
|
-
end
|
|
80
|
+
register_operation(router, path:, request_method:, operation_object:, path_parameters:)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
path_item_object['additionalOperations']&.each do |request_method, operation_object|
|
|
84
|
+
register_operation(router, path:, request_method: request_method.downcase,
|
|
85
|
+
operation_object:, path_parameters:)
|
|
91
86
|
end
|
|
92
87
|
end
|
|
93
88
|
router
|
|
94
89
|
end
|
|
95
90
|
|
|
91
|
+
def register_operation(router, path:, request_method:, operation_object:, path_parameters:)
|
|
92
|
+
operation_parameters = operation_object['parameters'] || []
|
|
93
|
+
parameters = parse_parameters(operation_parameters.chain(path_parameters))
|
|
94
|
+
|
|
95
|
+
build_requests(path:, request_method:, operation_object:,
|
|
96
|
+
parameters:).each do |request|
|
|
97
|
+
router.add_request(
|
|
98
|
+
request,
|
|
99
|
+
request_method:,
|
|
100
|
+
path:,
|
|
101
|
+
content_type: request.content_type,
|
|
102
|
+
allow_empty_content: request.allow_empty_content?
|
|
103
|
+
)
|
|
104
|
+
register_responses(router, request:, path:, request_method:,
|
|
105
|
+
responses: operation_object['responses'])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def register_responses(router, request:, path:, request_method:, responses:)
|
|
110
|
+
build_responses(request:, responses:).each do |response|
|
|
111
|
+
router.add_response(
|
|
112
|
+
response,
|
|
113
|
+
request_method:,
|
|
114
|
+
path:,
|
|
115
|
+
status: response.status,
|
|
116
|
+
response_content_type: response.content_type
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
96
121
|
def parse_parameters(parameters)
|
|
97
|
-
|
|
122
|
+
grouped = group_parameters(parameters)
|
|
123
|
+
path = build_parameters(grouped[:path])
|
|
124
|
+
query = build_parameters(grouped[:query])
|
|
125
|
+
header = build_parameters(grouped[:header])
|
|
126
|
+
cookie = build_parameters(grouped[:cookie])
|
|
98
127
|
ParsedParameters.new(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
path_schema: build_parameter_schema(
|
|
105
|
-
|
|
106
|
-
header_schema: build_parameter_schema(
|
|
128
|
+
all: [*path, *query, *header, *cookie].freeze,
|
|
129
|
+
path_parser: grouped[:path] && ParametersParser.new(path),
|
|
130
|
+
query_parser: QueryStringParser.new(query),
|
|
131
|
+
header_parser: grouped[:header] && ParametersParser.new(header),
|
|
132
|
+
cookie_parser: grouped[:cookie] && ParametersParser.new(cookie),
|
|
133
|
+
path_schema: build_parameter_schema(grouped[:path]),
|
|
134
|
+
query_schema: build_parameter_schema(grouped[:query]),
|
|
135
|
+
header_schema: build_parameter_schema(grouped[:header]),
|
|
136
|
+
cookie_schema: build_parameter_schema(grouped[:cookie])
|
|
107
137
|
)
|
|
108
138
|
end
|
|
109
139
|
|
|
110
|
-
def
|
|
111
|
-
parameters
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
def build_parameters(parameters)
|
|
141
|
+
parameters.to_a.map do |parameter|
|
|
142
|
+
Parameter.new(parameter.resolved, schema: parameter_schema_node(parameter)&.dereferenced)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The schema of a parameter is either defined directly or inside a content media type object
|
|
147
|
+
def parameter_schema_node(parameter)
|
|
148
|
+
_media_type, media_type_object = parameter['content']&.first
|
|
149
|
+
(media_type_object || parameter)['schema']
|
|
117
150
|
end
|
|
118
151
|
|
|
119
152
|
def build_parameter_schema(parameters)
|
|
@@ -121,7 +154,7 @@ module OpenapiFirst
|
|
|
121
154
|
|
|
122
155
|
required = []
|
|
123
156
|
schemas = parameters.each_with_object({}) do |parameter, result|
|
|
124
|
-
schema = parameter
|
|
157
|
+
schema = parameter_schema_node(parameter)&.schema(configuration: schemer_configuration)
|
|
125
158
|
name = parameter['name']&.value
|
|
126
159
|
required << name if parameter['required']&.value
|
|
127
160
|
result[name] = schema if schema
|
|
@@ -193,16 +226,18 @@ module OpenapiFirst
|
|
|
193
226
|
|
|
194
227
|
result = []
|
|
195
228
|
headers_object.each do |name, header|
|
|
196
|
-
|
|
229
|
+
schema_node = header['schema']
|
|
230
|
+
next if schema_node.nil?
|
|
197
231
|
next if IGNORED_HEADER_PARAMETERS.include?(name)
|
|
198
232
|
|
|
199
|
-
|
|
233
|
+
required = header['required']&.value == true
|
|
234
|
+
result << ResponseHeader.new(
|
|
200
235
|
name:,
|
|
201
|
-
schema:
|
|
202
|
-
required?:
|
|
203
|
-
|
|
236
|
+
schema: schema_node.schema(configuration: schemer_configuration),
|
|
237
|
+
required?: required,
|
|
238
|
+
parameter: Parameter.new({ 'name' => name, 'in' => 'header', 'required' => required },
|
|
239
|
+
schema: schema_node.dereferenced)
|
|
204
240
|
)
|
|
205
|
-
result << header
|
|
206
241
|
end
|
|
207
242
|
result
|
|
208
243
|
end
|
|
@@ -216,8 +251,8 @@ module OpenapiFirst
|
|
|
216
251
|
result
|
|
217
252
|
end
|
|
218
253
|
|
|
219
|
-
ParsedParameters = Data.define(:
|
|
220
|
-
:cookie_schema)
|
|
254
|
+
ParsedParameters = Data.define(:all, :path_parser, :query_parser, :header_parser, :cookie_parser,
|
|
255
|
+
:path_schema, :query_schema, :header_schema, :cookie_schema)
|
|
221
256
|
private_constant :ParsedParameters
|
|
222
257
|
end
|
|
223
258
|
end
|
|
@@ -7,8 +7,6 @@ module OpenapiFirst
|
|
|
7
7
|
super()
|
|
8
8
|
@parent = parent
|
|
9
9
|
@request_validation_error_response = parent.request_validation_error_response
|
|
10
|
-
@request_validation_raise_error = parent.request_validation_raise_error
|
|
11
|
-
@response_validation_raise_error = parent.response_validation_raise_error
|
|
12
10
|
@path = parent.path
|
|
13
11
|
end
|
|
14
12
|
|
|
@@ -14,8 +14,6 @@ module OpenapiFirst
|
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
16
16
|
@request_validation_error_response = OpenapiFirst.find_error_response(:default)
|
|
17
|
-
@request_validation_raise_error = false
|
|
18
|
-
@response_validation_raise_error = true
|
|
19
17
|
@hooks = HOOKS.to_h { [_1, Set.new] }
|
|
20
18
|
@path = nil
|
|
21
19
|
end
|
|
@@ -27,11 +25,6 @@ module OpenapiFirst
|
|
|
27
25
|
attr_reader :hooks, :request_validation_error_response
|
|
28
26
|
attr_accessor :path
|
|
29
27
|
|
|
30
|
-
# @deprecated
|
|
31
|
-
attr_reader :request_validation_raise_error
|
|
32
|
-
# @deprecated
|
|
33
|
-
attr_reader :response_validation_raise_error
|
|
34
|
-
|
|
35
28
|
# Return a child configuration that still receives updates of global hooks.
|
|
36
29
|
def child
|
|
37
30
|
ChildConfiguration.new(parent: self)
|
|
@@ -42,22 +35,6 @@ module OpenapiFirst
|
|
|
42
35
|
raise NoMethodError, 'OpenapiFirst::Configuration#clone was removed. You want to call #child instead'
|
|
43
36
|
end
|
|
44
37
|
|
|
45
|
-
# @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::RequestValidation directly
|
|
46
|
-
def request_validation_raise_error=(value)
|
|
47
|
-
message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
|
|
48
|
-
'Please pass `raise_error:` to `OpenapiFirst::Middlewares::RequestValidation directly`'
|
|
49
|
-
warn message, category: :deprecated
|
|
50
|
-
@request_validation_raise_error = value
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::ResponseValidation directly
|
|
54
|
-
def response_validation_raise_error=(value)
|
|
55
|
-
message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
|
|
56
|
-
'Please pass `raise_error:` to `OpenapiFirst::Middlewares::ResponseValidation directly`'
|
|
57
|
-
warn message
|
|
58
|
-
@response_validation_raise_error = value
|
|
59
|
-
end
|
|
60
|
-
|
|
61
38
|
HOOKS.each do |hook|
|
|
62
39
|
define_method(hook) do |&block|
|
|
63
40
|
return hooks[hook] if block.nil?
|
|
@@ -71,15 +71,40 @@ module OpenapiFirst
|
|
|
71
71
|
"#<#{self.class.name} @key='#{key}'>"
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Resolves the path for the named operation, filling in any `{param}` placeholders.
|
|
75
|
+
# @param operation_id [String, Symbol] An operationId present in this API description.
|
|
76
|
+
# @param params [Hash] Path-parameter values keyed by name (String or Symbol).
|
|
77
|
+
# @return [String] The resolved path (e.g. "/pets/42").
|
|
78
|
+
# @raise [ArgumentError] if the operationId is not found or a required path parameter is missing.
|
|
79
|
+
def path_for(params = {}, operation_id:)
|
|
80
|
+
request_def = routes.lazy.flat_map(&:requests).find { |r| r.operation_id == operation_id.to_s }
|
|
81
|
+
raise ArgumentError, "Operation #{operation_id.inspect} is not defined in #{key}." unless request_def
|
|
82
|
+
|
|
83
|
+
request_def.path.gsub(/\{([^}]+)\}/) do
|
|
84
|
+
name = Regexp.last_match(1)
|
|
85
|
+
params.fetch(name.to_sym) do
|
|
86
|
+
params.fetch(name) do
|
|
87
|
+
raise ArgumentError, "Missing path parameter #{name.inspect} for operation #{operation_id.inspect}."
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
74
93
|
# Validates the request against the API description.
|
|
75
94
|
# @param [Rack::Request] request The Rack request object.
|
|
76
95
|
# @param [Boolean] raise_error Whether to raise an error if validation fails.
|
|
96
|
+
# @param [String,nil] path_template The OpenAPI path template (e.g. "/pets/{petId}") of the
|
|
97
|
+
# already-matched route. Pass this when your own router has matched the route, to skip
|
|
98
|
+
# openapi_first's path matching. Used by framework integrations like Sinatra.
|
|
99
|
+
# @param [Hash,nil] path_params The path parameters extracted by your own router
|
|
100
|
+
# (e.g. { "petId" => "42" }), keyed by parameter name. Pass this together with +path_template+
|
|
101
|
+
# to skip openapi_first's path-parameter extraction; otherwise they are extracted from the path.
|
|
77
102
|
# @yield [ValidatedRequest] Optional block called after successful validation.
|
|
78
103
|
# The block runs inside the same catch(FAILURE) as the after_request_validation hooks,
|
|
79
104
|
# so it may call OpenapiFirst::Failure.fail! to short-circuit and produce an error.
|
|
80
105
|
# @return [ValidatedRequest] The validated request object.
|
|
81
|
-
def validate_request(request, raise_error: false, &after_block)
|
|
82
|
-
route =
|
|
106
|
+
def validate_request(request, raise_error: false, path_template: nil, path_params: nil, &after_block)
|
|
107
|
+
route = match_route(request, path_template, params: path_params)
|
|
83
108
|
validated = if route.error
|
|
84
109
|
ValidatedRequest.new(request, error: route.error)
|
|
85
110
|
else
|
|
@@ -117,6 +142,14 @@ module OpenapiFirst
|
|
|
117
142
|
|
|
118
143
|
private
|
|
119
144
|
|
|
145
|
+
def match_route(request, path_template, params:)
|
|
146
|
+
request_method = request.request_method
|
|
147
|
+
content_type = request.content_type
|
|
148
|
+
return @router.match_route(request_method, path_template, params:, content_type:) if path_template
|
|
149
|
+
|
|
150
|
+
@router.match(request_method, resolve_path(request), content_type:)
|
|
151
|
+
end
|
|
152
|
+
|
|
120
153
|
def call_before_request_validation_hooks(request, request_definition)
|
|
121
154
|
return if @config.before_request_validation.none?
|
|
122
155
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module OpenapiFirst
|
|
4
4
|
# A failure object returned when validation or parsing of a request or response has failed.
|
|
5
5
|
# This returned in ValidatedRequest#error and ValidatedResponse#error.
|
|
6
|
-
class Failure < Data.define(:type, :message, :errors)
|
|
6
|
+
class Failure < Data.define(:type, :message, :errors)
|
|
7
7
|
TYPES = {
|
|
8
8
|
not_found: [NotFoundError, 'Not found.'],
|
|
9
9
|
method_not_allowed: [RequestInvalidError, 'Request method is not defined.'],
|
|
@@ -49,6 +49,10 @@ module OpenapiFirst
|
|
|
49
49
|
alias original_message message
|
|
50
50
|
private :original_message
|
|
51
51
|
|
|
52
|
+
def inspect
|
|
53
|
+
"#<OpenapiFirst::Failure:#{object_id} type: #{type}, message: #{message}>"
|
|
54
|
+
end
|
|
55
|
+
|
|
52
56
|
# A generic error message
|
|
53
57
|
def message
|
|
54
58
|
original_message || exception_message
|
|
@@ -36,7 +36,7 @@ module OpenapiFirst
|
|
|
36
36
|
options = spec
|
|
37
37
|
spec = options[:spec]
|
|
38
38
|
end
|
|
39
|
-
@raise = options.fetch(:raise_error,
|
|
39
|
+
@raise = options.fetch(:raise_error, false)
|
|
40
40
|
@error_response_class = error_response_option(options[:error_response])
|
|
41
41
|
|
|
42
42
|
spec ||= :default
|