openapi_first 2.11.1 → 3.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 +46 -0
- data/README.md +72 -56
- data/lib/openapi_first/builder.rb +2 -2
- data/lib/openapi_first/child_configuration.rb +26 -0
- data/lib/openapi_first/configuration.rb +36 -5
- data/lib/openapi_first/definition.rb +7 -3
- data/lib/openapi_first/failure.rb +2 -7
- data/lib/openapi_first/middlewares/request_validation.rb +7 -5
- data/lib/openapi_first/middlewares/response_validation.rb +6 -3
- data/lib/openapi_first/registry.rb +44 -0
- data/lib/openapi_first/router/find_response.rb +13 -9
- data/lib/openapi_first/router/path_template.rb +1 -1
- data/lib/openapi_first/schema/validation_error.rb +0 -18
- data/lib/openapi_first/test/app.rb +30 -0
- data/lib/openapi_first/test/callable.rb +1 -1
- data/lib/openapi_first/test/configuration.rb +22 -14
- data/lib/openapi_first/test/coverage/covered_request.rb +11 -0
- data/lib/openapi_first/test/coverage/covered_response.rb +11 -0
- data/lib/openapi_first/test/coverage/plan.rb +2 -2
- data/lib/openapi_first/test/coverage/tracker.rb +32 -0
- data/lib/openapi_first/test/coverage.rb +55 -16
- data/lib/openapi_first/test/observe.rb +2 -0
- data/lib/openapi_first/test.rb +19 -20
- data/lib/openapi_first/validated_response.rb +8 -6
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +6 -1
- metadata +9 -4
- data/lib/openapi_first/test/registry.rb +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7474086c282336d7e77afe8143a0bda6e0a4a36fe284a746804bd933bdfd45a6
|
|
4
|
+
data.tar.gz: 7538b4bd0e9f30f3d95c5cb06842b03e15644ce441018577c467629ce62d9772
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d1469a59f294ce4b386fb60da3e7c019773a8516005d1b3c0f60bd26158e62a879367647cab558579085a3b3c866055ac70b3df255c2fb38d7d918e10892154
|
|
7
|
+
data.tar.gz: 835f24859d1510dfbe6a3fa99f1d9a496a0011441b041f8d92ee884b5938d53b5550fbe336a36adf44e6f8711fc30ec5333a7310de65cc93da8402e107b6beb4
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 3.0.0
|
|
6
|
+
|
|
7
|
+
### openapi_first
|
|
8
|
+
|
|
9
|
+
#### Changed
|
|
10
|
+
- Breaking: Trailing slashes are no longer ignored in dynamic paths. See [#403](https://github.com/ahx/openapi_first/issues/403).
|
|
11
|
+
Before this change `GET /things/24/` matched `/things/{id}:`, but it no longer does.
|
|
12
|
+
- Breaking: Failure type `:response_not_found` was split into two more specific types `:response_content_type_not_found` and `:response_status_not_found`. This should be mostly internal stuff. So if your custom error response used `response_not_found`, you will have to adapt.
|
|
13
|
+
- Deprecated configuration fields `request_validation_raise_error` and `response_validation_raise_error`. Please pass the `raise_error:` option to the middlewares directly.
|
|
14
|
+
|
|
15
|
+
#### Added
|
|
16
|
+
- Added support to register OADs globally via:
|
|
17
|
+
```ruby
|
|
18
|
+
OpenapiFirst.configure { |config| config.register('openapi.yaml') }
|
|
19
|
+
```
|
|
20
|
+
This makes the `spec` argument in middlewares optional and removes the necessity to load the OAD in the same place where you use the middlewares and adds a cache for parsed OADs.
|
|
21
|
+
|
|
22
|
+
#### Removed
|
|
23
|
+
- Removed deprecated methods which produced a warning since 2.0.0.
|
|
24
|
+
- Removed `OpenapiFirst::Configuration#clone`. Use `#child` instead.
|
|
25
|
+
- It's no longer supported to remove locally added hooks during runtime.
|
|
26
|
+
|
|
27
|
+
#### Fixed
|
|
28
|
+
- Update dependency `openapi_parameters` to >= 0.7.0, because that version supports unpacking parameters the use `style: deepObject` with `explode: true`.
|
|
29
|
+
- Make `OpenapiFirst::Test.setup` more robust by adding `OpenapiFirst::Configuration#child` so it does not matter if you load our OAD before callig `OpenapiFirst::Test.setup`.
|
|
30
|
+
|
|
31
|
+
### openapi_first/test
|
|
32
|
+
|
|
33
|
+
#### Changed
|
|
34
|
+
- `OpenapiFirst::Test.app` now returns an instance of `OpenapiFirst::Test::App`, instead of `Rack::Builer` and delegates methods other than `#call` to the original app. This wrapper adds validated requests, responses to the rack env at `env[OpenapiFirst::Test::REQUEST]`, `env[OpenapiFirst::Test::RESPONSE]`. This makes it possible to test Rails engines. Thanks to Josh! See [#410](https://github.com/ahx/openapi_first/issues/410).
|
|
35
|
+
- `OpenapiFirst::Test` now falls back to using globally registered OADs if nothing was registered inside `OpenapiFirst::Test.setup`.
|
|
36
|
+
- 401er and 500er status are okay to not be described.
|
|
37
|
+
|
|
38
|
+
#### Added
|
|
39
|
+
- The Coverage feature in `OpenapiFirst::Test` now supports parallel tests via a DRB client/sever. Thanks to Richard! See [#394](https://github.com/ahx/openapi_first/issues/394).
|
|
40
|
+
- Added `OpenapiFirst::Test` Configuration options which are useful when adopting OpenAPI:
|
|
41
|
+
- `ignore_unknown_response_status = true` to make API coverage no longer complain about undefined response statuses it sees during a test run.
|
|
42
|
+
- `minimum_coverage=` is no longer deprecated. This is useful when gradually adopting OpenAPI
|
|
43
|
+
- `ignored_unknown_status=` to overwrite the whole list of ignored unknown status at once
|
|
44
|
+
|
|
45
|
+
#### Removed
|
|
46
|
+
- Removed internally used `Test::Coverage.current_run, .plans, .install, .uninstall`. If you are using these, use `OpenapiFirst::Test.setup` instead.
|
|
47
|
+
|
|
48
|
+
#### Fixed
|
|
49
|
+
- Make `OpenapiFirst::Test.setup` more robust by adding `OpenapiFirst::Configuration#child` so it does not matter if you load our OAD before callig `OpenapiFirst::Test.setup`.
|
|
50
|
+
|
|
5
51
|
## 2.11.1
|
|
6
52
|
|
|
7
53
|
- OpenapiFirst can now route requests correctly for paths like `/stuffs` and `/stuffs{format}` (https://github.com/ahx/openapi_first/issues/386)
|
data/README.md
CHANGED
|
@@ -4,18 +4,21 @@ openapi_first is a Ruby gem for request / response validation and contract-testi
|
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
|
+
Configure
|
|
8
|
+
```ruby
|
|
9
|
+
OpenapiFirst.register('openapi/openapi.yaml')
|
|
10
|
+
```
|
|
11
|
+
|
|
7
12
|
Use an OAD to validate incoming requests:
|
|
8
13
|
```ruby
|
|
9
|
-
use OpenapiFirst::Middlewares::RequestValidation
|
|
14
|
+
use OpenapiFirst::Middlewares::RequestValidation
|
|
10
15
|
```
|
|
11
16
|
|
|
12
17
|
Turn your request tests into [contract tests](#contract-testing) against an OAD:
|
|
13
18
|
```ruby
|
|
14
19
|
# spec_helper.rb
|
|
15
20
|
require 'openapi_first'
|
|
16
|
-
OpenapiFirst::Test.setup
|
|
17
|
-
config.register('openapi/openapi.yaml')
|
|
18
|
-
end
|
|
21
|
+
OpenapiFirst::Test.setup
|
|
19
22
|
|
|
20
23
|
require 'my_app'
|
|
21
24
|
RSpec.configure do |config|
|
|
@@ -27,6 +30,7 @@ end
|
|
|
27
30
|
|
|
28
31
|
<!-- TOC -->
|
|
29
32
|
|
|
33
|
+
- [Configuration](#configuration)
|
|
30
34
|
- [Rack Middlewares](#rack-middlewares)
|
|
31
35
|
- [Request validation](#request-validation)
|
|
32
36
|
- [Response validation](#response-validation)
|
|
@@ -34,7 +38,6 @@ end
|
|
|
34
38
|
- [Test assertions](#test-assertions)
|
|
35
39
|
- [Manual use](#manual-use)
|
|
36
40
|
- [Framework integration](#framework-integration)
|
|
37
|
-
- [Configuration](#configuration)
|
|
38
41
|
- [Hooks](#hooks)
|
|
39
42
|
- [Alternatives](#alternatives)
|
|
40
43
|
- [Frequently Asked Questions](#frequently-asked-questions)
|
|
@@ -44,6 +47,33 @@ end
|
|
|
44
47
|
|
|
45
48
|
<!-- /TOC -->
|
|
46
49
|
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
You should register OADs globally so you don't have to load the file multiple times or to refernce them by Symbol (like :v1 in this example).
|
|
53
|
+
```ruby
|
|
54
|
+
OpenapiFirst.configure do |config|
|
|
55
|
+
config.register('openapi/openapi.yaml') # :default
|
|
56
|
+
config.register('openapi/v1.openapi.yaml', as: :v1)
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
You can configure default options globally:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
OpenapiFirst.configure do |config|
|
|
64
|
+
# Specify which plugin is used to render error responses returned by the request validation middleware (defaults to :default)
|
|
65
|
+
config.request_validation_error_response = :jsonapi
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
or configure per instance:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
OpenapiFirst.load('openapi.yaml') do |config|
|
|
73
|
+
config.request_validation_error_response = :jsonapi
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
47
77
|
## Rack Middlewares
|
|
48
78
|
|
|
49
79
|
### Request validation
|
|
@@ -51,10 +81,10 @@ end
|
|
|
51
81
|
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 exactly 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.
|
|
52
82
|
|
|
53
83
|
```ruby
|
|
54
|
-
use OpenapiFirst::Middlewares::RequestValidation
|
|
84
|
+
use OpenapiFirst::Middlewares::RequestValidation
|
|
55
85
|
|
|
56
86
|
# Pass `raise_error: true` to raise an error if request is invalid:
|
|
57
|
-
use OpenapiFirst::Middlewares::RequestValidation,
|
|
87
|
+
use OpenapiFirst::Middlewares::RequestValidation, raise_error: true
|
|
58
88
|
```
|
|
59
89
|
|
|
60
90
|
#### Error responses
|
|
@@ -92,7 +122,7 @@ content-type: "application/problem+json"
|
|
|
92
122
|
openapi_first offers a [JSON:API](https://jsonapi.org/) error response by passing `error_response: :jsonapi`:
|
|
93
123
|
|
|
94
124
|
```ruby
|
|
95
|
-
use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml, error_response: :jsonapi
|
|
125
|
+
use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml', error_response: :jsonapi
|
|
96
126
|
```
|
|
97
127
|
|
|
98
128
|
<details>
|
|
@@ -145,10 +175,10 @@ This middleware raises an error by default if the response is not valid.
|
|
|
145
175
|
This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
|
|
146
176
|
|
|
147
177
|
```ruby
|
|
148
|
-
use OpenapiFirst::Middlewares::ResponseValidation
|
|
178
|
+
use OpenapiFirst::Middlewares::ResponseValidation if ENV['RACK_ENV'] == 'test'
|
|
149
179
|
|
|
150
180
|
# Pass `raise_error: false` to not raise an error:
|
|
151
|
-
use OpenapiFirst::Middlewares::ResponseValidation,
|
|
181
|
+
use OpenapiFirst::Middlewares::ResponseValidation, raise_error: false
|
|
152
182
|
```
|
|
153
183
|
|
|
154
184
|
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.
|
|
@@ -159,13 +189,11 @@ You can see your OpenAPI API description as a contract that your clients can rel
|
|
|
159
189
|
|
|
160
190
|
Here is how to set it up:
|
|
161
191
|
|
|
162
|
-
1.
|
|
163
|
-
This should go at the top of your test helper file before loading your application code.
|
|
192
|
+
1. Setup the test mode
|
|
164
193
|
```ruby
|
|
194
|
+
# spec_helper.rb
|
|
165
195
|
require 'openapi_first'
|
|
166
|
-
OpenapiFirst::Test.setup
|
|
167
|
-
config.register('openapi/openapi.yaml')
|
|
168
|
-
end
|
|
196
|
+
OpenapiFirst::Test.setup
|
|
169
197
|
```
|
|
170
198
|
2. Observe your application. You can do this in multiple ways:
|
|
171
199
|
- Add an `app` method to your tests (which is called by rack-test) that wraps your application with silent request / response validation.
|
|
@@ -188,7 +216,7 @@ Here is how to set it up:
|
|
|
188
216
|
config.include OpenapiFirst::Test::Methods[MyApp], type: :request
|
|
189
217
|
end
|
|
190
218
|
```
|
|
191
|
-
|
|
219
|
+
3. Run your tests. The Coverage feature will tell you about missing or invalid requests/responses:
|
|
192
220
|
```
|
|
193
221
|
✓ GET /stations
|
|
194
222
|
✓ 200(application/json)
|
|
@@ -203,11 +231,19 @@ Here is how to set it up:
|
|
|
203
231
|
|
|
204
232
|
### Configure test coverage
|
|
205
233
|
|
|
206
|
-
OpenapiFirst::Test raises an error when a response status is not defined. You can
|
|
234
|
+
OpenapiFirst::Test raises an error when a response status is not defined except for 404 and 500. You can change this:
|
|
207
235
|
|
|
208
236
|
```ruby
|
|
209
237
|
OpenapiFirst::Test.setup do |test|
|
|
210
|
-
|
|
238
|
+
test.ignored_unknown_status << 403
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Or you can ignore all unknown response status:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
OpenapiFirst::Test.setup do |test|
|
|
246
|
+
test.ignore_all_unknown_status = true
|
|
211
247
|
end
|
|
212
248
|
```
|
|
213
249
|
|
|
@@ -215,7 +251,6 @@ Exclude certain _responses_ from coverage with `skip_coverage`:
|
|
|
215
251
|
|
|
216
252
|
```ruby
|
|
217
253
|
OpenapiFirst::Test.setup do |test|
|
|
218
|
-
# …
|
|
219
254
|
test.skip_response_coverage do |response_definition|
|
|
220
255
|
response_definition.status == '5XX'
|
|
221
256
|
end
|
|
@@ -226,7 +261,6 @@ Skip coverage for a request and all responses alltogether of a route with `skip_
|
|
|
226
261
|
|
|
227
262
|
```ruby
|
|
228
263
|
OpenapiFirst::Test.setup do |test|
|
|
229
|
-
# …
|
|
230
264
|
test.skip_coverage do |path, request_method|
|
|
231
265
|
path == '/bookings/{bookingId}' && requests_method == 'DELETE'
|
|
232
266
|
end
|
|
@@ -237,32 +271,36 @@ OpenapiFirst::Test raises an error when a request is not defined. You can deacti
|
|
|
237
271
|
|
|
238
272
|
```ruby
|
|
239
273
|
OpenapiFirst::Test.setup do |test|
|
|
240
|
-
# …
|
|
241
274
|
test.ignore_unknown_requests = true
|
|
242
275
|
end
|
|
243
276
|
```
|
|
244
277
|
|
|
245
278
|
### Test assertions
|
|
246
279
|
|
|
247
|
-
|
|
280
|
+
> [!WARNING]
|
|
281
|
+
> You probably don't need this. Just setup [Contract testing and API coverage](#contract-testing) and use your normal assertions.
|
|
248
282
|
|
|
249
|
-
|
|
283
|
+
openapi_first ships with a simple but powerful Test method `assert_api_conform` to run request and response validation in your tests without using the middlewares. This is designed to be used with rack-test.
|
|
284
|
+
|
|
285
|
+
Here is how to use it with RSpec, but MiniTest works just as good:
|
|
250
286
|
|
|
251
|
-
Inside your test:
|
|
252
287
|
```ruby
|
|
253
|
-
#
|
|
254
|
-
|
|
288
|
+
# spec_helper.rb
|
|
289
|
+
OpenapiFirst::Test.setup do |test|
|
|
290
|
+
test.register(File.join(__dir__, '../examples/openapi.yaml'), as: :example_app)
|
|
291
|
+
end
|
|
292
|
+
```
|
|
255
293
|
|
|
256
|
-
class TripsApiTest < ActionDispatch::IntegrationTest
|
|
257
|
-
include OpenapiFirst::Test::Methods
|
|
258
294
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
295
|
+
Inside your test :
|
|
296
|
+
```ruby
|
|
297
|
+
RSpec.describe 'Example App' do
|
|
298
|
+
include Rack::Test::Methods
|
|
299
|
+
include OpenapiFirst::Test::Methods[App]
|
|
263
300
|
|
|
301
|
+
it 'is API conform' do
|
|
302
|
+
get '/'
|
|
264
303
|
assert_api_conform(status: 200)
|
|
265
|
-
# assert_api_conform(status: 200, api: :v1) # Or this if you have multiple API descriptions
|
|
266
304
|
end
|
|
267
305
|
end
|
|
268
306
|
```
|
|
@@ -320,27 +358,6 @@ validated_response.parsed_headers
|
|
|
320
358
|
definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
|
|
321
359
|
```
|
|
322
360
|
|
|
323
|
-
## Configuration
|
|
324
|
-
|
|
325
|
-
You can configure default options globally:
|
|
326
|
-
|
|
327
|
-
```ruby
|
|
328
|
-
OpenapiFirst.configure do |config|
|
|
329
|
-
# Specify which plugin is used to render error responses returned by the request validation middleware (defaults to :default)
|
|
330
|
-
config.request_validation_error_response = :jsonapi
|
|
331
|
-
# Configure if the request validation middleware should raise an exception (defaults to false)
|
|
332
|
-
config.request_validation_raise_error = true
|
|
333
|
-
end
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
or configure per instance:
|
|
337
|
-
|
|
338
|
-
```ruby
|
|
339
|
-
OpenapiFirst.load('openapi.yaml') do |config|
|
|
340
|
-
config.request_validation_error_response = :jsonapi
|
|
341
|
-
end
|
|
342
|
-
```
|
|
343
|
-
|
|
344
361
|
## Hooks
|
|
345
362
|
|
|
346
363
|
You can integrate your code at certain points during request/response validation via hooks.
|
|
@@ -380,11 +397,10 @@ end
|
|
|
380
397
|
## Framework integration
|
|
381
398
|
|
|
382
399
|
Using rack middlewares is supported in probably all Ruby web frameworks.
|
|
383
|
-
If you are using Ruby on Rails for example, you can add the request validation middleware globally in `config/application.rb` or inside specific controllers.
|
|
384
400
|
|
|
385
401
|
The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.
|
|
386
402
|
|
|
387
|
-
That aside, closer integration with specific frameworks like Sinatra, Hanami, Roda or
|
|
403
|
+
That aside, closer integration with specific frameworks like Sinatra, 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).
|
|
388
404
|
|
|
389
405
|
## Alternatives
|
|
390
406
|
|
|
@@ -125,7 +125,7 @@ module OpenapiFirst
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
|
|
128
|
-
after_property_validation: config.
|
|
128
|
+
after_property_validation: config.after_request_parameter_property_validation)
|
|
129
129
|
end
|
|
130
130
|
|
|
131
131
|
def build_requests(path:, request_method:, operation_object:, parameters:)
|
|
@@ -139,7 +139,7 @@ module OpenapiFirst
|
|
|
139
139
|
content_objects.map do |content_type, content_object|
|
|
140
140
|
content_schema = content_object['schema'].schema(
|
|
141
141
|
configuration: schemer_configuration,
|
|
142
|
-
after_property_validation: config.
|
|
142
|
+
after_property_validation: config.after_request_body_property_validation
|
|
143
143
|
)
|
|
144
144
|
Request.new(path:, request_method:, parameters:,
|
|
145
145
|
operation_object: operation_object.resolved,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# A subclass to configuration that points to its parent
|
|
5
|
+
class ChildConfiguration < Configuration
|
|
6
|
+
def initialize(parent:)
|
|
7
|
+
super()
|
|
8
|
+
@parent = parent
|
|
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
|
+
@path = parent.path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private attr_reader :parent
|
|
16
|
+
|
|
17
|
+
HOOKS.each do |hook|
|
|
18
|
+
define_method(hook) do |&block|
|
|
19
|
+
return hooks[hook].chain(parent.hooks[hook]) if block.nil?
|
|
20
|
+
|
|
21
|
+
hooks[hook] << block
|
|
22
|
+
block
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -18,17 +18,48 @@ module OpenapiFirst
|
|
|
18
18
|
@path = nil
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
def register(path_or_definition, as: :default)
|
|
22
|
+
OpenapiFirst.register(path_or_definition, as:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :hooks, :request_validation_error_response
|
|
26
|
+
attr_accessor :path
|
|
27
|
+
|
|
28
|
+
# @deprecated
|
|
29
|
+
attr_reader :request_validation_raise_error
|
|
30
|
+
# @deprecated
|
|
31
|
+
attr_reader :response_validation_raise_error
|
|
32
|
+
|
|
33
|
+
# Return a child configuration that still receives updates of global hooks.
|
|
34
|
+
def child
|
|
35
|
+
ChildConfiguration.new(parent: self)
|
|
36
|
+
end
|
|
23
37
|
|
|
38
|
+
# @visibility private
|
|
24
39
|
def clone
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
raise NoMethodError, 'OpenapiFirst::Configuration#clone was removed. You want to call #child instead'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::RequestValidation directly
|
|
44
|
+
def request_validation_raise_error=(value)
|
|
45
|
+
message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
|
|
46
|
+
'Please pass `raise_error:` to `OpenapiFirst::Middlewares::RequestValidation directly`'
|
|
47
|
+
warn message, category: :deprecated
|
|
48
|
+
@request_validation_raise_error = value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::ResponseValidation directly
|
|
52
|
+
def response_validation_raise_error=(value)
|
|
53
|
+
message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
|
|
54
|
+
'Please pass `raise_error:` to `OpenapiFirst::Middlewares::ResponseValidation directly`'
|
|
55
|
+
warn message
|
|
56
|
+
@response_validation_raise_error = value
|
|
28
57
|
end
|
|
29
58
|
|
|
30
59
|
HOOKS.each do |hook|
|
|
31
60
|
define_method(hook) do |&block|
|
|
61
|
+
return hooks[hook] if block.nil?
|
|
62
|
+
|
|
32
63
|
hooks[hook] << block
|
|
33
64
|
block
|
|
34
65
|
end
|
|
@@ -22,7 +22,7 @@ module OpenapiFirst
|
|
|
22
22
|
# @param filepath [String] The file path of the OpenAPI document.
|
|
23
23
|
def initialize(contents, filepath = nil)
|
|
24
24
|
@filepath = filepath
|
|
25
|
-
@config = OpenapiFirst.configuration.
|
|
25
|
+
@config = OpenapiFirst.configuration.child
|
|
26
26
|
yield @config if block_given?
|
|
27
27
|
@config.freeze
|
|
28
28
|
@router = Builder.build_router(contents, filepath:, config:)
|
|
@@ -58,6 +58,10 @@ module OpenapiFirst
|
|
|
58
58
|
"#{title} @ #{version}"
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
def inspect
|
|
62
|
+
"#<#{self.class.name} @key='#{key}'>"
|
|
63
|
+
end
|
|
64
|
+
|
|
61
65
|
# Validates the request against the API description.
|
|
62
66
|
# @param [Rack::Request] request The Rack request object.
|
|
63
67
|
# @param [Boolean] raise_error Whether to raise an error if validation fails.
|
|
@@ -69,7 +73,7 @@ module OpenapiFirst
|
|
|
69
73
|
else
|
|
70
74
|
route.request_definition.validate(request, route_params: route.params)
|
|
71
75
|
end.tap do |validated|
|
|
72
|
-
@config.
|
|
76
|
+
@config.after_request_validation.each { |hook| hook.call(validated, self) }
|
|
73
77
|
raise validated.error.exception(validated) if validated.error && raise_error
|
|
74
78
|
end
|
|
75
79
|
end
|
|
@@ -91,7 +95,7 @@ module OpenapiFirst
|
|
|
91
95
|
else
|
|
92
96
|
response_match.response.validate(rack_response)
|
|
93
97
|
end
|
|
94
|
-
@config.
|
|
98
|
+
@config.after_response_validation&.each { |hook| hook.call(validated, rack_request, self) }
|
|
95
99
|
raise validated.error.exception(validated) if raise_error && validated.invalid?
|
|
96
100
|
|
|
97
101
|
validated
|
|
@@ -13,7 +13,8 @@ module OpenapiFirst
|
|
|
13
13
|
invalid_header: [RequestInvalidError, 'Request header is invalid:'],
|
|
14
14
|
invalid_path: [RequestInvalidError, 'Path segment is invalid:'],
|
|
15
15
|
invalid_cookie: [RequestInvalidError, 'Cookie value is invalid:'],
|
|
16
|
-
|
|
16
|
+
response_content_type_not_found: [ResponseNotFoundError],
|
|
17
|
+
response_status_not_found: [ResponseNotFoundError],
|
|
17
18
|
invalid_response_body: [ResponseInvalidError, 'Response body is invalid:'],
|
|
18
19
|
invalid_response_header: [ResponseInvalidError, 'Response header is invalid:']
|
|
19
20
|
}.freeze
|
|
@@ -65,12 +66,6 @@ module OpenapiFirst
|
|
|
65
66
|
[message_prefix, @message || generate_message].compact.join(' ')
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
# @deprecated Please use {#type} instead
|
|
69
|
-
def error_type
|
|
70
|
-
warn 'OpenapiFirst::Failure#error_type is deprecated. Use #type instead.'
|
|
71
|
-
type
|
|
72
|
-
end
|
|
73
|
-
|
|
74
69
|
private
|
|
75
70
|
|
|
76
71
|
def generate_message
|
|
@@ -6,9 +6,11 @@ module OpenapiFirst
|
|
|
6
6
|
# A Rack middleware to validate requests against an OpenAPI API description
|
|
7
7
|
class RequestValidation
|
|
8
8
|
# @param app The parent Rack application
|
|
9
|
-
# @param spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
9
|
+
# @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
10
|
+
# If you pass a Symbol, it will load the OAD registered via `OpenapiFirst.register`
|
|
11
|
+
# If you leave this blank, it will load the OAD registered as `:default`
|
|
10
12
|
# @param options Hash
|
|
11
|
-
# :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
13
|
+
# :spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
12
14
|
# This will be deprecated. Please use spec argument instead.
|
|
13
15
|
# :raise_error A Boolean indicating whether to raise an error if validation fails.
|
|
14
16
|
# default: false
|
|
@@ -20,13 +22,13 @@ module OpenapiFirst
|
|
|
20
22
|
@app = app
|
|
21
23
|
if spec.is_a?(Hash)
|
|
22
24
|
options = spec
|
|
23
|
-
spec = options
|
|
25
|
+
spec = options[:spec]
|
|
24
26
|
end
|
|
25
27
|
@raise = options.fetch(:raise_error, OpenapiFirst.configuration.request_validation_raise_error)
|
|
26
28
|
@error_response_class = error_response_option(options[:error_response])
|
|
27
29
|
|
|
28
|
-
spec ||=
|
|
29
|
-
|
|
30
|
+
spec ||= :default
|
|
31
|
+
spec = OpenapiFirst[spec] if spec.is_a?(Symbol)
|
|
30
32
|
|
|
31
33
|
@definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
|
|
32
34
|
end
|
|
@@ -6,7 +6,9 @@ module OpenapiFirst
|
|
|
6
6
|
module Middlewares
|
|
7
7
|
# A Rack middleware to validate requests against an OpenAPI API description
|
|
8
8
|
class ResponseValidation
|
|
9
|
-
# @param spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition
|
|
9
|
+
# @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
10
|
+
# If you pass a Symbol, it will load the OAD registered via `OpenapiFirst.register`
|
|
11
|
+
# If you leave this blank, it will load the OAD registered as `:default`
|
|
10
12
|
# @param options Hash
|
|
11
13
|
# :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
12
14
|
# This will be deprecated. Please use spec argument instead.
|
|
@@ -15,11 +17,12 @@ module OpenapiFirst
|
|
|
15
17
|
@app = app
|
|
16
18
|
if spec.is_a?(Hash)
|
|
17
19
|
options = spec
|
|
18
|
-
spec = options
|
|
20
|
+
spec = options[:spec]
|
|
19
21
|
end
|
|
20
22
|
@raise = options.fetch(:raise_error, OpenapiFirst.configuration.response_validation_raise_error)
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
spec ||= :default
|
|
25
|
+
spec = OpenapiFirst[spec] if spec.is_a?(Symbol)
|
|
23
26
|
|
|
24
27
|
@definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
|
|
25
28
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
class NotRegisteredError < Error; end
|
|
5
|
+
class AlreadyRegisteredError < Error; end
|
|
6
|
+
|
|
7
|
+
# @visibility private
|
|
8
|
+
module Registry
|
|
9
|
+
def definitions
|
|
10
|
+
@definitions ||= {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Register an OpenAPI definition for testing
|
|
14
|
+
# @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object
|
|
15
|
+
# @param as [Symbol] Name to register the API definition as
|
|
16
|
+
def register(path_or_definition, as: :default)
|
|
17
|
+
if definitions.key?(as) && as == :default
|
|
18
|
+
raise(
|
|
19
|
+
AlreadyRegisteredError,
|
|
20
|
+
"#{definitions[as].inspect} is already registered " \
|
|
21
|
+
"as ':default' so you cannot register #{path_or_definition.inspect} without " \
|
|
22
|
+
'giving it a custom name. Please call register with a custom key like: ' \
|
|
23
|
+
"#{name}.register(#{path_or_definition.inspect}, as: :my_other_api)"
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
definition = OpenapiFirst.load(path_or_definition)
|
|
28
|
+
definitions[as] = definition
|
|
29
|
+
definition
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def [](api)
|
|
33
|
+
return api if api.is_a?(Definition)
|
|
34
|
+
|
|
35
|
+
definitions.fetch(api) do
|
|
36
|
+
option = api == :default ? '' : ", as: #{api.inspect}"
|
|
37
|
+
raise(NotRegisteredError,
|
|
38
|
+
"API description '#{api.inspect}' not found." \
|
|
39
|
+
"Please call #{name}.register('myopenapi.yaml'#{option}) " \
|
|
40
|
+
'once before running your app.')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -10,18 +10,15 @@ module OpenapiFirst
|
|
|
10
10
|
|
|
11
11
|
def self.call(responses, status, content_type, request_method:, path:)
|
|
12
12
|
contents = find_status(responses, status)
|
|
13
|
-
if contents.nil?
|
|
14
|
-
|
|
15
|
-
"Defined statuses are: #{responses.keys.join(', ')}."
|
|
16
|
-
return Match.new(error: Failure.new(:response_not_found, message:), response: nil)
|
|
17
|
-
end
|
|
13
|
+
return response_status_not_found(status:, request_method:, path:, responses:) if contents.nil?
|
|
14
|
+
|
|
18
15
|
response = FindContent.call(contents, content_type)
|
|
19
|
-
return
|
|
16
|
+
return response_content_type_not_found(content_type:, contents:, request_method:, path:) unless response
|
|
20
17
|
|
|
21
18
|
Match.new(response:, error: nil)
|
|
22
19
|
end
|
|
23
20
|
|
|
24
|
-
def self.
|
|
21
|
+
def self.response_content_type_not_found(content_type:, contents:, request_method:, path:)
|
|
25
22
|
empty_content = content_type.nil? || content_type.empty?
|
|
26
23
|
message =
|
|
27
24
|
"Content-Type should be #{contents.keys.join(' or ')}, " \
|
|
@@ -29,11 +26,18 @@ module OpenapiFirst
|
|
|
29
26
|
"#{request_method.upcase} #{path}"
|
|
30
27
|
|
|
31
28
|
Match.new(
|
|
32
|
-
error: Failure.new(:
|
|
29
|
+
error: Failure.new(:response_content_type_not_found, message:),
|
|
33
30
|
response: nil
|
|
34
31
|
)
|
|
35
32
|
end
|
|
36
|
-
private_class_method :
|
|
33
|
+
private_class_method :response_content_type_not_found
|
|
34
|
+
|
|
35
|
+
def self.response_status_not_found(status:, request_method:, path:, responses:)
|
|
36
|
+
message = "Status #{status} is not defined for #{request_method.upcase} #{path}. " \
|
|
37
|
+
"Defined statuses are: #{responses.keys.join(', ')}."
|
|
38
|
+
Match.new(error: Failure.new(:response_status_not_found, message:), response: nil)
|
|
39
|
+
end
|
|
40
|
+
private_class_method :response_status_not_found
|
|
37
41
|
|
|
38
42
|
def self.find_status(responses, status)
|
|
39
43
|
# According to OAS status has to be a string,
|
|
@@ -40,24 +40,6 @@ module OpenapiFirst
|
|
|
40
40
|
"value at #{location} is invalid (#{type.inspect})"
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
|
-
|
|
44
|
-
# @deprecated Please use {#message} instead
|
|
45
|
-
def error
|
|
46
|
-
warn 'OpenapiFirst::Schema::ValidationError#error is deprecated. Use #message instead.'
|
|
47
|
-
message
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# @deprecated Please use {#data_pointer} instead
|
|
51
|
-
def instance_location
|
|
52
|
-
warn 'OpenapiFirst::Schema::ValidationError#instance_location is deprecated. Use #data_pointer instead.'
|
|
53
|
-
data_pointer
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# @deprecated Please use {#schema_pointer} instead
|
|
57
|
-
def schema_location
|
|
58
|
-
warn 'OpenapiFirst::Schema::ValidationError#schema_location is deprecated. Use #schema_pointer instead.'
|
|
59
|
-
schema_pointer
|
|
60
|
-
end
|
|
61
43
|
end
|
|
62
44
|
end
|
|
63
45
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'observe'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
module Test
|
|
7
|
+
REQUEST = 'openapi.test.request'
|
|
8
|
+
RESPONSE = 'openapi.test.response'
|
|
9
|
+
|
|
10
|
+
# A wrapper of the original app
|
|
11
|
+
# with silent request/response validation to track requests/responses.
|
|
12
|
+
class App < SimpleDelegator
|
|
13
|
+
def initialize(app, api:)
|
|
14
|
+
super(app)
|
|
15
|
+
@app = app
|
|
16
|
+
@definition = Test[api]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
request = Rack::Request.new(env)
|
|
21
|
+
env[Test::REQUEST] = @definition.validate_request(request, raise_error: false)
|
|
22
|
+
response = @app.call(env)
|
|
23
|
+
status, headers, body = response
|
|
24
|
+
env[Test::RESPONSE] =
|
|
25
|
+
@definition.validate_response(request, Rack::Response[status, headers, body], raise_error: false)
|
|
26
|
+
response
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -6,7 +6,7 @@ module OpenapiFirst
|
|
|
6
6
|
# This is used by Openapi::Test.observe
|
|
7
7
|
module Callable
|
|
8
8
|
# Returns a Module with a `call(env)` method that wraps super inside silent request/response validation
|
|
9
|
-
# You can use
|
|
9
|
+
# You can use `Application.prepend(OpenapiFirst::Test::Callable[oad])` to monitor your app during testing.
|
|
10
10
|
def self.[](definition)
|
|
11
11
|
Module.new.tap do |mod|
|
|
12
12
|
mod.define_method(:call) do |env|
|
|
@@ -11,28 +11,33 @@ module OpenapiFirst
|
|
|
11
11
|
@skip_response_coverage = nil
|
|
12
12
|
@skip_coverage = nil
|
|
13
13
|
@response_raise_error = true
|
|
14
|
-
@ignored_unknown_status = [404]
|
|
14
|
+
@ignored_unknown_status = Set.new([401, 404, 500])
|
|
15
|
+
@ignore_unknown_response_status = false
|
|
15
16
|
@report_coverage = true
|
|
16
17
|
@ignore_unknown_requests = false
|
|
17
|
-
@registry = {}
|
|
18
|
-
@apps = {}
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
# Register OADs, but don't load them just yet
|
|
22
21
|
# @param [OpenapiFirst::OAD] oad The OAD to register
|
|
23
22
|
# @param [Symbol] as The name to register the OAD under
|
|
24
23
|
def register(oad, as: :default)
|
|
25
|
-
|
|
24
|
+
Test.register(oad, as:)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
# Observe a rack app
|
|
29
28
|
def observe(app, api: :default)
|
|
30
|
-
(
|
|
29
|
+
Observe.observe(app, api:)
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
attr_accessor :coverage_formatter_options, :coverage_formatter, :response_raise_error,
|
|
34
|
-
:ignore_unknown_requests
|
|
35
|
-
attr_reader :
|
|
33
|
+
:ignore_unknown_requests, :ignore_unknown_response_status, :minimum_coverage
|
|
34
|
+
attr_reader :report_coverage, :ignored_unknown_status
|
|
35
|
+
|
|
36
|
+
# Set ignored unknown status codes.
|
|
37
|
+
# @param [Array<Integer>] status Status codes that are okay not to cover in an OAD
|
|
38
|
+
def ignored_unknown_status=(status)
|
|
39
|
+
@ignored_unknown_status = status.to_set
|
|
40
|
+
end
|
|
36
41
|
|
|
37
42
|
# Configure report coverage
|
|
38
43
|
# @param [Boolean, :warn] value Whether to report coverage or just warn.
|
|
@@ -45,13 +50,6 @@ module OpenapiFirst
|
|
|
45
50
|
@report_coverage = value
|
|
46
51
|
end
|
|
47
52
|
|
|
48
|
-
# @deprecated Use skip_response_coverage, ignored_unknown_status or skip_coverage to configure coverage
|
|
49
|
-
def minimum_coverage=(value)
|
|
50
|
-
warn 'OpenapiFirst::Test::Configuration#minimum_coverage= is deprecated. ' \
|
|
51
|
-
'Use skip_response_coverage, ignored_unknown_status to configure coverage instead.'
|
|
52
|
-
@minimum_coverage = value
|
|
53
|
-
end
|
|
54
|
-
|
|
55
53
|
def skip_response_coverage(&block)
|
|
56
54
|
return @skip_response_coverage unless block_given?
|
|
57
55
|
|
|
@@ -63,6 +61,16 @@ module OpenapiFirst
|
|
|
63
61
|
|
|
64
62
|
@skip_coverage = block
|
|
65
63
|
end
|
|
64
|
+
|
|
65
|
+
alias ignore_unknown_response_status? ignore_unknown_response_status
|
|
66
|
+
|
|
67
|
+
def ignore_response?(validated_response)
|
|
68
|
+
return false if validated_response.known?
|
|
69
|
+
|
|
70
|
+
return true if ignored_unknown_status.include?(validated_response.status)
|
|
71
|
+
|
|
72
|
+
ignore_unknown_response_status? && validated_response.error.type == :response_status_not_found
|
|
73
|
+
end
|
|
66
74
|
end
|
|
67
75
|
end
|
|
68
76
|
end
|
|
@@ -37,11 +37,11 @@ module OpenapiFirst
|
|
|
37
37
|
private attr_reader :index
|
|
38
38
|
|
|
39
39
|
def track_request(validated_request)
|
|
40
|
-
index[validated_request.
|
|
40
|
+
index[validated_request.key]&.track(validated_request)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def track_response(validated_response)
|
|
44
|
-
index[validated_response.
|
|
44
|
+
index[validated_response.key]&.track(validated_response)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def done?
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module Test
|
|
5
|
+
module Coverage
|
|
6
|
+
# Class that allows tracking requests and response for OAD definitions.
|
|
7
|
+
# For each definition it builds a plan and forwards tracking to the correct plan.
|
|
8
|
+
class Tracker
|
|
9
|
+
attr_reader :plans_by_key
|
|
10
|
+
|
|
11
|
+
def initialize(definitions, skip_response: nil, skip_route: nil)
|
|
12
|
+
@plans_by_key = definitions.values.to_h do |oad|
|
|
13
|
+
plan = Plan.for(oad, skip_response:, skip_route:)
|
|
14
|
+
[oad.key, plan]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def track_request(key, request)
|
|
19
|
+
@plans_by_key[key]&.track_request(request)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def track_response(key, response)
|
|
23
|
+
@plans_by_key[key]&.track_response(response)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def plans
|
|
27
|
+
@plans_by_key.values
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'coverage/plan'
|
|
4
|
+
require_relative 'coverage/tracker'
|
|
5
|
+
require_relative 'coverage/covered_request'
|
|
6
|
+
require_relative 'coverage/covered_response'
|
|
7
|
+
require 'drb'
|
|
4
8
|
|
|
5
9
|
module OpenapiFirst
|
|
6
10
|
module Test
|
|
@@ -12,45 +16,80 @@ module OpenapiFirst
|
|
|
12
16
|
|
|
13
17
|
Result = Data.define(:plans, :coverage)
|
|
14
18
|
|
|
15
|
-
@current_run = {}
|
|
16
|
-
|
|
17
19
|
class << self
|
|
18
|
-
|
|
20
|
+
def start(skip_response: nil, skip_route: nil)
|
|
21
|
+
return if @drb_uri
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
tracker = Tracker.new(Test.definitions, skip_response:, skip_route:)
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
[oad.key, plan]
|
|
26
|
-
end
|
|
25
|
+
# We need a custom DRbServer (not using DRb.start_service) because otherwise
|
|
26
|
+
# we'd conflict with Rails's DRb server
|
|
27
|
+
@drb_uri = DRb::DRbServer.new(nil, tracker).uri
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def uninstall = Test.uninstall
|
|
30
|
-
|
|
31
30
|
# Clear current coverage run
|
|
32
31
|
def reset
|
|
33
|
-
@
|
|
32
|
+
@tracker = nil
|
|
33
|
+
|
|
34
|
+
return unless @drb_uri
|
|
35
|
+
|
|
36
|
+
service = DRb.fetch_server(@drb_uri)
|
|
37
|
+
service&.stop_service
|
|
38
|
+
@drb_uri = nil
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def track_request(request, oad)
|
|
37
|
-
|
|
42
|
+
return unless request.known?
|
|
43
|
+
|
|
44
|
+
# The call to `track_request` may happen remotely in the main process that started
|
|
45
|
+
# the coverage collection.
|
|
46
|
+
# To make this work we need to keep arguments trivial, which is the reason the request
|
|
47
|
+
# is wrapped in a CoveredRequest data object.
|
|
48
|
+
tracker&.track_request(
|
|
49
|
+
oad.key,
|
|
50
|
+
CoveredRequest.new(
|
|
51
|
+
key: request.request_definition.key,
|
|
52
|
+
error: request.error
|
|
53
|
+
)
|
|
54
|
+
)
|
|
38
55
|
end
|
|
39
56
|
|
|
40
57
|
def track_response(response, _request, oad)
|
|
41
|
-
|
|
58
|
+
return unless response.known?
|
|
59
|
+
|
|
60
|
+
# The call to `track_response` may happen remotely in the main process that started
|
|
61
|
+
# the coverage collection.
|
|
62
|
+
# To make this work we need to keep arguments trivial, which is the reason the response
|
|
63
|
+
# is wrapped in a CoveredResponse data object.
|
|
64
|
+
tracker&.track_response(
|
|
65
|
+
oad.key,
|
|
66
|
+
CoveredResponse.new(
|
|
67
|
+
key: response.response_definition.key,
|
|
68
|
+
error: response.error
|
|
69
|
+
)
|
|
70
|
+
)
|
|
42
71
|
end
|
|
43
72
|
|
|
44
73
|
def result
|
|
45
74
|
Result.new(plans:, coverage:)
|
|
46
75
|
end
|
|
47
76
|
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def current_run
|
|
80
|
+
tracker.plans_by_key
|
|
81
|
+
end
|
|
82
|
+
|
|
48
83
|
# Returns all plans (Plan) that were registered for this run
|
|
49
84
|
def plans
|
|
50
|
-
|
|
85
|
+
tracker&.plans || []
|
|
51
86
|
end
|
|
52
87
|
|
|
53
|
-
|
|
88
|
+
def tracker
|
|
89
|
+
return unless @drb_uri
|
|
90
|
+
|
|
91
|
+
@tracker ||= DRbObject.new_with_uri(@drb_uri)
|
|
92
|
+
end
|
|
54
93
|
|
|
55
94
|
def coverage
|
|
56
95
|
return 0 if plans.empty?
|
data/lib/openapi_first/test.rb
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'test/configuration'
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative 'registry'
|
|
5
5
|
|
|
6
6
|
module OpenapiFirst
|
|
7
7
|
# Test integration
|
|
8
8
|
module Test
|
|
9
9
|
autoload :Coverage, 'openapi_first/test/coverage'
|
|
10
10
|
autoload :Methods, 'openapi_first/test/methods'
|
|
11
|
-
autoload :Callable, 'openapi_first/test/callable'
|
|
12
11
|
autoload :Observe, 'openapi_first/test/observe'
|
|
12
|
+
autoload :App, 'openapi_first/test/app'
|
|
13
13
|
extend Registry
|
|
14
14
|
|
|
15
15
|
class CoverageError < Error; end
|
|
@@ -25,6 +25,10 @@ module OpenapiFirst
|
|
|
25
25
|
false
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def self.definitions
|
|
29
|
+
super.empty? ? OpenapiFirst.definitions : super
|
|
30
|
+
end
|
|
31
|
+
|
|
28
32
|
def self.configuration
|
|
29
33
|
@configuration ||= Configuration.new
|
|
30
34
|
end
|
|
@@ -32,29 +36,28 @@ module OpenapiFirst
|
|
|
32
36
|
# Sets up OpenAPI test coverage and OAD registration.
|
|
33
37
|
# @yieldparam [OpenapiFirst::Test::Configuration] configuration A configuration to setup test integration
|
|
34
38
|
def self.setup
|
|
35
|
-
unless block_given?
|
|
36
|
-
raise ArgumentError, "Please provide a block to #{self.class}.confgure to register you API descriptions"
|
|
37
|
-
end
|
|
38
|
-
|
|
39
39
|
install
|
|
40
|
-
yield configuration
|
|
40
|
+
yield configuration if block_given?
|
|
41
41
|
|
|
42
|
-
configuration.registry.each { |name, oad| register(oad, as: name) }
|
|
43
|
-
configuration.apps.each { |name, apps| apps.each { |app| observe(app, api: name) } }
|
|
44
42
|
Coverage.start(skip_response: configuration.skip_response_coverage, skip_route: configuration.skip_coverage)
|
|
45
43
|
|
|
46
44
|
if definitions.empty?
|
|
47
45
|
raise NotRegisteredError,
|
|
48
46
|
'No API descriptions have been registered. ' \
|
|
49
47
|
'Please register your API description via ' \
|
|
50
|
-
"OpenapiFirst
|
|
48
|
+
"`OpenapiFirst.register('myopenapi.yaml)` or " \
|
|
49
|
+
'in a block passed to `OpenapiFirst::Test.setup` like this: ' \
|
|
50
|
+
"`OpenapiFirst::Test.setup { |test| test.register('myopenapi.yaml') }` " \
|
|
51
|
+
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
@exit_handler = method(:handle_exit)
|
|
54
55
|
|
|
56
|
+
main_process = Process.pid
|
|
55
57
|
@setup ||= at_exit do
|
|
56
58
|
# :nocov:
|
|
57
|
-
|
|
59
|
+
# Only handle exit once in the main process
|
|
60
|
+
@exit_handler&.call if Process.pid == main_process
|
|
58
61
|
# :nocov:
|
|
59
62
|
end
|
|
60
63
|
end
|
|
@@ -88,11 +91,7 @@ module OpenapiFirst
|
|
|
88
91
|
# the middlewares or manual request, response validation.
|
|
89
92
|
def self.app(app, spec: nil, api: :default)
|
|
90
93
|
spec ||= self[api]
|
|
91
|
-
|
|
92
|
-
use OpenapiFirst::Middlewares::ResponseValidation, spec:, raise_error: false
|
|
93
|
-
use OpenapiFirst::Middlewares::RequestValidation, spec:, raise_error: false, error_response: false
|
|
94
|
-
run app
|
|
95
|
-
end
|
|
94
|
+
App.new(app, api: spec)
|
|
96
95
|
end
|
|
97
96
|
|
|
98
97
|
def self.install
|
|
@@ -123,14 +122,14 @@ module OpenapiFirst
|
|
|
123
122
|
!configuration.ignore_unknown_requests
|
|
124
123
|
end
|
|
125
124
|
|
|
126
|
-
def self.raise_response_error?(
|
|
127
|
-
configuration.response_raise_error && !configuration.
|
|
125
|
+
def self.raise_response_error?(invalid_response)
|
|
126
|
+
configuration.response_raise_error && !configuration.ignore_response?(invalid_response)
|
|
128
127
|
end
|
|
129
128
|
|
|
130
129
|
def self.uninstall
|
|
131
130
|
configuration = OpenapiFirst.configuration
|
|
132
|
-
configuration.
|
|
133
|
-
configuration.
|
|
131
|
+
configuration.after_request_validation.delete(@after_request_validation)
|
|
132
|
+
configuration.after_response_validation.delete(@after_response_validation)
|
|
134
133
|
definitions.clear
|
|
135
134
|
@configuration = nil
|
|
136
135
|
@installed = nil
|
|
@@ -24,14 +24,16 @@ module OpenapiFirst
|
|
|
24
24
|
attr_reader :response_definition
|
|
25
25
|
|
|
26
26
|
# The parsed headers
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# @return [Hash<String,anything>, nil]
|
|
28
|
+
def parsed_headers
|
|
29
|
+
@parsed_values&.headers
|
|
30
|
+
end
|
|
30
31
|
|
|
31
32
|
# The parsed body
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
# @return [Hash<String,anything>, nil]
|
|
34
|
+
def parsed_body
|
|
35
|
+
@parsed_values&.body
|
|
36
|
+
end
|
|
35
37
|
|
|
36
38
|
# Checks if the response is valid.
|
|
37
39
|
# @return [Boolean] true if the response is valid, false otherwise.
|
data/lib/openapi_first.rb
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require_relative 'openapi_first/json'
|
|
4
4
|
require_relative 'openapi_first/file_loader'
|
|
5
5
|
require_relative 'openapi_first/errors'
|
|
6
|
+
require_relative 'openapi_first/registry'
|
|
6
7
|
require_relative 'openapi_first/configuration'
|
|
8
|
+
require_relative 'openapi_first/child_configuration'
|
|
7
9
|
require_relative 'openapi_first/definition'
|
|
8
10
|
require_relative 'openapi_first/version'
|
|
9
11
|
require_relative 'openapi_first/middlewares/response_validation'
|
|
@@ -11,6 +13,8 @@ require_relative 'openapi_first/middlewares/request_validation'
|
|
|
11
13
|
|
|
12
14
|
# OpenapiFirst is a toolchain to build HTTP APIS based on OpenAPI API descriptions.
|
|
13
15
|
module OpenapiFirst
|
|
16
|
+
extend Registry
|
|
17
|
+
|
|
14
18
|
autoload :Test, 'openapi_first/test'
|
|
15
19
|
|
|
16
20
|
# Key in rack to find instance of Request
|
|
@@ -26,7 +30,7 @@ module OpenapiFirst
|
|
|
26
30
|
# @return [Configuration]
|
|
27
31
|
# @yield [Configuration]
|
|
28
32
|
def self.configure
|
|
29
|
-
yield configuration
|
|
33
|
+
yield configuration if block_given?
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
ERROR_RESPONSES = {} # rubocop:disable Style/MutableConstant
|
|
@@ -54,6 +58,7 @@ module OpenapiFirst
|
|
|
54
58
|
# @return [Definition]
|
|
55
59
|
def self.load(filepath_or_definition, only: nil, &)
|
|
56
60
|
return filepath_or_definition if filepath_or_definition.is_a?(Definition)
|
|
61
|
+
return self[filepath_or_definition] if filepath_or_definition.is_a?(Symbol)
|
|
57
62
|
|
|
58
63
|
filepath = filepath_or_definition
|
|
59
64
|
raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openapi_first
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andreas Haller
|
|
@@ -49,7 +49,7 @@ dependencies:
|
|
|
49
49
|
requirements:
|
|
50
50
|
- - ">="
|
|
51
51
|
- !ruby/object:Gem::Version
|
|
52
|
-
version: 0.
|
|
52
|
+
version: 0.7.0
|
|
53
53
|
- - "<"
|
|
54
54
|
- !ruby/object:Gem::Version
|
|
55
55
|
version: '2.0'
|
|
@@ -59,7 +59,7 @@ dependencies:
|
|
|
59
59
|
requirements:
|
|
60
60
|
- - ">="
|
|
61
61
|
- !ruby/object:Gem::Version
|
|
62
|
-
version: 0.
|
|
62
|
+
version: 0.7.0
|
|
63
63
|
- - "<"
|
|
64
64
|
- !ruby/object:Gem::Version
|
|
65
65
|
version: '2.0'
|
|
@@ -94,6 +94,7 @@ files:
|
|
|
94
94
|
- README.md
|
|
95
95
|
- lib/openapi_first.rb
|
|
96
96
|
- lib/openapi_first/builder.rb
|
|
97
|
+
- lib/openapi_first/child_configuration.rb
|
|
97
98
|
- lib/openapi_first/configuration.rb
|
|
98
99
|
- lib/openapi_first/definition.rb
|
|
99
100
|
- lib/openapi_first/error_response.rb
|
|
@@ -107,6 +108,7 @@ files:
|
|
|
107
108
|
- lib/openapi_first/middlewares/request_validation.rb
|
|
108
109
|
- lib/openapi_first/middlewares/response_validation.rb
|
|
109
110
|
- lib/openapi_first/ref_resolver.rb
|
|
111
|
+
- lib/openapi_first/registry.rb
|
|
110
112
|
- lib/openapi_first/request.rb
|
|
111
113
|
- lib/openapi_first/request_body_parsers.rb
|
|
112
114
|
- lib/openapi_first/request_parser.rb
|
|
@@ -123,20 +125,23 @@ files:
|
|
|
123
125
|
- lib/openapi_first/schema/validation_error.rb
|
|
124
126
|
- lib/openapi_first/schema/validation_result.rb
|
|
125
127
|
- lib/openapi_first/test.rb
|
|
128
|
+
- lib/openapi_first/test/app.rb
|
|
126
129
|
- lib/openapi_first/test/callable.rb
|
|
127
130
|
- lib/openapi_first/test/configuration.rb
|
|
128
131
|
- lib/openapi_first/test/coverage.rb
|
|
132
|
+
- lib/openapi_first/test/coverage/covered_request.rb
|
|
133
|
+
- lib/openapi_first/test/coverage/covered_response.rb
|
|
129
134
|
- lib/openapi_first/test/coverage/plan.rb
|
|
130
135
|
- lib/openapi_first/test/coverage/request_task.rb
|
|
131
136
|
- lib/openapi_first/test/coverage/response_task.rb
|
|
132
137
|
- lib/openapi_first/test/coverage/route_task.rb
|
|
133
138
|
- lib/openapi_first/test/coverage/terminal_formatter.rb
|
|
139
|
+
- lib/openapi_first/test/coverage/tracker.rb
|
|
134
140
|
- lib/openapi_first/test/methods.rb
|
|
135
141
|
- lib/openapi_first/test/minitest_helpers.rb
|
|
136
142
|
- lib/openapi_first/test/observe.rb
|
|
137
143
|
- lib/openapi_first/test/observer_middleware.rb
|
|
138
144
|
- lib/openapi_first/test/plain_helpers.rb
|
|
139
|
-
- lib/openapi_first/test/registry.rb
|
|
140
145
|
- lib/openapi_first/validated_request.rb
|
|
141
146
|
- lib/openapi_first/validated_response.rb
|
|
142
147
|
- lib/openapi_first/validators/request_body.rb
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module OpenapiFirst
|
|
4
|
-
module Test
|
|
5
|
-
class NotRegisteredError < Error; end
|
|
6
|
-
class AlreadyRegisteredError < Error; end
|
|
7
|
-
|
|
8
|
-
# @visibility private
|
|
9
|
-
module Registry
|
|
10
|
-
def definitions
|
|
11
|
-
@definitions ||= {}
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# Register an OpenAPI definition for testing
|
|
15
|
-
# @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object
|
|
16
|
-
# @param as [Symbol] Name to register the API definition as
|
|
17
|
-
def register(path_or_definition, as: :default)
|
|
18
|
-
if definitions.key?(as) && as == :default
|
|
19
|
-
raise(
|
|
20
|
-
AlreadyRegisteredError,
|
|
21
|
-
"#{definitions[as].filepath.inspect} is already registered " \
|
|
22
|
-
"as ':default' so you cannot register #{path_or_definition.inspect} without " \
|
|
23
|
-
'giving it a custom name. Please call register with a custom key like: ' \
|
|
24
|
-
"#{name}.register(#{path_or_definition.inspect}, as: :my_other_api)"
|
|
25
|
-
)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
definition = OpenapiFirst.load(path_or_definition)
|
|
29
|
-
definitions[as] = definition
|
|
30
|
-
definition
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def [](api)
|
|
34
|
-
definitions.fetch(api) do
|
|
35
|
-
option = api == :default ? '' : ", as: #{api.inspect}"
|
|
36
|
-
raise(NotRegisteredError,
|
|
37
|
-
"API description '#{api.inspect}' not found." \
|
|
38
|
-
"Please call #{name}.register('myopenapi.yaml'#{option}) " \
|
|
39
|
-
'once before running tests.')
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|