openapi_first 2.5.1 → 2.7.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 +14 -0
- data/README.md +64 -22
- data/lib/openapi_first/builder.rb +60 -60
- data/lib/openapi_first/configuration.rb +2 -1
- data/lib/openapi_first/definition.rb +29 -6
- data/lib/openapi_first/header.rb +9 -0
- data/lib/openapi_first/middlewares/request_validation.rb +10 -3
- data/lib/openapi_first/middlewares/response_validation.rb +10 -5
- data/lib/openapi_first/ref_resolver.rb +22 -12
- data/lib/openapi_first/response.rb +3 -4
- data/lib/openapi_first/response_parser.rb +11 -4
- data/lib/openapi_first/response_validator.rb +4 -4
- data/lib/openapi_first/schema/hash.rb +43 -0
- data/lib/openapi_first/schema/validation_error.rb +38 -1
- data/lib/openapi_first/schema/validation_result.rb +0 -1
- data/lib/openapi_first/test/coverage/plan.rb +4 -3
- data/lib/openapi_first/test/coverage/terminal_formatter.rb +6 -5
- data/lib/openapi_first/test/coverage.rb +3 -3
- data/lib/openapi_first/test/methods.rb +46 -3
- data/lib/openapi_first/test/minitest_helpers.rb +1 -1
- data/lib/openapi_first/test/plain_helpers.rb +1 -1
- data/lib/openapi_first/test.rb +13 -9
- data/lib/openapi_first/validators/request_parameters.rb +1 -5
- data/lib/openapi_first/validators/response_body.rb +0 -7
- data/lib/openapi_first/validators/response_headers.rb +25 -12
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +10 -2
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 177998c88421283a6868e3aa9c5bbbe1eea702bc8b65abb22c28795cb2b7b6a2
|
4
|
+
data.tar.gz: 603118b530e39957ea95086d98a95362e9eb9b9490a63adc7c2bc8e0f7ba7846
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 58aac32a5c8d7433414f4bf0f2cbf8142376c76d9ad918bf06531f40da0206e870a12e36718ca7763dd273ea18b664799f22acff84df143e36591a19284895fd
|
7
|
+
data.tar.gz: cba873da9fa8b1bf957a47fe34dcd30c50af51185dc83ba78c3f78ad2d5630399555c7d81f89f6e7cb96d9c56771c55aa956900249c2a6700d88869233339f6d
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,20 @@
|
|
2
2
|
|
3
3
|
## Unreleased
|
4
4
|
|
5
|
+
## 2.7.0
|
6
|
+
|
7
|
+
- Allow to override path for schema matching with `config.path = ->(request) { '/prefix' + request.path } ` (https://github.com/ahx/openapi_first/issues/349)
|
8
|
+
- Support passing in a Definition instance when registering an OAD for tests (https://github.com/ahx/openapi_first/issues/353)
|
9
|
+
- Fix registering multiple APIs for testing (https://github.com/ahx/openapi_first/issues/352)
|
10
|
+
|
11
|
+
## 2.6.0
|
12
|
+
|
13
|
+
- Middlewares now accept the OAD as a first positional argument instead of `:spec` inside the options hash.
|
14
|
+
- No longer merge parameter schemas of the same location (for example "query") in order to fix [#320](https://github.com/ahx/openapi_first/issues/320).
|
15
|
+
- `OpenapiFirst::Test::Methods[MyApplication]` returns a Module which adds an `app` method to be used by rack-test alonside the `assert_api_conform` method.
|
16
|
+
- Make default coverage report less verbose
|
17
|
+
The default formatter (TerminalFormatter) no longer prints all un-requested requests by default. You can set `test.coverage_formatter_options = { focused: false }` to get back the old behavior
|
18
|
+
|
5
19
|
## 2.5.1
|
6
20
|
|
7
21
|
- Fix skipping skipped responses during coverage tracking
|
data/README.md
CHANGED
@@ -19,6 +19,7 @@ You can use openapi_first on production for [request validation](#request-valida
|
|
19
19
|
- [Configuration](#configuration)
|
20
20
|
- [Hooks](#hooks)
|
21
21
|
- [Alternatives](#alternatives)
|
22
|
+
- [Frequently Asked Questions](#frequently-asked-questions)
|
22
23
|
- [Development](#development)
|
23
24
|
- [Benchmarks](#benchmarks)
|
24
25
|
- [Contributing](#contributing)
|
@@ -29,13 +30,13 @@ You can use openapi_first on production for [request validation](#request-valida
|
|
29
30
|
|
30
31
|
### Request validation
|
31
32
|
|
32
|
-
The request validation middleware returns a 4xx if the request is invalid or not defined in the API description. It adds a request object to the current Rack environment at `env[OpenapiFirst::REQUEST]` with the request parameters parsed
|
33
|
+
The request validation middleware returns a 4xx if the request is invalid or not defined in the API description. It adds a request object to the current Rack environment at `env[OpenapiFirst::REQUEST]` with the request parameters parsed exactly as described in your API description plus access to meta information from your API description. See _[Manual use](#manual-use)_ for more details about that object.
|
33
34
|
|
34
35
|
```ruby
|
35
|
-
use OpenapiFirst::Middlewares::RequestValidation,
|
36
|
+
use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml'
|
36
37
|
|
37
38
|
# Pass `raise_error: true` to raise an error if request is invalid:
|
38
|
-
use OpenapiFirst::Middlewares::RequestValidation,
|
39
|
+
use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml', raise_error: true
|
39
40
|
```
|
40
41
|
|
41
42
|
#### Error responses
|
@@ -73,7 +74,7 @@ content-type: "application/problem+json"
|
|
73
74
|
openapi_first offers a [JSON:API](https://jsonapi.org/) error response by passing `error_response: :jsonapi`:
|
74
75
|
|
75
76
|
```ruby
|
76
|
-
use OpenapiFirst::Middlewares::RequestValidation,
|
77
|
+
use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml, error_response: :jsonapi'
|
77
78
|
```
|
78
79
|
|
79
80
|
<details>
|
@@ -126,45 +127,57 @@ This middleware raises an error by default if the response is not valid.
|
|
126
127
|
This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
|
127
128
|
|
128
129
|
```ruby
|
129
|
-
use OpenapiFirst::Middlewares::ResponseValidation,
|
130
|
+
use OpenapiFirst::Middlewares::ResponseValidation, 'openapi.yaml' if ENV['RACK_ENV'] == 'test'
|
130
131
|
|
131
132
|
# Pass `raise_error: false` to not raise an error:
|
132
|
-
use OpenapiFirst::Middlewares::ResponseValidation,
|
133
|
+
use OpenapiFirst::Middlewares::ResponseValidation, 'openapi.yaml', raise_error: false
|
133
134
|
```
|
134
135
|
|
135
136
|
If you are adopting OpenAPI you can use these options together with [hooks](#hooks) to get notified about requests/responses that do match your API description.
|
136
137
|
|
137
138
|
## Contract Testing
|
138
139
|
|
140
|
+
Here are two aspects of contract testing: Validation and Coverage
|
141
|
+
|
142
|
+
### Validation
|
143
|
+
|
144
|
+
By validating requests and responses, you can avoid that your API implementation processes requests or returns responses that don't match your API description. You can use [test assertions](#test-assertions) or [rack middlewares](#rack-middlewares) or manual validation to validate requests and responses with openapi_first.
|
145
|
+
|
139
146
|
### Coverage
|
140
147
|
|
148
|
+
To make sure your _whole_ API description is implemented, openapi_first ships with a coverage feature.
|
149
|
+
|
141
150
|
> [!NOTE]
|
142
151
|
> This is a brand new feature. ✨ Your feedback is very welcome.
|
143
152
|
|
144
|
-
This feature tracks all requests/
|
145
|
-
|
146
|
-
|
147
|
-
Here is how to set it up for RSpec in your `spec/spec_helper.rb`:
|
153
|
+
This feature tracks all requests/responses that are validated via openapi_first and tells you about which request/responses are missing.
|
154
|
+
Here is how to set it up with [rack-test](https://github.com/rack/rack-test):
|
148
155
|
|
149
|
-
1. Register all OpenAPI documents to track coverage for
|
156
|
+
1. Register all OpenAPI documents to track coverage for. This should go at the top of your test helper file before loading your application code.
|
150
157
|
```ruby
|
151
158
|
require 'openapi_first'
|
152
|
-
OpenapiFirst::Test.setup do |
|
159
|
+
OpenapiFirst::Test.setup do |test|
|
153
160
|
test.register('openapi/openapi.yaml')
|
154
|
-
test.minimum_coverage = 100 # Setting this will lead to an `exit 2` if coverage is below minimum
|
155
|
-
test.skip_response_coverage { it.status == '500' }
|
161
|
+
test.minimum_coverage = 100 # (Optional) Setting this will lead to an `exit 2` if coverage is below minimum
|
162
|
+
test.skip_response_coverage { it.status == '500' } # (Optional) Skip certain responses
|
156
163
|
end
|
157
164
|
```
|
158
|
-
2.
|
165
|
+
2. Add an `app` method to your tests, which wraps your application with silent request / response validation. This validates all requests/responses in your test run. (✷1)
|
166
|
+
|
159
167
|
```ruby
|
160
|
-
|
161
|
-
|
162
|
-
OpenapiFirst::Test.app(App)
|
163
|
-
end
|
168
|
+
def app
|
169
|
+
OpenapiFirst::Test.app(MyApp)
|
164
170
|
end
|
165
171
|
```
|
172
|
+
3. Run your tests. The Coverage feature will tell you about missing request/responses.
|
173
|
+
|
174
|
+
Or you can generate a Module and include it in your rspec spec_helper.rb:
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
config.include OpenapiFirst::Test::Methods[MyApp], type: :request
|
178
|
+
```
|
166
179
|
|
167
|
-
(✷1): Instead of using `OpenapiFirstTest.app` to wrap your application, you
|
180
|
+
(✷1): It does not matter what method of openapi_first you use to validate requests/responses. Instead of using `OpenapiFirstTest.app` to wrap your application, you could also 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.
|
168
181
|
|
169
182
|
### Test assertions
|
170
183
|
|
@@ -296,7 +309,7 @@ Setup globally:
|
|
296
309
|
```ruby
|
297
310
|
OpenapiFirst.configure do |config|
|
298
311
|
config.after_request_parameter_property_validation do |data, property, property_schema|
|
299
|
-
data[property] = Date.iso8601(data[property]) if
|
312
|
+
data[property] = Date.iso8601(data[property]) if property_schema['format'] == 'date'
|
300
313
|
end
|
301
314
|
end
|
302
315
|
```
|
@@ -306,11 +319,40 @@ end
|
|
306
319
|
Using rack middlewares is supported in probably all Ruby web frameworks.
|
307
320
|
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.
|
308
321
|
|
322
|
+
The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.
|
323
|
+
|
324
|
+
That aside, closer integration with specific frameworks like Sinatra, Hanami, Roda or Rails would be great. If you have ideas, pain points or PRs, please don't hesitate to [share](https://github.com/ahx/openapi_first/discussions).
|
325
|
+
|
309
326
|
## Alternatives
|
310
327
|
|
311
|
-
This gem was inspired by [
|
328
|
+
This gem was inspired by [committee](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
|
312
329
|
Here is a [feature comparison between openapi_first and committee](https://gist.github.com/ahx/1538c31f0652f459861713b5259e366a).
|
313
330
|
|
331
|
+
## Frequently Asked Questions
|
332
|
+
|
333
|
+
### How can I adapt request paths that don't match my schema?
|
334
|
+
|
335
|
+
If your API is deployed at a different path than what's defined in your OpenAPI schema, you can use `env[OpenapiFirst::PATH]` to override the path used for schema matching.
|
336
|
+
|
337
|
+
Let's say you have `openapi.yaml` like this:
|
338
|
+
|
339
|
+
```yaml
|
340
|
+
servers:
|
341
|
+
- url: https://yourhost/api
|
342
|
+
paths:
|
343
|
+
# The actual endpoint URL is https://yourhost/api/resource
|
344
|
+
/resource:
|
345
|
+
```
|
346
|
+
|
347
|
+
Here your OpenAPI schema defines endpoints starting with `/resource` but your actual application is mounted at `/api/resource`. You can bridge the gap by transforming the path via the `path:` configuration:
|
348
|
+
|
349
|
+
```ruby
|
350
|
+
oad = OpenapiFirst.load('openapi.yaml') do |config|
|
351
|
+
config.path = ->(req) { request.path.delete_prefix('/api') }
|
352
|
+
end
|
353
|
+
use OpenapiFirst::Middlewares::RequestValidation, oad
|
354
|
+
```
|
355
|
+
|
314
356
|
## Development
|
315
357
|
|
316
358
|
Run `bin/setup` to install dependencies.
|
@@ -1,6 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'json_schemer'
|
4
|
+
|
5
|
+
require_relative 'failure'
|
6
|
+
require_relative 'router'
|
7
|
+
require_relative 'header'
|
8
|
+
require_relative 'request'
|
9
|
+
require_relative 'response'
|
10
|
+
require_relative 'schema/hash'
|
4
11
|
require_relative 'ref_resolver'
|
5
12
|
|
6
13
|
module OpenapiFirst
|
@@ -17,10 +24,8 @@ module OpenapiFirst
|
|
17
24
|
end
|
18
25
|
|
19
26
|
def initialize(contents, filepath:, config:)
|
20
|
-
|
21
|
-
@schemer_configuration
|
22
|
-
@schemer_configuration.insert_property_defaults = true
|
23
|
-
|
27
|
+
meta_schema = detect_meta_schema(contents, filepath)
|
28
|
+
@schemer_configuration = build_schemer_config(filepath:, meta_schema:)
|
24
29
|
@config = config
|
25
30
|
@contents = RefResolver.for(contents, filepath:)
|
26
31
|
end
|
@@ -28,15 +33,25 @@ module OpenapiFirst
|
|
28
33
|
attr_reader :config
|
29
34
|
private attr_reader :schemer_configuration
|
30
35
|
|
36
|
+
def build_schemer_config(filepath:, meta_schema:)
|
37
|
+
result = JSONSchemer.configuration.clone
|
38
|
+
dir = (filepath && File.absolute_path(File.dirname(filepath))) || Dir.pwd
|
39
|
+
result.base_uri = URI::File.build({ path: "#{dir}/" })
|
40
|
+
result.ref_resolver = JSONSchemer::CachedResolver.new do |uri|
|
41
|
+
FileLoader.load(uri.path)
|
42
|
+
end
|
43
|
+
result.meta_schema = meta_schema
|
44
|
+
result.insert_property_defaults = true
|
45
|
+
result
|
46
|
+
end
|
47
|
+
|
31
48
|
def detect_meta_schema(document, filepath)
|
32
49
|
# Copied from JSONSchemer 🙇🏻♂️
|
33
50
|
version = document['openapi']
|
34
51
|
case version
|
35
52
|
when /\A3\.1\.\d+\z/
|
36
|
-
@document_schema = JSONSchemer.openapi31_document
|
37
53
|
document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
|
38
54
|
when /\A3\.0\.\d+\z/
|
39
|
-
@document_schema = JSONSchemer.openapi30_document
|
40
55
|
JSONSchemer::OpenAPI30::BASE_URI.to_s
|
41
56
|
else
|
42
57
|
raise Error, "Unsupported OpenAPI version #{version.inspect} #{filepath}"
|
@@ -46,10 +61,10 @@ module OpenapiFirst
|
|
46
61
|
def router # rubocop:disable Metrics/MethodLength
|
47
62
|
router = OpenapiFirst::Router.new
|
48
63
|
@contents.fetch('paths').each do |path, path_item_object|
|
49
|
-
path_parameters =
|
64
|
+
path_parameters = path_item_object['parameters'] || []
|
50
65
|
path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
|
51
66
|
operation_object = path_item_object[request_method]
|
52
|
-
operation_parameters =
|
67
|
+
operation_parameters = operation_object['parameters'] || []
|
53
68
|
parameters = parse_parameters(operation_parameters.chain(path_parameters))
|
54
69
|
|
55
70
|
build_requests(path:, request_method:, operation_object:,
|
@@ -79,10 +94,10 @@ module OpenapiFirst
|
|
79
94
|
def parse_parameters(parameters)
|
80
95
|
grouped_parameters = group_parameters(parameters)
|
81
96
|
ParsedParameters.new(
|
82
|
-
query: grouped_parameters[:query],
|
83
|
-
path: grouped_parameters[:path],
|
84
|
-
cookie: grouped_parameters[:cookie],
|
85
|
-
header: grouped_parameters[:header],
|
97
|
+
query: resolve_parameters(grouped_parameters[:query]),
|
98
|
+
path: resolve_parameters(grouped_parameters[:path]),
|
99
|
+
cookie: resolve_parameters(grouped_parameters[:cookie]),
|
100
|
+
header: resolve_parameters(grouped_parameters[:header]),
|
86
101
|
query_schema: build_parameter_schema(grouped_parameters[:query]),
|
87
102
|
path_schema: build_parameter_schema(grouped_parameters[:path]),
|
88
103
|
cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
|
@@ -99,11 +114,18 @@ module OpenapiFirst
|
|
99
114
|
end
|
100
115
|
|
101
116
|
def build_parameter_schema(parameters)
|
102
|
-
|
117
|
+
return unless parameters
|
103
118
|
|
104
|
-
|
105
|
-
|
106
|
-
|
119
|
+
required = []
|
120
|
+
schemas = parameters.each_with_object({}) do |parameter, result|
|
121
|
+
schema = parameter['schema'].schema(configuration: schemer_configuration)
|
122
|
+
name = parameter['name']&.value
|
123
|
+
required << name if parameter['required']&.value
|
124
|
+
result[name] = schema if schema
|
125
|
+
end
|
126
|
+
|
127
|
+
Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
|
128
|
+
after_property_validation: config.hooks[:after_request_parameter_property_validation])
|
107
129
|
end
|
108
130
|
|
109
131
|
def build_requests(path:, request_method:, operation_object:, parameters:)
|
@@ -141,20 +163,15 @@ module OpenapiFirst
|
|
141
163
|
return [] unless responses
|
142
164
|
|
143
165
|
responses.flat_map do |status, response_object|
|
144
|
-
headers = response_object['headers']
|
145
|
-
headers_schema = JSONSchemer::Schema.new(
|
146
|
-
build_headers_schema(headers),
|
147
|
-
configuration: schemer_configuration
|
148
|
-
)
|
166
|
+
headers = build_response_headers(response_object['headers'])
|
149
167
|
response_object['content']&.map do |content_type, content_object|
|
150
168
|
content_schema = content_object['schema'].schema(configuration: schemer_configuration)
|
151
169
|
Response.new(status:,
|
152
170
|
headers:,
|
153
|
-
headers_schema:,
|
154
171
|
content_type:,
|
155
172
|
content_schema:,
|
156
173
|
key: [request.key, status, content_type].join(':'))
|
157
|
-
end || Response.new(status:, headers:,
|
174
|
+
end || Response.new(status:, headers:, content_type: nil,
|
158
175
|
content_schema: nil, key: [request.key, status, nil].join(':'))
|
159
176
|
end
|
160
177
|
end
|
@@ -162,49 +179,32 @@ module OpenapiFirst
|
|
162
179
|
IGNORED_HEADER_PARAMETERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
|
163
180
|
private_constant :IGNORED_HEADER_PARAMETERS
|
164
181
|
|
165
|
-
def
|
166
|
-
|
167
|
-
parameter_definitions&.each do |parameter|
|
168
|
-
(result[parameter['in'].to_sym] ||= []) << parameter
|
169
|
-
end
|
170
|
-
result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']) }
|
171
|
-
result
|
172
|
-
end
|
173
|
-
|
174
|
-
def build_headers_schema(headers_object)
|
175
|
-
return unless headers_object&.any?
|
182
|
+
def build_response_headers(headers_object)
|
183
|
+
return if headers_object.nil?
|
176
184
|
|
177
|
-
|
178
|
-
required = []
|
185
|
+
result = []
|
179
186
|
headers_object.each do |name, header|
|
180
|
-
|
181
|
-
next if
|
182
|
-
|
183
|
-
|
184
|
-
|
187
|
+
next if header['schema'].nil?
|
188
|
+
next if IGNORED_HEADER_PARAMETERS.include?(name)
|
189
|
+
|
190
|
+
header = Header.new(
|
191
|
+
name:,
|
192
|
+
schema: header['schema'].schema(configuration: schemer_configuration),
|
193
|
+
required?: header['required']&.value == true,
|
194
|
+
node: header
|
195
|
+
)
|
196
|
+
result << header
|
185
197
|
end
|
186
|
-
|
187
|
-
'properties' => properties,
|
188
|
-
'required' => required
|
189
|
-
}
|
198
|
+
result
|
190
199
|
end
|
191
200
|
|
192
|
-
def
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
required = []
|
197
|
-
parameters.each do |parameter|
|
198
|
-
schema = parameter['schema']
|
199
|
-
name = parameter['name']
|
200
|
-
properties[name] = schema if schema
|
201
|
-
required << name if parameter['required']
|
201
|
+
def group_parameters(parameter_definitions)
|
202
|
+
result = {}
|
203
|
+
parameter_definitions&.each do |parameter|
|
204
|
+
(result[parameter['in']&.value&.to_sym] ||= []) << parameter
|
202
205
|
end
|
203
|
-
|
204
|
-
|
205
|
-
'properties' => properties,
|
206
|
-
'required' => required
|
207
|
-
}
|
206
|
+
result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']&.value) }
|
207
|
+
result
|
208
208
|
end
|
209
209
|
|
210
210
|
ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
|
@@ -15,10 +15,11 @@ module OpenapiFirst
|
|
15
15
|
@request_validation_raise_error = false
|
16
16
|
@response_validation_raise_error = true
|
17
17
|
@hooks = (HOOKS.map { [_1, Set.new] }).to_h
|
18
|
+
@path = nil
|
18
19
|
end
|
19
20
|
|
20
21
|
attr_reader :request_validation_error_response, :hooks
|
21
|
-
attr_accessor :request_validation_raise_error, :response_validation_raise_error
|
22
|
+
attr_accessor :request_validation_raise_error, :response_validation_raise_error, :path
|
22
23
|
|
23
24
|
def clone
|
24
25
|
copy = super
|
@@ -1,9 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative 'failure'
|
4
|
-
require_relative 'router'
|
5
|
-
require_relative 'request'
|
6
|
-
require_relative 'response'
|
7
3
|
require_relative 'builder'
|
8
4
|
require 'forwardable'
|
9
5
|
|
@@ -44,12 +40,30 @@ module OpenapiFirst
|
|
44
40
|
# @return [Enumerable[Router::Route]]
|
45
41
|
def_delegators :@router, :routes
|
46
42
|
|
43
|
+
# Returns a unique identifier for this API definition
|
44
|
+
# @return [String] A unique key for this API definition
|
45
|
+
def key
|
46
|
+
return filepath if filepath
|
47
|
+
|
48
|
+
info = self['info'] || {}
|
49
|
+
title = info['title']
|
50
|
+
version = info['version']
|
51
|
+
|
52
|
+
if title.nil? || version.nil?
|
53
|
+
raise ArgumentError,
|
54
|
+
"Cannot generate key for the OpenAPI document because 'info.title' or 'info.version' is missing. " \
|
55
|
+
'Please add these fields to your OpenAPI document.'
|
56
|
+
end
|
57
|
+
|
58
|
+
"#{title} @ #{version}"
|
59
|
+
end
|
60
|
+
|
47
61
|
# Validates the request against the API description.
|
48
62
|
# @param [Rack::Request] request The Rack request object.
|
49
63
|
# @param [Boolean] raise_error Whether to raise an error if validation fails.
|
50
64
|
# @return [ValidatedRequest] The validated request object.
|
51
65
|
def validate_request(request, raise_error: false)
|
52
|
-
route = @router.match(request.request_method, request
|
66
|
+
route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
|
53
67
|
if route.error
|
54
68
|
ValidatedRequest.new(request, error: route.error)
|
55
69
|
else
|
@@ -66,7 +80,8 @@ module OpenapiFirst
|
|
66
80
|
# @param raise_error [Boolean] Whethir to raise an error if validation fails.
|
67
81
|
# @return [ValidatedResponse] The validated response object.
|
68
82
|
def validate_response(rack_request, rack_response, raise_error: false)
|
69
|
-
route = @router.match(rack_request.request_method, rack_request
|
83
|
+
route = @router.match(rack_request.request_method, resolve_path(rack_request),
|
84
|
+
content_type: rack_request.content_type)
|
70
85
|
return if route.error # Skip response validation for unknown requests
|
71
86
|
|
72
87
|
response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
|
@@ -80,5 +95,13 @@ module OpenapiFirst
|
|
80
95
|
raise validated.error.exception(validated) if raise_error && validated.invalid?
|
81
96
|
end
|
82
97
|
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def resolve_path(rack_request)
|
102
|
+
return rack_request.path unless @config.path
|
103
|
+
|
104
|
+
@config.path.call(rack_request)
|
105
|
+
end
|
83
106
|
end
|
84
107
|
end
|
@@ -6,19 +6,26 @@ module OpenapiFirst
|
|
6
6
|
# A Rack middleware to validate requests against an OpenAPI API description
|
7
7
|
class RequestValidation
|
8
8
|
# @param app The parent Rack application
|
9
|
-
# @param
|
9
|
+
# @param spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
10
|
+
# @param options Hash
|
11
|
+
# :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
12
|
+
# This will be deprecated. Please use spec argument instead.
|
10
13
|
# :raise_error A Boolean indicating whether to raise an error if validation fails.
|
11
14
|
# default: false
|
12
15
|
# :error_response The Class to use for error responses.
|
13
16
|
# This can be a Symbol-name of an registered error response (:default, :jsonapi)
|
14
17
|
# or it can be set to false to disable returning a response.
|
15
18
|
# default: OpenapiFirst::Plugins::Default::ErrorResponse (Config.default_options.error_response)
|
16
|
-
def initialize(app, options = {})
|
19
|
+
def initialize(app, spec = nil, options = {})
|
17
20
|
@app = app
|
21
|
+
if spec.is_a?(Hash)
|
22
|
+
options = spec
|
23
|
+
spec = options.fetch(:spec)
|
24
|
+
end
|
18
25
|
@raise = options.fetch(:raise_error, OpenapiFirst.configuration.request_validation_raise_error)
|
19
26
|
@error_response_class = error_response_option(options[:error_response])
|
20
27
|
|
21
|
-
spec
|
28
|
+
spec ||= options.fetch(:spec)
|
22
29
|
raise "You have to pass spec: when initializing #{self.class}" unless spec
|
23
30
|
|
24
31
|
@definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
|
@@ -6,15 +6,19 @@ module OpenapiFirst
|
|
6
6
|
module Middlewares
|
7
7
|
# A Rack middleware to validate requests against an OpenAPI API description
|
8
8
|
class ResponseValidation
|
9
|
-
# @param
|
9
|
+
# @param spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition
|
10
10
|
# @param options Hash
|
11
|
-
# :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition
|
11
|
+
# :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
12
|
+
# This will be deprecated. Please use spec argument instead.
|
12
13
|
# :raise_error [Boolean] Whether to raise an error if validation fails. default: true
|
13
|
-
def initialize(app, options = {})
|
14
|
+
def initialize(app, spec = nil, options = {})
|
14
15
|
@app = app
|
16
|
+
if spec.is_a?(Hash)
|
17
|
+
options = spec
|
18
|
+
spec = options.fetch(:spec)
|
19
|
+
end
|
15
20
|
@raise = options.fetch(:raise_error, OpenapiFirst.configuration.response_validation_raise_error)
|
16
21
|
|
17
|
-
spec = options.fetch(:spec)
|
18
22
|
raise "You have to pass spec: when initializing #{self.class}" unless spec
|
19
23
|
|
20
24
|
@definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
|
@@ -25,7 +29,8 @@ module OpenapiFirst
|
|
25
29
|
|
26
30
|
def call(env)
|
27
31
|
status, headers, body = @app.call(env)
|
28
|
-
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body],
|
32
|
+
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body],
|
33
|
+
raise_error: @raise)
|
29
34
|
[status, headers, body]
|
30
35
|
end
|
31
36
|
end
|
@@ -41,8 +41,11 @@ module OpenapiFirst
|
|
41
41
|
@value = value
|
42
42
|
@context = context
|
43
43
|
@filepath = filepath
|
44
|
-
dir =
|
45
|
-
|
44
|
+
@dir = if filepath
|
45
|
+
File.dirname(File.absolute_path(filepath))
|
46
|
+
else
|
47
|
+
Dir.pwd
|
48
|
+
end
|
46
49
|
end
|
47
50
|
|
48
51
|
# The value of this node
|
@@ -52,7 +55,11 @@ module OpenapiFirst
|
|
52
55
|
# The object where this node was found in
|
53
56
|
attr_reader :context
|
54
57
|
|
55
|
-
|
58
|
+
attr_reader :filepath
|
59
|
+
|
60
|
+
def ==(_other)
|
61
|
+
raise "Don't call == on an unresolved value. Use .value == other instead."
|
62
|
+
end
|
56
63
|
|
57
64
|
def resolve_ref(pointer)
|
58
65
|
if pointer.start_with?('#')
|
@@ -89,6 +96,10 @@ module OpenapiFirst
|
|
89
96
|
include Diggable
|
90
97
|
include Enumerable
|
91
98
|
|
99
|
+
def ==(_other)
|
100
|
+
raise "Don't call == on an unresolved value. Use .value == other instead."
|
101
|
+
end
|
102
|
+
|
92
103
|
def resolved
|
93
104
|
return resolve_ref(value['$ref']).value if value.key?('$ref')
|
94
105
|
|
@@ -108,17 +119,16 @@ module OpenapiFirst
|
|
108
119
|
end
|
109
120
|
|
110
121
|
def each
|
111
|
-
resolved.
|
112
|
-
yield key,
|
122
|
+
resolved.each_key do |key|
|
123
|
+
yield key, self[key]
|
113
124
|
end
|
114
125
|
end
|
115
126
|
|
116
|
-
|
117
|
-
|
118
|
-
FileLoader.load(uri.path)
|
119
|
-
end
|
127
|
+
# You have to pass configuration or ref_resolver
|
128
|
+
def schema(options)
|
120
129
|
base_uri = URI::File.build({ path: "#{dir}/" })
|
121
|
-
root = JSONSchemer::Schema.new(context, base_uri:,
|
130
|
+
root = JSONSchemer::Schema.new(context, base_uri:, **options)
|
131
|
+
# binding.irb if value['maxItems'] == 4
|
122
132
|
JSONSchemer::Schema.new(value, nil, root, base_uri:, **options)
|
123
133
|
end
|
124
134
|
end
|
@@ -137,8 +147,8 @@ module OpenapiFirst
|
|
137
147
|
end
|
138
148
|
|
139
149
|
def each
|
140
|
-
resolved.
|
141
|
-
yield
|
150
|
+
resolved.each_with_index do |_item, index|
|
151
|
+
yield self[index]
|
142
152
|
end
|
143
153
|
end
|
144
154
|
|
@@ -9,21 +9,20 @@ 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:,
|
12
|
+
def initialize(status:, headers:, 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
|
-
@headers_schema = headers_schema
|
18
17
|
@key = key
|
19
18
|
@parser = ResponseParser.new(headers:, content_type:)
|
20
|
-
@validator = ResponseValidator.new(
|
19
|
+
@validator = ResponseValidator.new(content_schema:, headers:)
|
21
20
|
end
|
22
21
|
|
23
22
|
# @attr_reader [Integer] status The HTTP status code of the response definition.
|
24
23
|
# @attr_reader [String, nil] content_type Content type of this response.
|
25
24
|
# @attr_reader [Schema, nil] content_schema the Schema of the response body.
|
26
|
-
attr_reader :status, :content_type, :content_schema, :headers, :
|
25
|
+
attr_reader :status, :content_type, :content_schema, :headers, :key
|
27
26
|
|
28
27
|
def validate(response)
|
29
28
|
parsed_values = nil
|
@@ -15,7 +15,7 @@ module OpenapiFirst
|
|
15
15
|
def parse(rack_response)
|
16
16
|
ParsedResponse.new(
|
17
17
|
body: @body_parser.call(read_body(rack_response)),
|
18
|
-
headers: @headers_parser
|
18
|
+
headers: @headers_parser&.call(rack_response.headers) || {}
|
19
19
|
)
|
20
20
|
end
|
21
21
|
|
@@ -30,9 +30,16 @@ module OpenapiFirst
|
|
30
30
|
rack_response.body
|
31
31
|
end
|
32
32
|
|
33
|
-
def build_headers_parser(
|
34
|
-
|
35
|
-
|
33
|
+
def build_headers_parser(headers)
|
34
|
+
return unless headers&.any?
|
35
|
+
|
36
|
+
headers_as_parameters = headers.map do |header|
|
37
|
+
{
|
38
|
+
'name' => header.name,
|
39
|
+
'explode' => false,
|
40
|
+
'in' => 'header',
|
41
|
+
'schema' => header.resolved_schema
|
42
|
+
}
|
36
43
|
end
|
37
44
|
OpenapiParameters::Header.new(headers_as_parameters).method(:unpack)
|
38
45
|
end
|
@@ -11,10 +11,10 @@ module OpenapiFirst
|
|
11
11
|
Validators::ResponseBody
|
12
12
|
].freeze
|
13
13
|
|
14
|
-
def initialize(
|
15
|
-
@validators =
|
16
|
-
|
17
|
-
|
14
|
+
def initialize(content_schema:, headers:)
|
15
|
+
@validators = []
|
16
|
+
@validators << Validators::ResponseBody.new(content_schema) if content_schema
|
17
|
+
@validators << Validators::ResponseHeaders.new(headers) if headers&.any?
|
18
18
|
end
|
19
19
|
|
20
20
|
def call(parsed_response)
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'validation_error'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
class Schema
|
7
|
+
# A hash of Schemas
|
8
|
+
class Hash
|
9
|
+
# @param schema Hash of schemas
|
10
|
+
# @param required Array of required keys
|
11
|
+
def initialize(schemas, required: nil, **options)
|
12
|
+
@schemas = schemas
|
13
|
+
@options = options
|
14
|
+
@after_property_validation = options.delete(:after_property_validation)
|
15
|
+
schema = { 'type' => 'object' }
|
16
|
+
schema['required'] = required if required
|
17
|
+
@root_schema = JSONSchemer.schema(schema, **options)
|
18
|
+
end
|
19
|
+
|
20
|
+
def validate(root_value)
|
21
|
+
validation = @root_schema.validate(root_value)
|
22
|
+
validations = @schemas.reduce(validation) do |enum, (key, schema)|
|
23
|
+
root_value[key] = schema.value['default'] if schema.value.key?('default') && !root_value.key?(key)
|
24
|
+
next enum unless root_value.key?(key)
|
25
|
+
|
26
|
+
value = root_value[key]
|
27
|
+
key_validation = schema.validate(value)
|
28
|
+
@after_property_validation&.each do |hook|
|
29
|
+
hook.call(root_value, key, schema.value, nil)
|
30
|
+
end
|
31
|
+
enum.chain(key_validation.map do |err|
|
32
|
+
data_pointer = "/#{key}"
|
33
|
+
err.merge(
|
34
|
+
'error' => JSONSchemer::Errors.pretty(err),
|
35
|
+
'data_pointer' => data_pointer
|
36
|
+
)
|
37
|
+
end)
|
38
|
+
end
|
39
|
+
ValidationResult.new(validations)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -3,7 +3,44 @@
|
|
3
3
|
module OpenapiFirst
|
4
4
|
class Schema
|
5
5
|
# One of multiple validation errors. Returned by Schema::ValidationResult#errors.
|
6
|
-
ValidationError = Data.define(:value, :
|
6
|
+
ValidationError = Data.define(:value, :data_pointer, :schema_pointer, :type, :details, :schema) do
|
7
|
+
# This returns an error message for this specific error.
|
8
|
+
# This it copied from json_schemer here to be easier to customize when passing custom data_pointers.
|
9
|
+
def message
|
10
|
+
location = data_pointer.empty? ? 'root' : "`#{data_pointer}`"
|
11
|
+
|
12
|
+
case type
|
13
|
+
when 'required'
|
14
|
+
keys = details.fetch('missing_keys', []).join(', ')
|
15
|
+
"object at #{location} is missing required properties: #{keys}"
|
16
|
+
when 'dependentRequired'
|
17
|
+
keys = details.fetch('missing_keys').join(', ')
|
18
|
+
"object at #{location} is missing required properties: #{keys}"
|
19
|
+
when 'string', 'boolean', 'number'
|
20
|
+
"value at #{location} is not a #{type}"
|
21
|
+
when 'array', 'object', 'integer'
|
22
|
+
"value at #{location} is not an #{type}"
|
23
|
+
when 'null'
|
24
|
+
"value at #{location} is not #{type}"
|
25
|
+
when 'pattern'
|
26
|
+
"string at #{location} does not match pattern: #{schema.fetch('pattern')}"
|
27
|
+
when 'format'
|
28
|
+
"value at #{location} does not match format: #{schema.fetch('format')}"
|
29
|
+
when 'const'
|
30
|
+
"value at #{location} is not: #{schema.fetch('const').inspect}"
|
31
|
+
when 'enum'
|
32
|
+
"value at #{location} is not one of: #{schema.fetch('enum')}"
|
33
|
+
when 'minimum'
|
34
|
+
"number at #{location} is less than: #{schema['minimum']}"
|
35
|
+
when 'maximum'
|
36
|
+
"number at #{location} is greater than: #{schema['maximum']}"
|
37
|
+
when 'readOnly'
|
38
|
+
"value at #{location} is `readOnly`"
|
39
|
+
else
|
40
|
+
"value at #{location} is invalid (#{type.inspect})"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
7
44
|
# @deprecated Please use {#message} instead
|
8
45
|
def error
|
9
46
|
warn 'OpenapiFirst::Schema::ValidationError#error is deprecated. Use #message instead.'
|
@@ -13,7 +13,7 @@ module OpenapiFirst
|
|
13
13
|
class UnknownRequestError < StandardError; end
|
14
14
|
|
15
15
|
def self.for(oad, skip_response: nil)
|
16
|
-
plan = new(filepath: oad.filepath)
|
16
|
+
plan = new(definition_key: oad.key, filepath: oad.filepath)
|
17
17
|
oad.routes.each do |route|
|
18
18
|
responses = skip_response ? route.responses.reject(&skip_response) : route.responses
|
19
19
|
plan.add_route request_method: route.request_method,
|
@@ -24,13 +24,14 @@ module OpenapiFirst
|
|
24
24
|
plan
|
25
25
|
end
|
26
26
|
|
27
|
-
def initialize(filepath:)
|
27
|
+
def initialize(definition_key:, filepath: nil)
|
28
28
|
@routes = []
|
29
29
|
@index = {}
|
30
|
+
@api_identifier = filepath || definition_key
|
30
31
|
@filepath = filepath
|
31
32
|
end
|
32
33
|
|
33
|
-
attr_reader :filepath, :routes
|
34
|
+
attr_reader :api_identifier, :filepath, :routes
|
34
35
|
private attr_reader :index
|
35
36
|
|
36
37
|
def track_request(validated_request)
|
@@ -5,8 +5,9 @@ module OpenapiFirst
|
|
5
5
|
module Coverage
|
6
6
|
# This is the default formatter
|
7
7
|
class TerminalFormatter
|
8
|
-
def initialize(verbose: false)
|
8
|
+
def initialize(verbose: false, focused: true)
|
9
9
|
@verbose = verbose
|
10
|
+
@focused = focused && !verbose
|
10
11
|
end
|
11
12
|
|
12
13
|
# This takes a list of Coverage::Plan instances and outputs a String
|
@@ -16,7 +17,7 @@ module OpenapiFirst
|
|
16
17
|
@out.string
|
17
18
|
end
|
18
19
|
|
19
|
-
private attr_reader :out, :verbose
|
20
|
+
private attr_reader :out, :verbose, :focused
|
20
21
|
|
21
22
|
private
|
22
23
|
|
@@ -29,15 +30,15 @@ module OpenapiFirst
|
|
29
30
|
end
|
30
31
|
|
31
32
|
def format_plan(plan)
|
32
|
-
|
33
|
-
puts ['', "API validation coverage for #{filepath}: #{plan.coverage}%"]
|
33
|
+
puts ['', "API validation coverage for #{plan.api_identifier}: #{plan.coverage}%"]
|
34
34
|
return if plan.done? && !verbose
|
35
35
|
|
36
36
|
plan.routes.each do |route|
|
37
37
|
next if route.finished? && !verbose
|
38
38
|
|
39
|
+
next if route.requests.none?(&:requested?) && focused
|
40
|
+
|
39
41
|
format_requests(route.requests)
|
40
|
-
next if route.requests.none?(&:requested?)
|
41
42
|
|
42
43
|
format_responses(route.responses)
|
43
44
|
end
|
@@ -36,7 +36,7 @@ module OpenapiFirst
|
|
36
36
|
def start(skip_response: nil)
|
37
37
|
@current_run = Test.definitions.values.to_h do |oad|
|
38
38
|
plan = Plan.for(oad, skip_response:)
|
39
|
-
[oad.
|
39
|
+
[oad.key, plan]
|
40
40
|
end
|
41
41
|
end
|
42
42
|
|
@@ -53,11 +53,11 @@ module OpenapiFirst
|
|
53
53
|
end
|
54
54
|
|
55
55
|
def track_request(request, oad)
|
56
|
-
current_run[oad.
|
56
|
+
current_run[oad.key]&.track_request(request)
|
57
57
|
end
|
58
58
|
|
59
59
|
def track_response(response, _request, oad)
|
60
|
-
current_run[oad.
|
60
|
+
current_run[oad.key]&.track_response(response)
|
61
61
|
end
|
62
62
|
|
63
63
|
def result
|
@@ -8,10 +8,53 @@ module OpenapiFirst
|
|
8
8
|
# Methods to use in integration tests
|
9
9
|
module Methods
|
10
10
|
def self.included(base)
|
11
|
-
|
12
|
-
|
11
|
+
base.include(DefaultApiMethod)
|
12
|
+
base.include(AssertionMethod)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.[](application_under_test = nil, api: nil)
|
16
|
+
mod = Module.new do
|
17
|
+
def self.included(base)
|
18
|
+
base.include OpenapiFirst::Test::Methods::AssertionMethod
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
if api
|
23
|
+
mod.define_method(:openapi_first_default_api) { api }
|
13
24
|
else
|
14
|
-
|
25
|
+
mod.include(DefaultApiMethod)
|
26
|
+
end
|
27
|
+
|
28
|
+
if application_under_test
|
29
|
+
mod.define_method(:app) { OpenapiFirst::Test.app(application_under_test, api: openapi_first_default_api) }
|
30
|
+
end
|
31
|
+
|
32
|
+
mod
|
33
|
+
end
|
34
|
+
|
35
|
+
# Default methods
|
36
|
+
module DefaultApiMethod
|
37
|
+
# This is the default api that is used by assert_api_conform
|
38
|
+
# :default is the default name that is used if you don't pass an `api:` option to `OpenapiFirst::Test.register`
|
39
|
+
# This is overwritten if you pass an `api:` option to `include OpenapiFirst::Test::Methods[…]`
|
40
|
+
def openapi_first_default_api
|
41
|
+
klass = self.class
|
42
|
+
if klass.respond_to?(:metadata) && klass.metadata[:api]
|
43
|
+
klass.metadata[:api]
|
44
|
+
else
|
45
|
+
:default
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# @visibility private
|
51
|
+
module AssertionMethod
|
52
|
+
def self.included(base)
|
53
|
+
if Test.minitest?(base)
|
54
|
+
base.include(OpenapiFirst::Test::MinitestHelpers)
|
55
|
+
else
|
56
|
+
base.include(OpenapiFirst::Test::PlainHelpers)
|
57
|
+
end
|
15
58
|
end
|
16
59
|
end
|
17
60
|
end
|
@@ -5,7 +5,7 @@ module OpenapiFirst
|
|
5
5
|
# Assertion methods for Minitest
|
6
6
|
module MinitestHelpers
|
7
7
|
# :nocov:
|
8
|
-
def assert_api_conform(status: nil, api:
|
8
|
+
def assert_api_conform(status: nil, api: openapi_first_default_api)
|
9
9
|
api = OpenapiFirst::Test[api]
|
10
10
|
request = respond_to?(:last_request) ? last_request : @request
|
11
11
|
response = respond_to?(:last_response) ? last_response : @response
|
@@ -5,7 +5,7 @@ module OpenapiFirst
|
|
5
5
|
# Assertion methods to use when no known test framework was found
|
6
6
|
# These methods just raise an exception if an error was found
|
7
7
|
module PlainHelpers
|
8
|
-
def assert_api_conform(status: nil, api:
|
8
|
+
def assert_api_conform(status: nil, api: openapi_first_default_api)
|
9
9
|
api = OpenapiFirst::Test[api]
|
10
10
|
# :nocov:
|
11
11
|
request = respond_to?(:last_request) ? last_request : @request
|
data/lib/openapi_first/test.rb
CHANGED
@@ -22,8 +22,8 @@ module OpenapiFirst
|
|
22
22
|
yield self
|
23
23
|
end
|
24
24
|
|
25
|
-
def register(
|
26
|
-
Test.register(
|
25
|
+
def register(oad, as: :default)
|
26
|
+
Test.register(oad, as:)
|
27
27
|
end
|
28
28
|
|
29
29
|
attr_accessor :minimum_coverage, :coverage_formatter_options, :coverage_formatter
|
@@ -47,7 +47,7 @@ module OpenapiFirst
|
|
47
47
|
end
|
48
48
|
return unless minimum_coverage > coverage
|
49
49
|
|
50
|
-
puts "API Coverage fails with exit 2, because API coverage of #{coverage}%" \
|
50
|
+
puts "API Coverage fails with exit 2, because API coverage of #{coverage}% " \
|
51
51
|
"is below minimum of #{minimum_coverage}%!"
|
52
52
|
exit 2
|
53
53
|
# :nocov:
|
@@ -81,7 +81,6 @@ module OpenapiFirst
|
|
81
81
|
def self.report_coverage(formatter: Coverage::TerminalFormatter, **)
|
82
82
|
coverage_result = Coverage.result
|
83
83
|
puts formatter.new(**).format(coverage_result)
|
84
|
-
puts "The overal API validation coverage of this run is: #{coverage_result.coverage}%"
|
85
84
|
end
|
86
85
|
|
87
86
|
# Returns the Rack app wrapped with silent request, response validation
|
@@ -104,18 +103,23 @@ module OpenapiFirst
|
|
104
103
|
class << self
|
105
104
|
attr_reader :definitions
|
106
105
|
|
107
|
-
|
108
|
-
|
106
|
+
# Register an OpenAPI definition for testing
|
107
|
+
# @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object
|
108
|
+
# @param as [Symbol] Name to register the API definition as
|
109
|
+
def register(path_or_definition, as: :default)
|
110
|
+
if definitions.key?(as) && as == :default
|
109
111
|
raise(
|
110
112
|
AlreadyRegisteredError,
|
111
113
|
"#{definitions[as].filepath.inspect} is already registered " \
|
112
|
-
"as ':default' so you cannot register #{
|
114
|
+
"as ':default' so you cannot register #{path_or_definition.inspect} without " \
|
113
115
|
'giving it a custom name. Please call register with a custom key like: ' \
|
114
|
-
"OpenapiFirst::Test.register(#{
|
116
|
+
"OpenapiFirst::Test.register(#{path_or_definition.inspect}, as: :my_other_api)"
|
115
117
|
)
|
116
118
|
end
|
117
119
|
|
118
|
-
|
120
|
+
definition = OpenapiFirst.load(path_or_definition)
|
121
|
+
definitions[as] = definition
|
122
|
+
definition
|
119
123
|
end
|
120
124
|
|
121
125
|
def [](api)
|
@@ -6,7 +6,6 @@ module OpenapiFirst
|
|
6
6
|
RequestHeaders = Data.define(:schema) do
|
7
7
|
def call(parsed_request)
|
8
8
|
validation = schema.validate(parsed_request.headers)
|
9
|
-
validation = Schema::ValidationResult.new(validation.to_a)
|
10
9
|
Failure.fail!(:invalid_header, errors: validation.errors) if validation.error?
|
11
10
|
end
|
12
11
|
end
|
@@ -14,7 +13,6 @@ module OpenapiFirst
|
|
14
13
|
Path = Data.define(:schema) do
|
15
14
|
def call(parsed_request)
|
16
15
|
validation = schema.validate(parsed_request.path)
|
17
|
-
validation = Schema::ValidationResult.new(validation.to_a)
|
18
16
|
Failure.fail!(:invalid_path, errors: validation.errors) if validation.error?
|
19
17
|
end
|
20
18
|
end
|
@@ -22,7 +20,6 @@ module OpenapiFirst
|
|
22
20
|
Query = Data.define(:schema) do
|
23
21
|
def call(parsed_request)
|
24
22
|
validation = schema.validate(parsed_request.query)
|
25
|
-
validation = Schema::ValidationResult.new(validation.to_a)
|
26
23
|
Failure.fail!(:invalid_query, errors: validation.errors) if validation.error?
|
27
24
|
end
|
28
25
|
end
|
@@ -30,7 +27,6 @@ module OpenapiFirst
|
|
30
27
|
RequestCookies = Data.define(:schema) do
|
31
28
|
def call(parsed_request)
|
32
29
|
validation = schema.validate(parsed_request.cookies)
|
33
|
-
validation = Schema::ValidationResult.new(validation.to_a)
|
34
30
|
Failure.fail!(:invalid_cookie, errors: validation.errors) if validation.error?
|
35
31
|
end
|
36
32
|
end
|
@@ -45,7 +41,7 @@ module OpenapiFirst
|
|
45
41
|
def self.for(args)
|
46
42
|
VALIDATORS.filter_map do |key, klass|
|
47
43
|
schema = args[key]
|
48
|
-
klass.new(schema) if schema
|
44
|
+
klass.new(schema) if schema
|
49
45
|
end
|
50
46
|
end
|
51
47
|
end
|
@@ -5,13 +5,6 @@ require_relative '../schema/validation_result'
|
|
5
5
|
module OpenapiFirst
|
6
6
|
module Validators
|
7
7
|
class ResponseBody
|
8
|
-
def self.for(response_definition)
|
9
|
-
schema = response_definition&.content_schema
|
10
|
-
return unless schema
|
11
|
-
|
12
|
-
new(schema)
|
13
|
-
end
|
14
|
-
|
15
8
|
def initialize(schema)
|
16
9
|
@schema = schema
|
17
10
|
end
|
@@ -3,24 +3,37 @@
|
|
3
3
|
module OpenapiFirst
|
4
4
|
module Validators
|
5
5
|
class ResponseHeaders
|
6
|
-
def
|
7
|
-
|
8
|
-
return unless schema&.value
|
9
|
-
|
10
|
-
new(schema)
|
6
|
+
def initialize(headers)
|
7
|
+
@headers = headers
|
11
8
|
end
|
12
9
|
|
13
|
-
|
14
|
-
|
10
|
+
attr_reader :headers
|
11
|
+
|
12
|
+
def call(parsed_response)
|
13
|
+
headers.each do |header|
|
14
|
+
header_value = parsed_response.headers[header.name]
|
15
|
+
next if header_value.nil? && !header.required?
|
16
|
+
|
17
|
+
validation_errors = header.schema.validate(header_value)
|
18
|
+
next unless validation_errors.any?
|
19
|
+
|
20
|
+
Failure.fail!(:invalid_response_header,
|
21
|
+
errors: [error_for(data_pointer: "/#{header.name}", value: header_value,
|
22
|
+
error: validation_errors.first)])
|
23
|
+
end
|
15
24
|
end
|
16
25
|
|
17
|
-
|
26
|
+
private
|
18
27
|
|
19
|
-
def
|
20
|
-
|
21
|
-
|
28
|
+
def error_for(data_pointer:, value:, error:)
|
29
|
+
Schema::ValidationError.new(
|
30
|
+
value: value,
|
31
|
+
data_pointer:,
|
32
|
+
schema_pointer: error['schema_pointer'],
|
33
|
+
type: error['type'],
|
34
|
+
details: error['details'],
|
35
|
+
schema: error['schema']
|
22
36
|
)
|
23
|
-
Failure.fail!(:invalid_response_header, errors: validation.errors) if validation.error?
|
24
37
|
end
|
25
38
|
end
|
26
39
|
end
|
data/lib/openapi_first.rb
CHANGED
@@ -15,6 +15,10 @@ module OpenapiFirst
|
|
15
15
|
|
16
16
|
# Key in rack to find instance of Request
|
17
17
|
REQUEST = 'openapi.request'
|
18
|
+
|
19
|
+
# Key in rack to store the alternate path used for schema matching
|
20
|
+
PATH = 'openapi.path'
|
21
|
+
|
18
22
|
FAILURE = :openapi_first_validation_failure
|
19
23
|
|
20
24
|
# @return [Configuration]
|
@@ -48,9 +52,13 @@ module OpenapiFirst
|
|
48
52
|
end
|
49
53
|
end
|
50
54
|
|
51
|
-
# Load and dereference an OpenAPI spec file
|
55
|
+
# Load and dereference an OpenAPI spec file or return the Definition if it's already loaded
|
56
|
+
# @param filepath_or_definition [String, Definition] The path to the file or a Definition object
|
52
57
|
# @return [Definition]
|
53
|
-
def self.load(
|
58
|
+
def self.load(filepath_or_definition, only: nil, &)
|
59
|
+
return filepath_or_definition if filepath_or_definition.is_a?(Definition)
|
60
|
+
|
61
|
+
filepath = filepath_or_definition
|
54
62
|
raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath)
|
55
63
|
|
56
64
|
contents = FileLoader.load(filepath)
|
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.7.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-05-04 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: hana
|
@@ -102,6 +102,7 @@ files:
|
|
102
102
|
- lib/openapi_first/errors.rb
|
103
103
|
- lib/openapi_first/failure.rb
|
104
104
|
- lib/openapi_first/file_loader.rb
|
105
|
+
- lib/openapi_first/header.rb
|
105
106
|
- lib/openapi_first/json.rb
|
106
107
|
- lib/openapi_first/middlewares/request_validation.rb
|
107
108
|
- lib/openapi_first/middlewares/response_validation.rb
|
@@ -118,6 +119,7 @@ files:
|
|
118
119
|
- lib/openapi_first/router/find_content.rb
|
119
120
|
- lib/openapi_first/router/find_response.rb
|
120
121
|
- lib/openapi_first/router/path_template.rb
|
122
|
+
- lib/openapi_first/schema/hash.rb
|
121
123
|
- lib/openapi_first/schema/validation_error.rb
|
122
124
|
- lib/openapi_first/schema/validation_result.rb
|
123
125
|
- lib/openapi_first/test.rb
|