openapi_first 2.2.3 → 2.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +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
|