openapi_first 2.2.3 → 2.3.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 +10 -0
- data/README.md +105 -99
- data/lib/openapi_first/builder.rb +38 -26
- data/lib/openapi_first/configuration.rb +1 -1
- data/lib/openapi_first/definition.rb +1 -1
- data/lib/openapi_first/file_loader.rb +1 -1
- data/lib/openapi_first/ref_resolver.rb +22 -15
- data/lib/openapi_first/request.rb +10 -2
- data/lib/openapi_first/request_body_parsers.rb +21 -4
- data/lib/openapi_first/response.rb +3 -2
- data/lib/openapi_first/router.rb +6 -3
- data/lib/openapi_first/schema/validation_error.rb +1 -1
- data/lib/openapi_first/schema/validation_result.rb +3 -1
- data/lib/openapi_first/test/coverage/plan.rb +69 -0
- data/lib/openapi_first/test/coverage/request_task.rb +40 -0
- data/lib/openapi_first/test/coverage/response_task.rb +40 -0
- data/lib/openapi_first/test/coverage/route_task.rb +13 -0
- data/lib/openapi_first/test/coverage/terminal_formatter.rb +101 -0
- data/lib/openapi_first/test/coverage.rb +75 -0
- data/lib/openapi_first/test/methods.rb +0 -1
- data/lib/openapi_first/test/plain_helpers.rb +7 -2
- data/lib/openapi_first/test.rb +72 -14
- data/lib/openapi_first/validated_response.rb +3 -0
- data/lib/openapi_first/version.rb +1 -1
- metadata +8 -3
- data/lib/openapi_first/body_parser.rb +0 -45
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 851414a9f2a8d64df210a610ed9577ad6608c85173a06221b96c3d390dcca1e5
|
4
|
+
data.tar.gz: 815e970727a8e683740b622768ccd031a138e4ef4ca38c8aebcd73bf2a7f4230
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8696eaabb5455c0e009233a3f8099198aa222cb1a442ef53314e5ed756373f41eff3747f69c0036cb43c708a746b21289866b9d7cfeace5649a68a686ad302ef
|
7
|
+
data.tar.gz: 9bb2db9c0443eb75299c755a79a0e9c6d696bc27a5683bc8fe4994e3e7e965e336afe855bbe41666346697668ae312697f5b0e307ec89a13c1c9ecf6c4087134
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,16 @@
|
|
2
2
|
|
3
3
|
## Unreleased
|
4
4
|
|
5
|
+
## 2.3.0
|
6
|
+
|
7
|
+
### New feature
|
8
|
+
- Add OpenapiFirst::Test::Coverage to track request/response coverage for your API descriptions. (https://github.com/ahx/openapi_first/pull/327)
|
9
|
+
|
10
|
+
## 2.2.4
|
11
|
+
|
12
|
+
- Fix request validation file uploads in multipart/form-data requests with nested fields (https://github.com/ahx/openapi_first/issues/324)
|
13
|
+
- Add more error details to validation result (https://github.com/ahx/openapi_first/pull/322)
|
14
|
+
|
5
15
|
## 2.2.3
|
6
16
|
|
7
17
|
- Respect global JSONSchemer configuration (https://github.com/ahx/openapi_first/pull/318)
|
data/README.md
CHANGED
@@ -1,21 +1,20 @@
|
|
1
1
|
# openapi_first
|
2
2
|
|
3
|
-
|
3
|
+
openapi_first is a Ruby gem for request / response validation and contract-testing against an [OpenAPI](https://www.openapis.org/) 3.0 or 3.1 API description. It makes an APIFirst workflow easy and reliable.
|
4
4
|
|
5
|
-
[
|
6
|
-
[](https://github.com/ahx/openapi_first/blob/codeql/.github/workflows/codeql.yml)
|
5
|
+
You can use openapi_first on production for [request validation](#request-validation) and in your tests to avoid API drift with it's request/response validation and coverage features.
|
7
6
|
|
8
7
|
## Contents
|
9
8
|
|
10
9
|
<!-- TOC -->
|
11
10
|
|
12
|
-
- [Manual use](#manual-use)
|
13
|
-
- [Validate request](#validate-request)
|
14
|
-
- [Validate response](#validate-response)
|
15
11
|
- [Rack Middlewares](#rack-middlewares)
|
16
12
|
- [Request validation](#request-validation)
|
17
13
|
- [Response validation](#response-validation)
|
18
|
-
- [
|
14
|
+
- [Contract testing](#contract-testing)
|
15
|
+
- [Coverage](#coverage)
|
16
|
+
- [Test assertions](#test-assertions)
|
17
|
+
- [Manual use](#manual-use)
|
19
18
|
- [Framework integration](#framework-integration)
|
20
19
|
- [Configuration](#configuration)
|
21
20
|
- [Hooks](#hooks)
|
@@ -26,59 +25,6 @@ OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.open
|
|
26
25
|
|
27
26
|
<!-- /TOC -->
|
28
27
|
|
29
|
-
## Manual use
|
30
|
-
|
31
|
-
Load the API description:
|
32
|
-
|
33
|
-
```ruby
|
34
|
-
require 'openapi_first'
|
35
|
-
|
36
|
-
definition = OpenapiFirst.load('openapi.yaml')
|
37
|
-
```
|
38
|
-
|
39
|
-
### Validate request
|
40
|
-
|
41
|
-
```ruby
|
42
|
-
validated_request = definition.validate_request(rack_request)
|
43
|
-
|
44
|
-
# Inspect the request and access parsed parameters
|
45
|
-
validated_request.valid?
|
46
|
-
validated_request.invalid?
|
47
|
-
validated_request.error # => Failure object or nil
|
48
|
-
validated_request.parsed_body # => The parsed request body (Hash)
|
49
|
-
validated_request.parsed_query # A Hash of query parameters that are defined in the API description, parsed exactly as described.
|
50
|
-
validated_request.parsed_path_parameters
|
51
|
-
validated_request.parsed_headers
|
52
|
-
validated_request.parsed_cookies
|
53
|
-
validated_request.parsed_params # Merged parsed path, query parameters and request body
|
54
|
-
# Access the Openapi 3 Operation Object Hash
|
55
|
-
validated_request.operation['x-foo']
|
56
|
-
validated_request.operation['operationId'] => "getStuff"
|
57
|
-
# or the whole request definition
|
58
|
-
validated_request.request_definition.path # => "/pets/{petId}"
|
59
|
-
validated_request.request_definition.operation_id # => "showPetById"
|
60
|
-
|
61
|
-
# Or you can raise an exception if validation fails:
|
62
|
-
definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
|
63
|
-
```
|
64
|
-
|
65
|
-
### Validate response
|
66
|
-
|
67
|
-
```ruby
|
68
|
-
validated_response = definition.validate_response(rack_request, rack_response)
|
69
|
-
|
70
|
-
# Inspect the response and access parsed parameters and
|
71
|
-
validated_response.valid?
|
72
|
-
validated_response.invalid?
|
73
|
-
validated_response.error # => Failure object or nil
|
74
|
-
validated_response.status # => 200
|
75
|
-
validated_response.parsed_body
|
76
|
-
validated_response.parsed_headers
|
77
|
-
|
78
|
-
# Or you can raise an exception if validation fails:
|
79
|
-
definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
|
80
|
-
```
|
81
|
-
|
82
28
|
## Rack Middlewares
|
83
29
|
|
84
30
|
### Request validation
|
@@ -87,15 +33,10 @@ The request validation middleware returns a 4xx if the request is invalid or not
|
|
87
33
|
|
88
34
|
```ruby
|
89
35
|
use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml'
|
90
|
-
```
|
91
36
|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
| :---------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
96
|
-
| `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
|
97
|
-
| `raise_error:` | `false` (default), `true` | If set to true the middleware raises `OpenapiFirst::RequestInvalidError` or `OpenapiFirst::NotFoundError` instead of returning 4xx. |
|
98
|
-
| `error_response:` | `:default` (default), `:jsonapi`, Your implementation of `ErrorResponse` or `false` to disable responding |
|
37
|
+
# Pass `raise_error: true` to raise an error if request is invalid:
|
38
|
+
use OpenapiFirst::Middlewares::RequestValidation, raise_error: true, spec: 'openapi.yaml'
|
39
|
+
```
|
99
40
|
|
100
41
|
#### Error responses
|
101
42
|
|
@@ -129,7 +70,7 @@ content-type: "application/problem+json"
|
|
129
70
|
}
|
130
71
|
```
|
131
72
|
|
132
|
-
openapi_first offers a [JSON:API](https://jsonapi.org/) error response
|
73
|
+
openapi_first offers a [JSON:API](https://jsonapi.org/) error response by passing `error_response: :jsonapi`:
|
133
74
|
|
134
75
|
```ruby
|
135
76
|
use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml, error_response: :jsonapi'
|
@@ -179,37 +120,58 @@ use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml, error_res
|
|
179
120
|
You can build your own custom error response with `error_response: MyCustomClass` that implements `OpenapiFirst::ErrorResponse`.
|
180
121
|
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`.
|
181
122
|
|
182
|
-
#### readOnly / writeOnly properties
|
183
|
-
|
184
|
-
Request validation fails if request includes a property with `readOnly: true`.
|
185
|
-
|
186
|
-
Response validation fails if response body includes a property with `writeOnly: true`.
|
187
|
-
|
188
123
|
### Response validation
|
189
124
|
|
190
|
-
This middleware
|
125
|
+
This middleware raises an error by default if the response is not valid.
|
126
|
+
This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
|
191
127
|
|
192
128
|
```ruby
|
193
129
|
use OpenapiFirst::Middlewares::ResponseValidation, spec: 'openapi.yaml' if ENV['RACK_ENV'] == 'test'
|
130
|
+
|
131
|
+
# Pass `raise_error: false` to not raise an error:
|
132
|
+
use OpenapiFirst::Middlewares::ResponseValidation, raise_error: true, spec: 'openapi.yaml'
|
194
133
|
```
|
195
134
|
|
196
|
-
|
135
|
+
## Contract Testing
|
197
136
|
|
198
|
-
|
199
|
-
| :------ | --------------- | ---------------------------------------------------------------- |
|
200
|
-
| `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
|
201
|
-
| `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. |
|
137
|
+
### Coverage
|
202
138
|
|
203
|
-
|
139
|
+
> [!NOTE]
|
140
|
+
> This is a brand new feature. ✨ Your feedback is very welcome.
|
204
141
|
|
205
|
-
|
142
|
+
This feature tracks all requests/resposes that are validated via openapi_first and get return an overal coverage value. If all of your described requests/responses have been validated successfully at least once, your coverage is 100%.
|
143
|
+
By checking your validation coverage you can avoid API drift where your API description describes requests/responses differently than your implemention works.
|
206
144
|
|
207
|
-
Here is how to set it up for
|
145
|
+
Here is how to set it up for RSpec in your `spec/spec_helper.rb`:
|
208
146
|
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
147
|
+
1. Register all OpenAPI documents to track coverage for and start tracking. This should go at the top of you test helper file before loading application code.
|
148
|
+
```ruby
|
149
|
+
require 'openapi_first'
|
150
|
+
OpenapiFirst::Test.setup do |test|
|
151
|
+
test.register('openapi/openapi.yaml')
|
152
|
+
end
|
153
|
+
```
|
154
|
+
2. Wrap your app with silent request / response validation. This validates all requets/responses you do during your test run. (✷1)
|
155
|
+
```ruby
|
156
|
+
config.before type: :request do
|
157
|
+
def app
|
158
|
+
OpenapiFirst::Test::(App)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
```
|
162
|
+
3. Check coverage after your test suite has finished
|
163
|
+
```ruby
|
164
|
+
# Prints a coverage report to the terminal
|
165
|
+
config.after(:suite) { OpenapiFirst::Test.report_coverage }
|
166
|
+
```
|
167
|
+
|
168
|
+
(✷1): Instead of using `OpenapiFirstTest.app` to wrap your application, you can use the middlewares or [test assertion method](#test-assertions), but you would have to do that for all requests/responses defined in your API description to make coverage work.
|
169
|
+
|
170
|
+
### Test assertions
|
171
|
+
|
172
|
+
openapi_first ships with a simple but powerful Test method 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.
|
173
|
+
|
174
|
+
Here is how to set it up for Rails integration tests:
|
213
175
|
|
214
176
|
Inside your test:
|
215
177
|
```ruby
|
@@ -225,10 +187,64 @@ class TripsApiTest < ActionDispatch::IntegrationTest
|
|
225
187
|
date: '2024-07-02T09:00:00Z' }
|
226
188
|
|
227
189
|
assert_api_conform(status: 200)
|
190
|
+
# assert_api_conform(status: 200, api: :v1) # Or this if you have multiple API descriptions
|
228
191
|
end
|
229
192
|
end
|
230
193
|
```
|
231
194
|
|
195
|
+
## Manual use
|
196
|
+
|
197
|
+
Load the API description:
|
198
|
+
|
199
|
+
```ruby
|
200
|
+
require 'openapi_first'
|
201
|
+
|
202
|
+
definition = OpenapiFirst.load('openapi.yaml')
|
203
|
+
```
|
204
|
+
|
205
|
+
### Validate request
|
206
|
+
|
207
|
+
```ruby
|
208
|
+
validated_request = definition.validate_request(rack_request)
|
209
|
+
|
210
|
+
# Inspect the request and access parsed parameters
|
211
|
+
validated_request.valid?
|
212
|
+
validated_request.invalid?
|
213
|
+
validated_request.error # => Failure object or nil
|
214
|
+
validated_request.parsed_body # => The parsed request body (Hash)
|
215
|
+
validated_request.parsed_query # A Hash of query parameters that are defined in the API description, parsed exactly as described.
|
216
|
+
validated_request.parsed_path_parameters
|
217
|
+
validated_request.parsed_headers
|
218
|
+
validated_request.parsed_cookies
|
219
|
+
validated_request.parsed_params # Merged parsed path, query parameters and request body
|
220
|
+
# Access the Openapi 3 Operation Object Hash
|
221
|
+
validated_request.operation['x-foo']
|
222
|
+
validated_request.operation['operationId'] => "getStuff"
|
223
|
+
# or the whole request definition
|
224
|
+
validated_request.request_definition.path # => "/pets/{petId}"
|
225
|
+
validated_request.request_definition.operation_id # => "showPetById"
|
226
|
+
|
227
|
+
# Or you can raise an exception if validation fails:
|
228
|
+
definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
|
229
|
+
```
|
230
|
+
|
231
|
+
### Validate response
|
232
|
+
|
233
|
+
```ruby
|
234
|
+
validated_response = definition.validate_response(rack_request, rack_response)
|
235
|
+
|
236
|
+
# Inspect the response and access parsed parameters and
|
237
|
+
validated_response.valid?
|
238
|
+
validated_response.invalid?
|
239
|
+
validated_response.error # => Failure object or nil
|
240
|
+
validated_response.status # => 200
|
241
|
+
validated_response.parsed_body
|
242
|
+
validated_response.parsed_headers
|
243
|
+
|
244
|
+
# Or you can raise an exception if validation fails:
|
245
|
+
definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
|
246
|
+
```
|
247
|
+
|
232
248
|
## Configuration
|
233
249
|
|
234
250
|
You can configure default options globally:
|
@@ -291,16 +307,6 @@ end
|
|
291
307
|
Using rack middlewares is supported in probably all Ruby web frameworks.
|
292
308
|
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.
|
293
309
|
|
294
|
-
When running integration tests (or request specs when using rspec), it makes sense to add the response validation middleware to `config/environments/test.rb`:
|
295
|
-
|
296
|
-
```ruby
|
297
|
-
config.middleware.use OpenapiFirst::Middlewares::ResponseValidation,
|
298
|
-
spec: 'api/openapi.yaml'
|
299
|
-
```
|
300
|
-
|
301
|
-
That way you don't have to call specific test assertions to make sure your API matches the OpenAPI document.
|
302
|
-
There is no need to run response validation on production if your test coverage is decent.
|
303
|
-
|
304
310
|
## Alternatives
|
305
311
|
|
306
312
|
This gem was inspired by [committe](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
|
@@ -328,6 +334,6 @@ bundle exec ruby benchmarks.rb
|
|
328
334
|
|
329
335
|
### Contributing
|
330
336
|
|
331
|
-
If you have a question or an idea or found a bug don't hesitate to
|
337
|
+
If you have a question or an idea or found a bug, don't hesitate to create an issue [on Github](https://github.com/ahx/openapi_first) or [Codeberg](https://codeberg.org/ahx/openapi_first) or say hi on [Mastodon (ruby.social)](https://ruby.social/@ahx).
|
332
338
|
|
333
339
|
Pull requests are very welcome as well, of course. Feel free to create a "draft" pull request early on, even if your change is still work in progress. 🤗
|
@@ -22,7 +22,7 @@ module OpenapiFirst
|
|
22
22
|
@schemer_configuration.insert_property_defaults = true
|
23
23
|
|
24
24
|
@config = config
|
25
|
-
@contents = RefResolver.for(contents,
|
25
|
+
@contents = RefResolver.for(contents, filepath:)
|
26
26
|
end
|
27
27
|
|
28
28
|
attr_reader :config
|
@@ -58,17 +58,18 @@ module OpenapiFirst
|
|
58
58
|
request,
|
59
59
|
request_method:,
|
60
60
|
path:,
|
61
|
-
content_type: request.content_type
|
62
|
-
|
63
|
-
end
|
64
|
-
build_responses(responses: operation_object['responses']).each do |response|
|
65
|
-
router.add_response(
|
66
|
-
response,
|
67
|
-
request_method:,
|
68
|
-
path:,
|
69
|
-
status: response.status,
|
70
|
-
response_content_type: response.content_type
|
61
|
+
content_type: request.content_type,
|
62
|
+
allow_empty_content: request.allow_empty_content?
|
71
63
|
)
|
64
|
+
build_responses(request:, responses: operation_object['responses']).each do |response|
|
65
|
+
router.add_response(
|
66
|
+
response,
|
67
|
+
request_method:,
|
68
|
+
path:,
|
69
|
+
status: response.status,
|
70
|
+
response_content_type: response.content_type
|
71
|
+
)
|
72
|
+
end
|
72
73
|
end
|
73
74
|
end
|
74
75
|
end
|
@@ -106,28 +107,37 @@ module OpenapiFirst
|
|
106
107
|
end
|
107
108
|
|
108
109
|
def build_requests(path:, request_method:, operation_object:, parameters:)
|
110
|
+
content_objects = operation_object.dig('requestBody', 'content')
|
111
|
+
if content_objects.nil?
|
112
|
+
return [
|
113
|
+
request_without_body(path:, request_method:, parameters:, operation_object:)
|
114
|
+
]
|
115
|
+
end
|
109
116
|
required_body = operation_object['requestBody']&.resolved&.fetch('required', false) == true
|
110
|
-
|
117
|
+
content_objects.map do |content_type, content_object|
|
111
118
|
content_schema = content_object['schema'].schema(
|
112
119
|
configuration: schemer_configuration,
|
113
120
|
after_property_validation: config.hooks[:after_request_body_property_validation]
|
114
121
|
)
|
115
|
-
Request.new(path:, request_method:,
|
122
|
+
Request.new(path:, request_method:, parameters:,
|
116
123
|
operation_object: operation_object.resolved,
|
117
|
-
|
124
|
+
content_type:,
|
118
125
|
content_schema:,
|
119
|
-
required_body
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
126
|
+
required_body:,
|
127
|
+
key: [path, request_method, content_type].join(':'))
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def request_without_body(path:, request_method:, parameters:, operation_object:)
|
132
|
+
Request.new(path:, request_method:, parameters:,
|
133
|
+
operation_object: operation_object.resolved,
|
134
|
+
content_type: nil,
|
135
|
+
content_schema: nil,
|
136
|
+
required_body: false,
|
137
|
+
key: [path, request_method, nil].join(':'))
|
128
138
|
end
|
129
139
|
|
130
|
-
def build_responses(responses:)
|
140
|
+
def build_responses(responses:, request:)
|
131
141
|
return [] unless responses
|
132
142
|
|
133
143
|
responses.flat_map do |status, response_object|
|
@@ -142,9 +152,10 @@ module OpenapiFirst
|
|
142
152
|
headers:,
|
143
153
|
headers_schema:,
|
144
154
|
content_type:,
|
145
|
-
content_schema
|
155
|
+
content_schema:,
|
156
|
+
key: [request.key, status, content_type].join(':'))
|
146
157
|
end || Response.new(status:, headers:, headers_schema:, content_type: nil,
|
147
|
-
content_schema: nil)
|
158
|
+
content_schema: nil, key: [request.key, status, nil].join(':'))
|
148
159
|
end
|
149
160
|
end
|
150
161
|
|
@@ -198,5 +209,6 @@ module OpenapiFirst
|
|
198
209
|
|
199
210
|
ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
|
200
211
|
:cookie_schema)
|
212
|
+
private_constant :ParsedParameters
|
201
213
|
end
|
202
214
|
end
|
@@ -14,7 +14,7 @@ module OpenapiFirst
|
|
14
14
|
@request_validation_error_response = OpenapiFirst.find_error_response(:default)
|
15
15
|
@request_validation_raise_error = false
|
16
16
|
@response_validation_raise_error = true
|
17
|
-
@hooks = (HOOKS.map { [_1,
|
17
|
+
@hooks = (HOOKS.map { [_1, Set.new] }).to_h
|
18
18
|
end
|
19
19
|
|
20
20
|
attr_reader :request_validation_error_response, :hooks
|
@@ -63,7 +63,7 @@ module OpenapiFirst
|
|
63
63
|
# Validates the response against the API description.
|
64
64
|
# @param rack_request [Rack::Request] The Rack request object.
|
65
65
|
# @param rack_response [Rack::Response] The Rack response object.
|
66
|
-
# @param raise_error [Boolean]
|
66
|
+
# @param raise_error [Boolean] Whethir to raise an error if validation fails.
|
67
67
|
# @return [ValidatedResponse] The validated response object.
|
68
68
|
def validate_response(rack_request, rack_response, raise_error: false)
|
69
69
|
route = @router.match(rack_request.request_method, rack_request.path, content_type: rack_request.content_type)
|
@@ -8,7 +8,7 @@ module OpenapiFirst
|
|
8
8
|
module_function
|
9
9
|
|
10
10
|
def load(file_path)
|
11
|
-
raise FileNotFoundError, "File not found #{file_path}" unless File.exist?(file_path)
|
11
|
+
raise FileNotFoundError, "File not found #{file_path.inspect}" unless File.exist?(file_path)
|
12
12
|
|
13
13
|
body = File.read(file_path)
|
14
14
|
extname = File.extname(file_path)
|
@@ -6,17 +6,17 @@ module OpenapiFirst
|
|
6
6
|
# This is here to give traverse an OAD while keeping $refs intact
|
7
7
|
# @visibility private
|
8
8
|
module RefResolver
|
9
|
-
def self.load(
|
10
|
-
contents = OpenapiFirst::FileLoader.load(
|
11
|
-
self.for(contents,
|
9
|
+
def self.load(filepath)
|
10
|
+
contents = OpenapiFirst::FileLoader.load(filepath)
|
11
|
+
self.for(contents, filepath:)
|
12
12
|
end
|
13
13
|
|
14
|
-
def self.for(value,
|
14
|
+
def self.for(value, filepath: nil, context: value)
|
15
15
|
case value
|
16
16
|
when ::Hash
|
17
|
-
Hash.new(value, context:,
|
17
|
+
Hash.new(value, context:, filepath:)
|
18
18
|
when ::Array
|
19
|
-
Array.new(value, context:,
|
19
|
+
Array.new(value, context:, filepath:)
|
20
20
|
when ::NilClass
|
21
21
|
nil
|
22
22
|
else
|
@@ -37,9 +37,11 @@ module OpenapiFirst
|
|
37
37
|
|
38
38
|
# @visibility private
|
39
39
|
module Resolvable
|
40
|
-
def initialize(value, context: value,
|
40
|
+
def initialize(value, context: value, filepath: nil)
|
41
41
|
@value = value
|
42
42
|
@context = context
|
43
|
+
@filepath = filepath
|
44
|
+
dir = File.dirname(File.expand_path(filepath)) if filepath
|
43
45
|
@dir = (dir && File.absolute_path(dir)) || Dir.pwd
|
44
46
|
end
|
45
47
|
|
@@ -50,12 +52,14 @@ module OpenapiFirst
|
|
50
52
|
# The object where this node was found in
|
51
53
|
attr_reader :context
|
52
54
|
|
55
|
+
private attr_reader :filepath
|
56
|
+
|
53
57
|
def resolve_ref(pointer)
|
54
58
|
if pointer.start_with?('#')
|
55
59
|
value = Hana::Pointer.new(pointer[1..]).eval(context)
|
56
60
|
raise "Unknown reference #{pointer} in #{context}" unless value
|
57
61
|
|
58
|
-
return RefResolver.for(value,
|
62
|
+
return RefResolver.for(value, filepath:, context:)
|
59
63
|
end
|
60
64
|
|
61
65
|
relative_path, file_pointer = pointer.split('#')
|
@@ -63,9 +67,12 @@ module OpenapiFirst
|
|
63
67
|
return RefResolver.load(full_path) unless file_pointer
|
64
68
|
|
65
69
|
file_contents = FileLoader.load(full_path)
|
66
|
-
new_dir = File.dirname(full_path)
|
67
70
|
value = Hana::Pointer.new(file_pointer).eval(file_contents)
|
68
|
-
RefResolver.for(value,
|
71
|
+
RefResolver.for(value, filepath: full_path, context: file_contents)
|
72
|
+
rescue OpenapiFirst::FileNotFoundError => e
|
73
|
+
message = "Problem with reference resolving #{pointer.inspect} in " \
|
74
|
+
"file #{File.absolute_path(filepath).inspect}: #{e.message}"
|
75
|
+
raise OpenapiFirst::FileNotFoundError, message
|
69
76
|
end
|
70
77
|
end
|
71
78
|
|
@@ -91,18 +98,18 @@ module OpenapiFirst
|
|
91
98
|
def [](key)
|
92
99
|
return resolve_ref(@value['$ref'])[key] if !@value.key?(key) && @value.key?('$ref')
|
93
100
|
|
94
|
-
RefResolver.for(@value[key],
|
101
|
+
RefResolver.for(@value[key], filepath:, context:)
|
95
102
|
end
|
96
103
|
|
97
104
|
def fetch(key)
|
98
105
|
return resolve_ref(@value['$ref']).fetch(key) if !@value.key?(key) && @value.key?('$ref')
|
99
106
|
|
100
|
-
RefResolver.for(@value.fetch(key),
|
107
|
+
RefResolver.for(@value.fetch(key), filepath:, context:)
|
101
108
|
end
|
102
109
|
|
103
110
|
def each
|
104
111
|
resolved.each do |key, value|
|
105
|
-
yield key, RefResolver.for(value,
|
112
|
+
yield key, RefResolver.for(value, filepath:, context:)
|
106
113
|
end
|
107
114
|
end
|
108
115
|
|
@@ -126,12 +133,12 @@ module OpenapiFirst
|
|
126
133
|
item = @value[index]
|
127
134
|
return resolve_ref(item['$ref']) if item.is_a?(::Hash) && item.key?('$ref')
|
128
135
|
|
129
|
-
RefResolver.for(item,
|
136
|
+
RefResolver.for(item, filepath:, context:)
|
130
137
|
end
|
131
138
|
|
132
139
|
def each
|
133
140
|
resolved.each do |item|
|
134
|
-
yield RefResolver.for(item,
|
141
|
+
yield RefResolver.for(item, filepath:, context:)
|
135
142
|
end
|
136
143
|
end
|
137
144
|
|
@@ -10,13 +10,16 @@ module OpenapiFirst
|
|
10
10
|
# An 3.x Operation object can accept multiple requests, because it can handle multiple content-types.
|
11
11
|
# This class represents one of those requests.
|
12
12
|
class Request
|
13
|
+
# rubocop:disable Metrics/MethodLength
|
13
14
|
def initialize(path:, request_method:, operation_object:,
|
14
|
-
parameters:, content_type:, content_schema:, required_body:)
|
15
|
+
parameters:, content_type:, content_schema:, required_body:, key:)
|
15
16
|
@path = path
|
16
17
|
@request_method = request_method
|
17
18
|
@content_type = content_type
|
18
19
|
@content_schema = content_schema
|
19
20
|
@operation = operation_object
|
21
|
+
@allow_empty_content = content_type.nil? || required_body == false
|
22
|
+
@key = key
|
20
23
|
@request_parser = RequestParser.new(
|
21
24
|
query_parameters: parameters.query,
|
22
25
|
path_parameters: parameters.path,
|
@@ -33,8 +36,13 @@ module OpenapiFirst
|
|
33
36
|
cookie_schema: parameters.cookie_schema
|
34
37
|
)
|
35
38
|
end
|
39
|
+
# rubocop:enable Metrics/MethodLength
|
36
40
|
|
37
|
-
attr_reader :content_type, :content_schema, :operation, :request_method, :path
|
41
|
+
attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key
|
42
|
+
|
43
|
+
def allow_empty_content?
|
44
|
+
@allow_empty_content
|
45
|
+
end
|
38
46
|
|
39
47
|
def validate(request, route_params:)
|
40
48
|
parsed_request = nil
|
@@ -36,11 +36,28 @@ module OpenapiFirst
|
|
36
36
|
Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
|
37
37
|
end)
|
38
38
|
|
39
|
-
|
40
|
-
|
41
|
-
|
39
|
+
# Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values.
|
40
|
+
# NOTE: This behavior will probably change in the next major version.
|
41
|
+
# The uploaded file should not be read during request validation.
|
42
|
+
module MultipartBodyParser
|
43
|
+
def self.call(request)
|
44
|
+
request.POST.transform_values do |value|
|
45
|
+
unpack_value(value)
|
46
|
+
end
|
42
47
|
end
|
43
|
-
|
48
|
+
|
49
|
+
def self.unpack_value(value)
|
50
|
+
return value.map { unpack_value(_1) } if value.is_a?(Array)
|
51
|
+
return value unless value.is_a?(Hash)
|
52
|
+
return value[:tempfile]&.read if value.key?(:tempfile)
|
53
|
+
|
54
|
+
value.transform_values do |v|
|
55
|
+
unpack_value(v)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
register('multipart/form-data', MultipartBodyParser)
|
44
61
|
|
45
62
|
register('application/x-www-form-urlencoded', lambda(&:POST))
|
46
63
|
end
|
@@ -9,12 +9,13 @@ module OpenapiFirst
|
|
9
9
|
# This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
|
10
10
|
# status, content type and content schema.
|
11
11
|
class Response
|
12
|
-
def initialize(status:, headers:, headers_schema:, content_type:, content_schema:)
|
12
|
+
def initialize(status:, headers:, headers_schema:, content_type:, content_schema:, key:)
|
13
13
|
@status = status
|
14
14
|
@content_type = content_type
|
15
15
|
@content_schema = content_schema
|
16
16
|
@headers = headers
|
17
17
|
@headers_schema = headers_schema
|
18
|
+
@key = key
|
18
19
|
@parser = ResponseParser.new(headers:, content_type:)
|
19
20
|
@validator = ResponseValidator.new(self)
|
20
21
|
end
|
@@ -22,7 +23,7 @@ module OpenapiFirst
|
|
22
23
|
# @attr_reader [Integer] status The HTTP status code of the response definition.
|
23
24
|
# @attr_reader [String, nil] content_type Content type of this response.
|
24
25
|
# @attr_reader [Schema, nil] content_schema the Schema of the response body.
|
25
|
-
attr_reader :status, :content_type, :content_schema, :headers, :headers_schema
|
26
|
+
attr_reader :status, :content_type, :content_schema, :headers, :headers_schema, :key
|
26
27
|
|
27
28
|
def validate(response)
|
28
29
|
parsed_values = @parser.parse(response)
|
data/lib/openapi_first/router.rb
CHANGED
@@ -32,15 +32,18 @@ module OpenapiFirst
|
|
32
32
|
request_methods.filter_map do |request_method, content|
|
33
33
|
next if request_method == :template
|
34
34
|
|
35
|
-
Route.new(path:, request_method:, requests: content[:requests].each_value,
|
35
|
+
Route.new(path:, request_method:, requests: content[:requests].each_value.lazy.uniq,
|
36
36
|
responses: content[:responses].each_value.lazy.flat_map(&:values))
|
37
37
|
end
|
38
38
|
end
|
39
39
|
end
|
40
40
|
|
41
41
|
# Add a request definition
|
42
|
-
def add_request(request, request_method:, path:, content_type: nil)
|
43
|
-
route_at(path, request_method)
|
42
|
+
def add_request(request, request_method:, path:, content_type: nil, allow_empty_content: false)
|
43
|
+
route = route_at(path, request_method)
|
44
|
+
requests = route[:requests]
|
45
|
+
requests[content_type] = request
|
46
|
+
requests[nil] = request if allow_empty_content
|
44
47
|
end
|
45
48
|
|
46
49
|
# Add a response definition
|
@@ -3,7 +3,7 @@
|
|
3
3
|
module OpenapiFirst
|
4
4
|
class Schema
|
5
5
|
# One of multiple validation errors. Returned by Schema::ValidationResult#errors.
|
6
|
-
ValidationError = Data.define(:message, :data_pointer, :schema_pointer, :type, :details) do
|
6
|
+
ValidationError = Data.define(:value, :message, :data_pointer, :schema_pointer, :type, :details, :schema) do
|
7
7
|
# @deprecated Please use {#message} instead
|
8
8
|
def error
|
9
9
|
warn 'OpenapiFirst::Schema::ValidationError#error is deprecated. Use #message instead.'
|
@@ -16,11 +16,13 @@ module OpenapiFirst
|
|
16
16
|
def errors
|
17
17
|
@errors ||= @validation.map do |err|
|
18
18
|
ValidationError.new(
|
19
|
+
value: err['data'],
|
19
20
|
message: err['error'],
|
20
21
|
data_pointer: err['data_pointer'],
|
21
22
|
schema_pointer: err['schema_pointer'],
|
22
23
|
type: err['type'],
|
23
|
-
details: err['details']
|
24
|
+
details: err['details'],
|
25
|
+
schema: err['schema']
|
24
26
|
)
|
25
27
|
end
|
26
28
|
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'route_task'
|
4
|
+
require_relative 'response_task'
|
5
|
+
require_relative 'request_task'
|
6
|
+
|
7
|
+
module OpenapiFirst
|
8
|
+
module Test
|
9
|
+
module Coverage
|
10
|
+
# This stores the coverage data for one API description
|
11
|
+
# A plan can be #done? and has several #tasks which can be #finished?
|
12
|
+
class Plan
|
13
|
+
class UnknownRequestError < StandardError; end
|
14
|
+
|
15
|
+
def initialize(oad)
|
16
|
+
@oad = oad
|
17
|
+
@routes = []
|
18
|
+
@index = {}
|
19
|
+
@filepath = oad.filepath
|
20
|
+
oad.routes.each do |route|
|
21
|
+
add_route request_method: route.request_method,
|
22
|
+
path: route.path,
|
23
|
+
requests: route.requests,
|
24
|
+
responses: route.responses
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
attr_reader :filepath, :oad, :routes
|
29
|
+
private attr_reader :index
|
30
|
+
|
31
|
+
def track_request(validated_request)
|
32
|
+
index[validated_request.request_definition.key].track(validated_request) if validated_request.known?
|
33
|
+
end
|
34
|
+
|
35
|
+
def track_response(validated_response)
|
36
|
+
index[validated_response.response_definition.key].track(validated_response) if validated_response.known?
|
37
|
+
end
|
38
|
+
|
39
|
+
def done?
|
40
|
+
tasks.all?(&:finished?)
|
41
|
+
end
|
42
|
+
|
43
|
+
def coverage
|
44
|
+
done = tasks.count(&:finished?)
|
45
|
+
return 0 if done.zero?
|
46
|
+
|
47
|
+
all = tasks.count
|
48
|
+
(done / (all.to_f / 100)).to_i
|
49
|
+
end
|
50
|
+
|
51
|
+
def tasks
|
52
|
+
index.values
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def add_route(request_method:, path:, requests:, responses:)
|
58
|
+
request_tasks = requests.to_a.map do |request|
|
59
|
+
index[request.key] = RequestTask.new(request)
|
60
|
+
end
|
61
|
+
response_tasks = responses.to_a.map do |response|
|
62
|
+
index[response.key] = ResponseTask.new(response)
|
63
|
+
end
|
64
|
+
@routes << RouteTask.new(path:, request_method:, requests: request_tasks, responses: response_tasks)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
module Test
|
7
|
+
module Coverage
|
8
|
+
# @visibility private
|
9
|
+
class RequestTask
|
10
|
+
extend Forwardable
|
11
|
+
|
12
|
+
def_delegators :@request, :path, :request_method, :content_type
|
13
|
+
|
14
|
+
def initialize(request_definition)
|
15
|
+
@request = request_definition
|
16
|
+
@requested = false
|
17
|
+
end
|
18
|
+
|
19
|
+
attr_reader :request
|
20
|
+
|
21
|
+
def track(validated_request)
|
22
|
+
@requested = true
|
23
|
+
@valid ||= true if validated_request.valid?
|
24
|
+
end
|
25
|
+
|
26
|
+
def requested?
|
27
|
+
@requested == true
|
28
|
+
end
|
29
|
+
|
30
|
+
def any_valid_request?
|
31
|
+
@valid == true
|
32
|
+
end
|
33
|
+
|
34
|
+
def finished?
|
35
|
+
requested? && any_valid_request?
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
module Test
|
7
|
+
module Coverage
|
8
|
+
# @visibility private
|
9
|
+
class ResponseTask
|
10
|
+
extend Forwardable
|
11
|
+
|
12
|
+
def_delegators :@response, :status, :content_type, :key
|
13
|
+
|
14
|
+
def initialize(response_definition)
|
15
|
+
@response = response_definition
|
16
|
+
@responded = false
|
17
|
+
end
|
18
|
+
|
19
|
+
attr_reader :response
|
20
|
+
|
21
|
+
def track(validated_response)
|
22
|
+
@responded = true
|
23
|
+
@valid ||= true if validated_response.valid?
|
24
|
+
end
|
25
|
+
|
26
|
+
def responded?
|
27
|
+
@responded == true
|
28
|
+
end
|
29
|
+
|
30
|
+
def any_valid_response?
|
31
|
+
@valid == true
|
32
|
+
end
|
33
|
+
|
34
|
+
def finished?
|
35
|
+
responded? && any_valid_response?
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
module Test
|
5
|
+
module Coverage
|
6
|
+
RouteTask = Data.define(:path, :request_method, :requests, :responses) do
|
7
|
+
def finished?
|
8
|
+
requests.all?(&:finished?) && responses.all?(&:finished?)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
module Test
|
5
|
+
module Coverage
|
6
|
+
# This is the default formatter
|
7
|
+
class TerminalFormatter
|
8
|
+
# This takes a list of Coverage::Plan instances and outputs a String
|
9
|
+
def format(coverage_result)
|
10
|
+
@out = StringIO.new
|
11
|
+
coverage_result.plans.each { |plan| format_plan(plan) }
|
12
|
+
@out.string
|
13
|
+
end
|
14
|
+
|
15
|
+
private attr_reader :out
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def puts(string)
|
20
|
+
@out.puts(string)
|
21
|
+
end
|
22
|
+
|
23
|
+
def print(string)
|
24
|
+
@out.print(string)
|
25
|
+
end
|
26
|
+
|
27
|
+
def format_plan(plan)
|
28
|
+
filepath = plan.filepath
|
29
|
+
puts ['', "API validation coverage for #{filepath}: #{plan.coverage}%"]
|
30
|
+
return if plan.done?
|
31
|
+
|
32
|
+
plan.routes.each do |route|
|
33
|
+
next if route.finished?
|
34
|
+
|
35
|
+
format_requests(route.requests)
|
36
|
+
next if route.requests.none?(&:requested?)
|
37
|
+
|
38
|
+
format_responses(route.responses)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def format_requests(requests)
|
43
|
+
requests.each do |request|
|
44
|
+
if request.finished?
|
45
|
+
puts green "✓ #{request_label(request)}"
|
46
|
+
else
|
47
|
+
puts red "❌ #{request_label(request)} – #{explain_unfinished_request(request)}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def format_responses(responses)
|
53
|
+
responses.each do |response|
|
54
|
+
if response.finished?
|
55
|
+
puts green " ✓ #{response_label(response)}"
|
56
|
+
else
|
57
|
+
puts red " ❌ #{response_label(response)} – #{explain_unfinished_response(response)}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def green(text)
|
63
|
+
"\e[32m#{text}\e[0m"
|
64
|
+
end
|
65
|
+
|
66
|
+
def red(text)
|
67
|
+
"\e[31m#{text}\e[0m"
|
68
|
+
end
|
69
|
+
|
70
|
+
def orange(text)
|
71
|
+
"\e[33m#{text}\e[0m"
|
72
|
+
end
|
73
|
+
|
74
|
+
def request_label(request)
|
75
|
+
name = "#{request.request_method.upcase} #{request.path}" # TODO: add required query parameters?
|
76
|
+
name << " (#{request.content_type})" if request.content_type
|
77
|
+
name
|
78
|
+
end
|
79
|
+
|
80
|
+
def explain_unfinished_request(request)
|
81
|
+
return 'No requests tracked!' unless request.requested?
|
82
|
+
|
83
|
+
'All requests invalid!' unless request.any_valid_request?
|
84
|
+
end
|
85
|
+
|
86
|
+
def response_label(response)
|
87
|
+
name = +''
|
88
|
+
name += response.status.to_s
|
89
|
+
name += "(#{response.content_type})" if response.content_type
|
90
|
+
name
|
91
|
+
end
|
92
|
+
|
93
|
+
def explain_unfinished_response(response)
|
94
|
+
return 'No responses tracked!' unless response.responded?
|
95
|
+
|
96
|
+
'All responses invalid!' unless response.any_valid_response?
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'coverage/plan'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
module Test
|
7
|
+
# The Coverage module is about tracking request and response validation
|
8
|
+
# to assess if all parts of the API description have been tested.
|
9
|
+
# Currently it does not care about unknown requests that are not part of any API description.
|
10
|
+
module Coverage
|
11
|
+
autoload :TerminalFormatter, 'openapi_first/test/coverage/terminal_formatter'
|
12
|
+
|
13
|
+
Result = Data.define(:plans, :coverage)
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def start
|
17
|
+
@after_request_validation = lambda do |validated_request, oad|
|
18
|
+
track_request(validated_request, oad)
|
19
|
+
end
|
20
|
+
|
21
|
+
@after_response_validation = lambda do |validated_response, request, oad|
|
22
|
+
track_response(validated_response, request, oad)
|
23
|
+
end
|
24
|
+
|
25
|
+
OpenapiFirst.configure do |config|
|
26
|
+
config.after_request_validation(&@after_request_validation)
|
27
|
+
config.after_response_validation(&@after_response_validation)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def stop
|
32
|
+
configuration = OpenapiFirst.configuration
|
33
|
+
configuration.hooks[:after_request_validation].delete(@after_request_validation)
|
34
|
+
configuration.hooks[:after_response_validation].delete(@after_response_validation)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Clear current coverage run
|
38
|
+
def reset
|
39
|
+
@current_run = nil
|
40
|
+
end
|
41
|
+
|
42
|
+
def track_request(request, oad)
|
43
|
+
current_run[oad.filepath].track_request(request)
|
44
|
+
end
|
45
|
+
|
46
|
+
def track_response(response, _request, oad)
|
47
|
+
current_run[oad.filepath].track_response(response)
|
48
|
+
end
|
49
|
+
|
50
|
+
def result
|
51
|
+
Result.new(plans:, coverage:)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# Returns all plans (Plan) that were registered for this run
|
57
|
+
def plans
|
58
|
+
current_run.values
|
59
|
+
end
|
60
|
+
|
61
|
+
def coverage
|
62
|
+
return 0 if plans.empty?
|
63
|
+
|
64
|
+
plans.sum(&:coverage) / plans.length
|
65
|
+
end
|
66
|
+
|
67
|
+
def current_run
|
68
|
+
@current_run ||= Test.definitions.values.to_h do |oad|
|
69
|
+
[oad.filepath, Plan.new(oad)]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -18,8 +18,13 @@ module OpenapiFirst
|
|
18
18
|
"from #{request.request_method.upcase} #{request.path}."
|
19
19
|
end
|
20
20
|
|
21
|
-
api.validate_request(request, raise_error:
|
22
|
-
|
21
|
+
validated = api.validate_request(request, raise_error: false)
|
22
|
+
# :nocov:
|
23
|
+
raise validated.error.exception if validated.invalid?
|
24
|
+
|
25
|
+
validated = api.validate_response(request, response, raise_error: false)
|
26
|
+
raise validated.error.exception if validated.invalid?
|
27
|
+
# :nocov:
|
23
28
|
end
|
24
29
|
end
|
25
30
|
end
|
data/lib/openapi_first/test.rb
CHANGED
@@ -1,33 +1,91 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative 'test/methods'
|
4
|
-
|
5
3
|
module OpenapiFirst
|
6
4
|
# Test integration
|
7
5
|
module Test
|
6
|
+
autoload :Coverage, 'openapi_first/test/coverage'
|
7
|
+
autoload :Methods, 'openapi_first/test/methods'
|
8
|
+
|
8
9
|
def self.minitest?(base)
|
9
10
|
base.include?(::Minitest::Assertions)
|
10
11
|
rescue NameError
|
11
12
|
false
|
12
13
|
end
|
13
14
|
|
14
|
-
class
|
15
|
+
# Helper class to setup tests
|
16
|
+
class Setup
|
17
|
+
def register(*)
|
18
|
+
Test.register(*)
|
19
|
+
end
|
20
|
+
end
|
15
21
|
|
16
|
-
|
22
|
+
def self.setup
|
23
|
+
unless block_given?
|
24
|
+
raise ArgumentError, "Please provide a block to #{self.class}.setup to register you API descriptions"
|
25
|
+
end
|
26
|
+
|
27
|
+
Coverage.start
|
28
|
+
setup = Setup.new
|
29
|
+
yield setup
|
30
|
+
return unless definitions.empty?
|
31
|
+
|
32
|
+
raise NotRegisteredError,
|
33
|
+
'No API descriptions have been registered. ' \
|
34
|
+
'Please register your API description via ' \
|
35
|
+
"OpenapiFirst::Test.setup { |test| test.register('myopenapi.yaml') }"
|
36
|
+
end
|
17
37
|
|
18
|
-
|
38
|
+
# Print the coverage report
|
39
|
+
# @param formatter A formatter to define the report.
|
40
|
+
# @output [IO] An output where to puts the report.
|
41
|
+
def self.report_coverage(formatter: Coverage::TerminalFormatter)
|
42
|
+
coverage_result = Coverage.result
|
43
|
+
puts formatter.new.format(coverage_result)
|
44
|
+
puts "The overal API validation coverage of this run is: #{coverage_result.coverage}%"
|
45
|
+
end
|
19
46
|
|
20
|
-
|
21
|
-
|
47
|
+
# Returns the Rack app wrapped with silent request, response validation
|
48
|
+
# You can use this if you want to track coverage via Test::Coverage, but don't want to use
|
49
|
+
# the middlewares or manual request, response validation.
|
50
|
+
def self.app(app, spec: nil, api: :default)
|
51
|
+
spec ||= self[api]
|
52
|
+
Rack::Builder.app do
|
53
|
+
use OpenapiFirst::Middlewares::ResponseValidation, spec:, raise_error: false
|
54
|
+
use OpenapiFirst::Middlewares::RequestValidation, spec:, raise_error: false, error_response: false
|
55
|
+
run app
|
56
|
+
end
|
22
57
|
end
|
23
58
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
59
|
+
class NotRegisteredError < StandardError; end
|
60
|
+
class AlreadyRegisteredError < StandardError; end
|
61
|
+
|
62
|
+
@definitions = {}
|
63
|
+
|
64
|
+
class << self
|
65
|
+
attr_reader :definitions
|
66
|
+
|
67
|
+
def register(path, as: :default)
|
68
|
+
if definitions.key?(:default)
|
69
|
+
raise(
|
70
|
+
AlreadyRegisteredError,
|
71
|
+
"#{definitions[as].filepath.inspect} is already registered " \
|
72
|
+
"as ':default' so you cannot register #{path.inspect} without " \
|
73
|
+
'giving it a custom name. Please call register with a custom key like: ' \
|
74
|
+
"OpenapiFirst::Test.register(#{path.inspect}, as: :my_other_api)"
|
75
|
+
)
|
76
|
+
end
|
77
|
+
|
78
|
+
definitions[as] = OpenapiFirst.load(path)
|
79
|
+
end
|
80
|
+
|
81
|
+
def [](api)
|
82
|
+
definitions.fetch(api) do
|
83
|
+
option = api == :default ? '' : ", as: #{api.inspect}"
|
84
|
+
raise(NotRegisteredError,
|
85
|
+
"API description '#{api.inspect}' not found." \
|
86
|
+
"Please call OpenapiFirst::Test.register('myopenapi.yaml'#{option}) " \
|
87
|
+
'once before running tests.')
|
88
|
+
end
|
31
89
|
end
|
32
90
|
end
|
33
91
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: openapi_first
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andreas Haller
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
10
|
+
date: 2025-02-14 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: hana
|
@@ -93,7 +93,6 @@ files:
|
|
93
93
|
- LICENSE.txt
|
94
94
|
- README.md
|
95
95
|
- lib/openapi_first.rb
|
96
|
-
- lib/openapi_first/body_parser.rb
|
97
96
|
- lib/openapi_first/builder.rb
|
98
97
|
- lib/openapi_first/configuration.rb
|
99
98
|
- lib/openapi_first/definition.rb
|
@@ -122,6 +121,12 @@ files:
|
|
122
121
|
- lib/openapi_first/schema/validation_error.rb
|
123
122
|
- lib/openapi_first/schema/validation_result.rb
|
124
123
|
- lib/openapi_first/test.rb
|
124
|
+
- lib/openapi_first/test/coverage.rb
|
125
|
+
- lib/openapi_first/test/coverage/plan.rb
|
126
|
+
- lib/openapi_first/test/coverage/request_task.rb
|
127
|
+
- lib/openapi_first/test/coverage/response_task.rb
|
128
|
+
- lib/openapi_first/test/coverage/route_task.rb
|
129
|
+
- lib/openapi_first/test/coverage/terminal_formatter.rb
|
125
130
|
- lib/openapi_first/test/methods.rb
|
126
131
|
- lib/openapi_first/test/minitest_helpers.rb
|
127
132
|
- lib/openapi_first/test/plain_helpers.rb
|
@@ -1,45 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module OpenapiFirst
|
4
|
-
# @!visibility private
|
5
|
-
module BodyParser
|
6
|
-
def self.[](content_type)
|
7
|
-
case content_type
|
8
|
-
when /json/i
|
9
|
-
JsonBodyParser
|
10
|
-
when %r{multipart/form-data}i
|
11
|
-
MultipartBodyParser
|
12
|
-
else
|
13
|
-
DefaultBodyParser
|
14
|
-
end
|
15
|
-
end
|
16
|
-
|
17
|
-
def self.read_body(request)
|
18
|
-
body = request.body&.read
|
19
|
-
request.body.rewind if request.body.respond_to?(:rewind)
|
20
|
-
body
|
21
|
-
end
|
22
|
-
|
23
|
-
JsonBodyParser = lambda do |request|
|
24
|
-
body = read_body(request)
|
25
|
-
return if body.nil? || body.empty?
|
26
|
-
|
27
|
-
JSON.parse(body)
|
28
|
-
rescue JSON::ParserError
|
29
|
-
Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
|
30
|
-
end
|
31
|
-
|
32
|
-
MultipartBodyParser = lambda do |request|
|
33
|
-
request.POST.transform_values do |value|
|
34
|
-
value.is_a?(Hash) && value[:tempfile] ? value[:tempfile].read : value
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
# This returns the post data parsed by rack or the raw body
|
39
|
-
DefaultBodyParser = lambda do |request|
|
40
|
-
return request.POST if request.form_data?
|
41
|
-
|
42
|
-
read_body(request)
|
43
|
-
end
|
44
|
-
end
|
45
|
-
end
|