openapi_first 2.5.1 → 2.6.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 +8 -0
- data/README.md +23 -13
- data/lib/openapi_first/builder.rb +60 -58
- data/lib/openapi_first/definition.rb +0 -4
- 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/terminal_formatter.rb +5 -3
- data/lib/openapi_first/test/methods.rb +10 -0
- data/lib/openapi_first/test.rb +0 -1
- 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
- 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: 63630ef9a08bcbb2602c05d518f0fc8e2da334ca27e8b5aa06d1165e16c5b1f8
|
4
|
+
data.tar.gz: 7a78262f72e78437b873a2044c81dac22fd76befa8671f457bf0deebe523028e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cd59bdb56d8ff90378967cf410ad0ccf0583d69b56bc153bc454773e2637023efb420658b7ba8e71ada6e763d116727b4057e73f6664b0ce2216127759e0aac7
|
7
|
+
data.tar.gz: 128c890b985671e5f882cb58bc0e5fbe49098619321d420d52d49035ad2650b18cc65b9cde246247a21176db23de527aec8b2e7921b693c638297d5057f63802
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,14 @@
|
|
2
2
|
|
3
3
|
## Unreleased
|
4
4
|
|
5
|
+
## 2.6.0
|
6
|
+
|
7
|
+
- Middlewares now accept the OAD as a first positional argument instead of `:spec` inside the options hash.
|
8
|
+
- 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). Use `OpenapiFirst::Schema::Hash` to validate multiple parameters schemas and return a single error object.
|
9
|
+
- `OpenapiFirst::Test::Methods[MyApplication]` returns a Module which adds an `app` method to be used by rack-test alonside the `assert_api_conform` method.
|
10
|
+
- Make default coverage report less verbose
|
11
|
+
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
|
12
|
+
|
5
13
|
## 2.5.1
|
6
14
|
|
7
15
|
- Fix skipping skipped responses during coverage tracking
|
data/README.md
CHANGED
@@ -136,35 +136,41 @@ If you are adopting OpenAPI you can use these options together with [hooks](#hoo
|
|
136
136
|
|
137
137
|
## Contract Testing
|
138
138
|
|
139
|
+
Here are two aspects of contract testing: Validation and Coverage
|
140
|
+
|
141
|
+
### Validation
|
142
|
+
|
143
|
+
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.
|
144
|
+
|
139
145
|
### Coverage
|
140
146
|
|
147
|
+
To make sure your _whole_ API description is implemented, openapi_first ships with a coverage feature.
|
148
|
+
|
141
149
|
> [!NOTE]
|
142
150
|
> This is a brand new feature. ✨ Your feedback is very welcome.
|
143
151
|
|
144
|
-
This feature tracks all requests/resposes that are validated via openapi_first and
|
145
|
-
|
146
|
-
|
147
|
-
Here is how to set it up for RSpec in your `spec/spec_helper.rb`:
|
152
|
+
This feature tracks all requests/resposes that are validated via openapi_first and tells you about which request/responses are missing.
|
153
|
+
Here is how to set it up with [rack-test](https://github.com/rack/rack-test):
|
148
154
|
|
149
|
-
1. Register all OpenAPI documents to track coverage for
|
155
|
+
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
156
|
```ruby
|
151
157
|
require 'openapi_first'
|
152
158
|
OpenapiFirst::Test.setup do |s|
|
153
159
|
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' }
|
160
|
+
test.minimum_coverage = 100 # (Optional) Setting this will lead to an `exit 2` if coverage is below minimum
|
161
|
+
test.skip_response_coverage { it.status == '500' } # (Optional) Skip certain responses
|
156
162
|
end
|
157
163
|
```
|
158
|
-
2.
|
164
|
+
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)
|
165
|
+
|
159
166
|
```ruby
|
160
|
-
|
161
|
-
|
162
|
-
OpenapiFirst::Test.app(App)
|
163
|
-
end
|
167
|
+
def app
|
168
|
+
OpenapiFirst::Test.app(MyApp)
|
164
169
|
end
|
165
170
|
```
|
171
|
+
3. Run your tests. The Coverage feature will tell you about missing request/responses.
|
166
172
|
|
167
|
-
(✷1): Instead of using `OpenapiFirstTest.app` to wrap your application, you
|
173
|
+
(✷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
174
|
|
169
175
|
### Test assertions
|
170
176
|
|
@@ -306,6 +312,10 @@ end
|
|
306
312
|
Using rack middlewares is supported in probably all Ruby web frameworks.
|
307
313
|
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
314
|
|
315
|
+
The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.
|
316
|
+
|
317
|
+
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).
|
318
|
+
|
309
319
|
## Alternatives
|
310
320
|
|
311
321
|
This gem was inspired by [committe](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
|
@@ -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,6 +33,18 @@ 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']
|
@@ -46,10 +63,10 @@ module OpenapiFirst
|
|
46
63
|
def router # rubocop:disable Metrics/MethodLength
|
47
64
|
router = OpenapiFirst::Router.new
|
48
65
|
@contents.fetch('paths').each do |path, path_item_object|
|
49
|
-
path_parameters =
|
66
|
+
path_parameters = path_item_object['parameters'] || []
|
50
67
|
path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
|
51
68
|
operation_object = path_item_object[request_method]
|
52
|
-
operation_parameters =
|
69
|
+
operation_parameters = operation_object['parameters'] || []
|
53
70
|
parameters = parse_parameters(operation_parameters.chain(path_parameters))
|
54
71
|
|
55
72
|
build_requests(path:, request_method:, operation_object:,
|
@@ -79,10 +96,10 @@ module OpenapiFirst
|
|
79
96
|
def parse_parameters(parameters)
|
80
97
|
grouped_parameters = group_parameters(parameters)
|
81
98
|
ParsedParameters.new(
|
82
|
-
query: grouped_parameters[:query],
|
83
|
-
path: grouped_parameters[:path],
|
84
|
-
cookie: grouped_parameters[:cookie],
|
85
|
-
header: grouped_parameters[:header],
|
99
|
+
query: resolve_parameters(grouped_parameters[:query]),
|
100
|
+
path: resolve_parameters(grouped_parameters[:path]),
|
101
|
+
cookie: resolve_parameters(grouped_parameters[:cookie]),
|
102
|
+
header: resolve_parameters(grouped_parameters[:header]),
|
86
103
|
query_schema: build_parameter_schema(grouped_parameters[:query]),
|
87
104
|
path_schema: build_parameter_schema(grouped_parameters[:path]),
|
88
105
|
cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
|
@@ -99,11 +116,18 @@ module OpenapiFirst
|
|
99
116
|
end
|
100
117
|
|
101
118
|
def build_parameter_schema(parameters)
|
102
|
-
|
119
|
+
return unless parameters
|
103
120
|
|
104
|
-
|
105
|
-
|
106
|
-
|
121
|
+
required = []
|
122
|
+
schemas = parameters.each_with_object({}) do |parameter, result|
|
123
|
+
schema = parameter['schema'].schema(configuration: schemer_configuration)
|
124
|
+
name = parameter['name']&.value
|
125
|
+
required << name if parameter['required']&.value
|
126
|
+
result[name] = schema if schema
|
127
|
+
end
|
128
|
+
|
129
|
+
Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
|
130
|
+
after_property_validation: config.hooks[:after_request_parameter_property_validation])
|
107
131
|
end
|
108
132
|
|
109
133
|
def build_requests(path:, request_method:, operation_object:, parameters:)
|
@@ -141,20 +165,15 @@ module OpenapiFirst
|
|
141
165
|
return [] unless responses
|
142
166
|
|
143
167
|
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
|
-
)
|
168
|
+
headers = build_response_headers(response_object['headers'])
|
149
169
|
response_object['content']&.map do |content_type, content_object|
|
150
170
|
content_schema = content_object['schema'].schema(configuration: schemer_configuration)
|
151
171
|
Response.new(status:,
|
152
172
|
headers:,
|
153
|
-
headers_schema:,
|
154
173
|
content_type:,
|
155
174
|
content_schema:,
|
156
175
|
key: [request.key, status, content_type].join(':'))
|
157
|
-
end || Response.new(status:, headers:,
|
176
|
+
end || Response.new(status:, headers:, content_type: nil,
|
158
177
|
content_schema: nil, key: [request.key, status, nil].join(':'))
|
159
178
|
end
|
160
179
|
end
|
@@ -162,49 +181,32 @@ module OpenapiFirst
|
|
162
181
|
IGNORED_HEADER_PARAMETERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
|
163
182
|
private_constant :IGNORED_HEADER_PARAMETERS
|
164
183
|
|
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?
|
184
|
+
def build_response_headers(headers_object)
|
185
|
+
return if headers_object.nil?
|
176
186
|
|
177
|
-
|
178
|
-
required = []
|
187
|
+
result = []
|
179
188
|
headers_object.each do |name, header|
|
180
|
-
|
181
|
-
next if
|
182
|
-
|
183
|
-
|
184
|
-
|
189
|
+
next if header['schema'].nil?
|
190
|
+
next if IGNORED_HEADER_PARAMETERS.include?(name)
|
191
|
+
|
192
|
+
header = Header.new(
|
193
|
+
name:,
|
194
|
+
schema: header['schema'].schema(configuration: schemer_configuration),
|
195
|
+
required?: header['required']&.value == true,
|
196
|
+
node: header
|
197
|
+
)
|
198
|
+
result << header
|
185
199
|
end
|
186
|
-
|
187
|
-
'properties' => properties,
|
188
|
-
'required' => required
|
189
|
-
}
|
200
|
+
result
|
190
201
|
end
|
191
202
|
|
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']
|
203
|
+
def group_parameters(parameter_definitions)
|
204
|
+
result = {}
|
205
|
+
parameter_definitions&.each do |parameter|
|
206
|
+
(result[parameter['in']&.value&.to_sym] ||= []) << parameter
|
202
207
|
end
|
203
|
-
|
204
|
-
|
205
|
-
'properties' => properties,
|
206
|
-
'required' => required
|
207
|
-
}
|
208
|
+
result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']&.value) }
|
209
|
+
result
|
208
210
|
end
|
209
211
|
|
210
212
|
ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
|
@@ -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.'
|
@@ -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
|
|
@@ -36,8 +37,9 @@ module OpenapiFirst
|
|
36
37
|
plan.routes.each do |route|
|
37
38
|
next if route.finished? && !verbose
|
38
39
|
|
40
|
+
next if route.requests.none?(&:requested?) && focused
|
41
|
+
|
39
42
|
format_requests(route.requests)
|
40
|
-
next if route.requests.none?(&:requested?)
|
41
43
|
|
42
44
|
format_responses(route.responses)
|
43
45
|
end
|
@@ -7,6 +7,16 @@ module OpenapiFirst
|
|
7
7
|
module Test
|
8
8
|
# Methods to use in integration tests
|
9
9
|
module Methods
|
10
|
+
def self.[](*)
|
11
|
+
mod = Module.new do
|
12
|
+
def self.included(base)
|
13
|
+
OpenapiFirst::Test::Methods.included(base)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
mod.define_method(:app) { OpenapiFirst::Test.app(*) }
|
17
|
+
mod
|
18
|
+
end
|
19
|
+
|
10
20
|
def self.included(base)
|
11
21
|
if Test.minitest?(base)
|
12
22
|
base.include(OpenapiFirst::Test::MinitestHelpers)
|
data/lib/openapi_first/test.rb
CHANGED
@@ -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
|
@@ -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
|
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.6.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-04-06 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
|