rspec-openapi 0.28.0 → 0.30.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/README.md +122 -1
- data/lib/rspec/openapi/default_schema.rb +3 -1
- data/lib/rspec/openapi/exchange_recorder.rb +94 -0
- data/lib/rspec/openapi/extractors/hanami.rb +1 -5
- data/lib/rspec/openapi/extractors/rack.rb +1 -5
- data/lib/rspec/openapi/extractors/shared_extractor.rb +11 -7
- data/lib/rspec/openapi/nullable_converter.rb +64 -0
- data/lib/rspec/openapi/operation_converter.rb +46 -0
- data/lib/rspec/openapi/record_builder.rb +11 -1
- data/lib/rspec/openapi/result_recorder.rb +42 -24
- data/lib/rspec/openapi/rspec_hooks.rb +4 -0
- data/lib/rspec/openapi/schema_builder.rb +14 -0
- data/lib/rspec/openapi/schema_file.rb +20 -13
- data/lib/rspec/openapi/schema_sorter.rb +14 -5
- data/lib/rspec/openapi/stream_parser.rb +46 -0
- data/lib/rspec/openapi/version.rb +1 -1
- data/lib/rspec/openapi.rb +67 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ce922f425644ad7ab8645bf2dcd7df5f93085e5e15cae1f826feff526fc96f6
|
|
4
|
+
data.tar.gz: 19b7d372d3a1324903ec229f96292afdf738f99e492d76c16d281b9323e369f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe915320f2594675a5930eb28dd580f99fe4377e3c8dea443656d0de171187dd8cd30f2bbac720ea5c2a19271001e734740d09e472ee0cf7987caf89e11ebad6
|
|
7
|
+
data.tar.gz: 2f41de3cafeeeb044748cf71bf6cd8163f4d083d145ea8c9adbf6dfeb57336d2582c1280086c5e008a8f679e7a03f780eca4e4a76b9ef741c3d2e40633d21d48
|
data/README.md
CHANGED
|
@@ -116,6 +116,10 @@ RSpec::OpenAPI.path = 'doc/schema.yaml'
|
|
|
116
116
|
# Change the output type to JSON
|
|
117
117
|
RSpec::OpenAPI.path = 'doc/schema.json'
|
|
118
118
|
|
|
119
|
+
# Or emit the same schema in multiple formats from a single run by passing an array.
|
|
120
|
+
# The output format of each file is still chosen by its extension (`.json` -> JSON, otherwise YAML).
|
|
121
|
+
RSpec::OpenAPI.path = ['doc/openapi.yaml', 'doc/openapi.json']
|
|
122
|
+
|
|
119
123
|
# Or generate multiple partial schema files, given an RSpec example
|
|
120
124
|
RSpec::OpenAPI.path = -> (example) {
|
|
121
125
|
case example.file_path
|
|
@@ -125,6 +129,12 @@ RSpec::OpenAPI.path = -> (example) {
|
|
|
125
129
|
end
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
# The two can be combined: a proc may also return an array to fan a partial schema out to multiple formats.
|
|
133
|
+
RSpec::OpenAPI.path = -> (example) {
|
|
134
|
+
base = example.file_path.match?(%r[spec/requests/api/v2/]) ? 'doc/openapi/v2' : 'doc/openapi'
|
|
135
|
+
["#{base}.yaml", "#{base}.json"]
|
|
136
|
+
}
|
|
137
|
+
|
|
128
138
|
# Change the default title of the generated schema
|
|
129
139
|
RSpec::OpenAPI.title = 'OpenAPI Documentation'
|
|
130
140
|
|
|
@@ -149,6 +159,10 @@ RSpec::OpenAPI.enable_example_summary = false
|
|
|
149
159
|
# Change `info.version`
|
|
150
160
|
RSpec::OpenAPI.application_version = '1.0.0'
|
|
151
161
|
|
|
162
|
+
# Change the target OpenAPI version (defaults to '3.2.0').
|
|
163
|
+
# Use a 3.0.x string to keep the previous output, or a 3.1.x string.
|
|
164
|
+
RSpec::OpenAPI.openapi_version = '3.0.3'
|
|
165
|
+
|
|
152
166
|
# Set the info header details
|
|
153
167
|
RSpec::OpenAPI.info = {
|
|
154
168
|
description: 'My beautiful API',
|
|
@@ -177,6 +191,14 @@ RSpec::OpenAPI.security_schemes = {
|
|
|
177
191
|
},
|
|
178
192
|
}
|
|
179
193
|
|
|
194
|
+
# Set root-level `tags` definitions, emitted verbatim.
|
|
195
|
+
#
|
|
196
|
+
# OpenAPI 3.2+: Structured fields such as `summary`, `parent` and `kind` are allowed.
|
|
197
|
+
RSpec::OpenAPI.root_tags = [
|
|
198
|
+
{ name: 'Users', summary: 'User management', kind: 'nav' },
|
|
199
|
+
{ name: 'Admin', parent: 'Users' },
|
|
200
|
+
]
|
|
201
|
+
|
|
180
202
|
# Generate a comment on top of a schema file
|
|
181
203
|
RSpec::OpenAPI.comment = <<~EOS
|
|
182
204
|
This file is auto-generated by rspec-openapi https://github.com/k0kubun/rspec-openapi
|
|
@@ -190,10 +212,16 @@ RSpec::OpenAPI.description_builder = -> (example) { example.description }
|
|
|
190
212
|
|
|
191
213
|
# Generate a custom summary, given an RSpec example
|
|
192
214
|
# This example uses the summary from the example_group.
|
|
215
|
+
# NOTE: a fixed `dig` only reaches one specific depth. `summary` declared on an
|
|
216
|
+
# ancestor group is already inherited automatically (see "Inheritance of
|
|
217
|
+
# `openapi:` metadata"), so a builder is only needed for fully custom logic.
|
|
193
218
|
RSpec::OpenAPI.summary_builder = ->(example) { example.metadata.dig(:example_group, :openapi, :summary) }
|
|
194
219
|
|
|
195
220
|
# Generate a custom tags, given an RSpec example
|
|
196
|
-
# This example uses the tags from the parent_example_group
|
|
221
|
+
# This example uses the tags from the parent_example_group.
|
|
222
|
+
# NOTE: `dig(:example_group, :parent_example_group, ...)` only matches tags
|
|
223
|
+
# declared exactly two levels up; it won't find tags on deeper ancestors. Rely
|
|
224
|
+
# on the built-in inheritance unless you need custom resolution.
|
|
197
225
|
RSpec::OpenAPI.tags_builder = -> (example) { example.metadata.dig(:example_group, :parent_example_group, :openapi, :tags) }
|
|
198
226
|
|
|
199
227
|
# Configure custom format for specific properties
|
|
@@ -222,6 +250,46 @@ RSpec::OpenAPI.post_process_hook = -> (path, records, spec) do
|
|
|
222
250
|
end
|
|
223
251
|
```
|
|
224
252
|
|
|
253
|
+
### OpenAPI version
|
|
254
|
+
|
|
255
|
+
The generated `openapi` version defaults to `3.2.0` (the latest). OpenAPI 3.1+ is aligned with
|
|
256
|
+
JSON Schema 2020-12, so nullable values are expressed via the type array instead of the `nullable` keyword.
|
|
257
|
+
|
|
258
|
+
```yaml
|
|
259
|
+
# 3.0 (set RSpec::OpenAPI.openapi_version = '3.0.x')
|
|
260
|
+
name:
|
|
261
|
+
type: string
|
|
262
|
+
nullable: true
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```yaml
|
|
266
|
+
# 3.1 (set RSpec::OpenAPI.openapi_version = '3.1.x')
|
|
267
|
+
# 3.2 (set RSpec::OpenAPI.openapi_version = '3.2.x')
|
|
268
|
+
name:
|
|
269
|
+
type:
|
|
270
|
+
- string
|
|
271
|
+
- null
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- Set `RSpec::OpenAPI.openapi_version` to a `3.0.x` string to keep the previous `nullable: true` output.
|
|
275
|
+
- A `3.1.x` string produces the same schema shapes as the default `3.2.0`; they differ only in the reported
|
|
276
|
+
version and the 3.2-only constructs described below (`query` / `additionalOperations` / `itemSchema`).
|
|
277
|
+
|
|
278
|
+
Upgrade note: an existing file is read regardless of its version, so the first `OPENAPI=1` run after upgrading
|
|
279
|
+
rewrites it to the new default (e.g. `3.0.3` -> `3.2.0`, `nullable: true` -> a `null` type). Pin
|
|
280
|
+
`RSpec::OpenAPI.openapi_version = '3.0.3'` to opt out.
|
|
281
|
+
|
|
282
|
+
### Supported OpenAPI features
|
|
283
|
+
|
|
284
|
+
| Feature | OpenAPI 3.0.x | 3.1.x | 3.2.x (default) |
|
|
285
|
+
|---|:----------------:|:---:|:---:|
|
|
286
|
+
| Core generation (paths / schemas / examples) | ✓ | ✓ | ✓ |
|
|
287
|
+
| Null representation | `nullable: true` | type array `[..., 'null']` | type array `[..., 'null']` |
|
|
288
|
+
| Structured root tags (`summary` / `parent` / `kind`) | name only | name only | ✓ |
|
|
289
|
+
| `query` field (QUERY method) | — | — | ✓ |
|
|
290
|
+
| `additionalOperations` (non-standard verbs: COPY, MOVE, …) | — | — | ✓ |
|
|
291
|
+
| Streaming `itemSchema` (jsonl / ndjson / json-seq / SSE) | — | — | ✓ |
|
|
292
|
+
|
|
225
293
|
### Can I use rspec-openapi with `$ref` to minimize duplication of schema?
|
|
226
294
|
|
|
227
295
|
Yes, rspec-openapi v0.7.0+ supports [`$ref` mechanism](https://swagger.io/docs/specification/using-ref/) and generates
|
|
@@ -369,6 +437,59 @@ end
|
|
|
369
437
|
|
|
370
438
|
**NOTE**: `description` key will override also the one provided by `RSpec::OpenAPI.description_builder` method.
|
|
371
439
|
|
|
440
|
+
### Inheritance of `openapi:` metadata
|
|
441
|
+
|
|
442
|
+
`openapi:` metadata is inherited from the surrounding `describe`/`context` groups down to each
|
|
443
|
+
example, at any nesting depth. Inner levels override outer ones key by key, so a nested group only
|
|
444
|
+
needs to declare the keys it wants to change:
|
|
445
|
+
|
|
446
|
+
```rb
|
|
447
|
+
describe 'GET /tables', openapi: { summary: 'Get a list of tables', tags: %w[Table] } do
|
|
448
|
+
context 'with pagination', openapi: { example_mode: :multiple } do
|
|
449
|
+
# This example inherits summary: 'Get a list of tables' and tags: ['Table']
|
|
450
|
+
# from the outer describe, and adds example_mode: :multiple.
|
|
451
|
+
it { get '/tables', params: { page: 1 } }
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
The merge happens at the top level of the `openapi:` hash. Scalar keys (`summary`, `operation_id`,
|
|
457
|
+
…) follow last-wins, and a nested level that re-declares a structured key (`tags`, `security`,
|
|
458
|
+
`enum`, …) replaces the inherited value for that key rather than deep-merging it.
|
|
459
|
+
|
|
460
|
+
### Selecting which request to document (`request_pattern`)
|
|
461
|
+
|
|
462
|
+
By default rspec-openapi documents the *last* request/response exchange issued in an example.
|
|
463
|
+
When a single example performs several requests on the same path (for example a `DELETE`
|
|
464
|
+
followed by a `GET` that asserts the resource is gone), that trailing request would otherwise
|
|
465
|
+
overwrite the operation you actually want to document.
|
|
466
|
+
|
|
467
|
+
Use the `request_pattern` metadata to pick the exchange explicitly. Its value is a
|
|
468
|
+
`"<HTTP method> <path>"` string, where the path may use `{param}` placeholders:
|
|
469
|
+
|
|
470
|
+
```rb
|
|
471
|
+
describe 'Deleting a widget',
|
|
472
|
+
openapi: { summary: 'Delete a widget', request_pattern: 'DELETE /widgets/{id}' } do
|
|
473
|
+
it 'deletes the widget and verifies it is gone' do
|
|
474
|
+
delete '/widgets/1'
|
|
475
|
+
expect(response.status).to eq(200)
|
|
476
|
+
|
|
477
|
+
# Without request_pattern this trailing GET would become the documented
|
|
478
|
+
# exchange; the selector keeps the DELETE above as the operation instead.
|
|
479
|
+
get '/widgets/1?missing=1'
|
|
480
|
+
expect(response.status).to eq(404)
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
- Each `{param}` placeholder matches a single path segment independently, so multi-segment
|
|
486
|
+
templates such as `'DELETE /orgs/{org_id}/members/{user_id}'` work as expected.
|
|
487
|
+
- When several requests match the pattern, the last one wins.
|
|
488
|
+
- It works for both Rails (`ActionDispatch::IntegrationTest`) and `Rack::Test`-based specs
|
|
489
|
+
(e.g. Roda, Hanami).
|
|
490
|
+
- If the pattern can't be parsed, or no request issued in the example matches it, generation
|
|
491
|
+
fails fast with a message listing the requests that were actually recorded.
|
|
492
|
+
|
|
372
493
|
### Enum Support
|
|
373
494
|
|
|
374
495
|
You can specify enum values for string properties that should have a fixed set of allowed values. Since enums cannot be
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
class << RSpec::OpenAPI::DefaultSchema = Object.new
|
|
4
4
|
def build(title)
|
|
5
5
|
spec = {
|
|
6
|
-
openapi:
|
|
6
|
+
openapi: RSpec::OpenAPI.openapi_version,
|
|
7
7
|
info: {
|
|
8
8
|
title: title,
|
|
9
9
|
version: RSpec::OpenAPI.application_version,
|
|
@@ -18,6 +18,8 @@ class << RSpec::OpenAPI::DefaultSchema = Object.new
|
|
|
18
18
|
}
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
spec[:tags] = RSpec::OpenAPI.root_tags if RSpec::OpenAPI.root_tags.present?
|
|
22
|
+
|
|
21
23
|
spec.freeze
|
|
22
24
|
end
|
|
23
25
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec::OpenAPI::ExchangeRecorder
|
|
4
|
+
THREAD_KEY = :rspec_openapi_exchanges
|
|
5
|
+
VERBS = [:get, :post, :put, :patch, :delete, :head, :options].freeze
|
|
6
|
+
|
|
7
|
+
module VerbTracking
|
|
8
|
+
VERBS.each do |verb|
|
|
9
|
+
define_method(verb) do |*args, &block|
|
|
10
|
+
result = super(*args, &block)
|
|
11
|
+
RSpec::OpenAPI::ExchangeRecorder.capture_from_context(self)
|
|
12
|
+
result
|
|
13
|
+
end
|
|
14
|
+
ruby2_keywords(verb)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def reset!(example)
|
|
20
|
+
if pattern_for(example)
|
|
21
|
+
example.example_group.prepend(VerbTracking)
|
|
22
|
+
Thread.current[THREAD_KEY] = []
|
|
23
|
+
else
|
|
24
|
+
Thread.current[THREAD_KEY] = nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def pattern_for(example)
|
|
29
|
+
SharedExtractor.merge_openapi_metadata(example.metadata)[:request_pattern]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def capture_from_context(context)
|
|
33
|
+
exchanges = Thread.current[THREAD_KEY]
|
|
34
|
+
return unless exchanges
|
|
35
|
+
|
|
36
|
+
exchanges << build_exchange(context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns the exchange matching a `"METHOD /path/template"` pattern.
|
|
40
|
+
#
|
|
41
|
+
# @param [String] pattern e.g. "DELETE /resources/{id}"
|
|
42
|
+
# @return [Array(ActionDispatch::Request, ActionDispatch::TestResponse)]
|
|
43
|
+
def fetch(pattern)
|
|
44
|
+
method, path_template = parse_pattern(pattern)
|
|
45
|
+
path_matcher = path_template_to_regexp(path_template)
|
|
46
|
+
|
|
47
|
+
found = recorded.reverse_each.find do |request, _response|
|
|
48
|
+
request.request_method.to_s.upcase == method && request.path.match?(path_matcher)
|
|
49
|
+
end
|
|
50
|
+
return found if found
|
|
51
|
+
|
|
52
|
+
raise ArgumentError, no_match_message(pattern)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def recorded
|
|
58
|
+
Thread.current[THREAD_KEY]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse_pattern(pattern)
|
|
62
|
+
match = %r{\A(\S+)\s+(/\S*)\z}.match(pattern.to_s.strip)
|
|
63
|
+
unless match
|
|
64
|
+
raise ArgumentError,
|
|
65
|
+
"[rspec-openapi] Invalid request_pattern #{pattern.inspect}. " \
|
|
66
|
+
'Expected "<HTTP method> <path>", e.g. "DELETE /widgets/{id}".'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
[match[1].upcase, match[2]]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def no_match_message(pattern)
|
|
73
|
+
issued = recorded.map { |request, _response| "#{request.request_method} #{request.path}" }
|
|
74
|
+
listing = issued.empty? ? '(no requests were recorded)' : issued.join(', ')
|
|
75
|
+
"[rspec-openapi] request_pattern #{pattern.inspect} did not match any request " \
|
|
76
|
+
"issued in this example. Recorded requests: #{listing}."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def path_template_to_regexp(template)
|
|
80
|
+
segments = template.split(/\{[^}]+\}/, -1)
|
|
81
|
+
pattern = segments.map { |segment| Regexp.escape(segment) }.join('[^/]+')
|
|
82
|
+
/\A#{pattern}\z/
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_exchange(context)
|
|
86
|
+
if context.respond_to?(:last_request)
|
|
87
|
+
SharedExtractor.build_request_response(context.last_request.env, context.last_response.to_a)
|
|
88
|
+
else
|
|
89
|
+
session = context.instance_variable_get(:@integration_session)
|
|
90
|
+
SharedExtractor.build_request_response(session.request.env, session.response.to_a)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -71,11 +71,7 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
71
71
|
|
|
72
72
|
# @param [RSpec::ExampleGroups::*] context
|
|
73
73
|
def request_response(context)
|
|
74
|
-
|
|
75
|
-
request.body.rewind if request.body.respond_to?(:rewind)
|
|
76
|
-
response = ActionDispatch::TestResponse.new(*context.last_response.to_a)
|
|
77
|
-
|
|
78
|
-
[request, response]
|
|
74
|
+
SharedExtractor.build_request_response(context.last_request.env, context.last_response.to_a)
|
|
79
75
|
end
|
|
80
76
|
|
|
81
77
|
private
|
|
@@ -17,10 +17,6 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
|
|
|
17
17
|
|
|
18
18
|
# @param [RSpec::ExampleGroups::*] context
|
|
19
19
|
def request_response(context)
|
|
20
|
-
|
|
21
|
-
request.body.rewind if request.body.respond_to?(:rewind)
|
|
22
|
-
response = ActionDispatch::TestResponse.new(*context.last_response.to_a)
|
|
23
|
-
|
|
24
|
-
[request, response]
|
|
20
|
+
SharedExtractor.build_request_response(context.last_request.env, context.last_response.to_a)
|
|
25
21
|
end
|
|
26
22
|
end
|
|
@@ -12,6 +12,12 @@ class SharedExtractor
|
|
|
12
12
|
opt in early.
|
|
13
13
|
MSG
|
|
14
14
|
|
|
15
|
+
def self.build_request_response(env, response_array)
|
|
16
|
+
request = ActionDispatch::Request.new(env)
|
|
17
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
18
|
+
[request, ActionDispatch::TestResponse.new(*response_array)]
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
def self.attributes(example)
|
|
16
22
|
metadata = merge_openapi_metadata(example.metadata)
|
|
17
23
|
request_example_mode, response_example_mode = normalize_example_mode(metadata[:example_mode], example)
|
|
@@ -86,14 +92,12 @@ class SharedExtractor
|
|
|
86
92
|
|
|
87
93
|
def self.collect_openapi_metadata(metadata)
|
|
88
94
|
[].tap do |result|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
while current
|
|
92
|
-
[current[:example_group], current].each do |meta|
|
|
93
|
-
result.unshift(meta[:openapi]) if meta&.dig(:openapi)
|
|
94
|
-
end
|
|
95
|
+
result.unshift(metadata[:openapi]) if metadata[:openapi]
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
group = metadata.fetch(:example_group) { metadata[:parent_example_group] }
|
|
98
|
+
while group
|
|
99
|
+
result.unshift(group[:openapi]) if group[:openapi]
|
|
100
|
+
group = group[:parent_example_group]
|
|
97
101
|
end
|
|
98
102
|
end
|
|
99
103
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Converts between the internal `nullable: true` form and the 3.1+ JSON Schema
|
|
4
|
+
# null form (`type: [..., 'null']`). The builder and merger only use `nullable`;
|
|
5
|
+
# normalize! runs on read, to_json_schema! on write.
|
|
6
|
+
class << RSpec::OpenAPI::NullableConverter = Object.new
|
|
7
|
+
# Keys whose values hold user data, not schemas. Their contents must never be
|
|
8
|
+
# rewritten, or a recorded example/default that happens to contain a field
|
|
9
|
+
# named `type` or `nullable` would be silently mangled.
|
|
10
|
+
DATA_KEYS = [:example, :examples, :default, :enum].freeze
|
|
11
|
+
|
|
12
|
+
def to_json_schema!(node)
|
|
13
|
+
each_schema(node) { |schema| nullable_to_type_null!(schema) }
|
|
14
|
+
node
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def normalize!(node)
|
|
18
|
+
each_schema(node) { |schema| type_null_to_nullable!(schema) }
|
|
19
|
+
node
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def nullable_to_type_null!(schema)
|
|
25
|
+
return unless schema.delete(:nullable)
|
|
26
|
+
|
|
27
|
+
schema[:type] =
|
|
28
|
+
case (type = schema[:type])
|
|
29
|
+
when nil then 'null'
|
|
30
|
+
else [type, 'null']
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def type_null_to_nullable!(schema)
|
|
35
|
+
case (type = schema[:type])
|
|
36
|
+
when 'null'
|
|
37
|
+
schema.delete(:type)
|
|
38
|
+
schema[:nullable] = true
|
|
39
|
+
when Array
|
|
40
|
+
return unless type.include?('null')
|
|
41
|
+
|
|
42
|
+
rest = type - ['null']
|
|
43
|
+
if rest.empty?
|
|
44
|
+
schema.delete(:type)
|
|
45
|
+
else
|
|
46
|
+
schema[:type] = rest.one? ? rest.first : rest
|
|
47
|
+
end
|
|
48
|
+
schema[:nullable] = true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Yield every schema Hash in the tree, skipping the data subtrees in DATA_KEYS. Each
|
|
53
|
+
# transform mutates only its own node's keys, so the following each still
|
|
54
|
+
# iterates a stable Hash.
|
|
55
|
+
def each_schema(node, &block)
|
|
56
|
+
case node
|
|
57
|
+
when Hash
|
|
58
|
+
yield node
|
|
59
|
+
node.each { |key, value| each_schema(value, &block) unless DATA_KEYS.include?(key) }
|
|
60
|
+
when Array
|
|
61
|
+
node.each { |value| each_schema(value, &block) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Moves non-standard HTTP methods between the internal flat form
|
|
4
|
+
# (`paths.<path>.<method>`) and the 3.2 `additionalOperations` map. 3.2 keeps the
|
|
5
|
+
# 8 standard methods plus `query` as fixed fields; other verbs (COPY, MOVE, ...)
|
|
6
|
+
# go under `additionalOperations`. normalize! on read, to_additional_operations! on write.
|
|
7
|
+
class << RSpec::OpenAPI::OperationConverter = Object.new
|
|
8
|
+
FIXED_METHODS = [:get, :put, :post, :delete, :options, :head, :patch, :trace, :query].freeze
|
|
9
|
+
# Path Item fields that are not operations.
|
|
10
|
+
PATH_ITEM_METADATA = [:summary, :description, :servers, :parameters, :additionalOperations].push(:$ref).freeze
|
|
11
|
+
|
|
12
|
+
def to_additional_operations!(spec)
|
|
13
|
+
each_path_item(spec) { |item| relocate_non_standard!(item) }
|
|
14
|
+
spec
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def normalize!(spec)
|
|
18
|
+
each_path_item(spec) { |item| inline_additional_operations!(item) }
|
|
19
|
+
spec
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def relocate_non_standard!(item)
|
|
25
|
+
non_standard = item.keys - FIXED_METHODS - PATH_ITEM_METADATA
|
|
26
|
+
non_standard.each do |method|
|
|
27
|
+
additional = (item[:additionalOperations] ||= {})
|
|
28
|
+
additional[method.to_s.upcase.to_sym] = item.delete(method)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inline_additional_operations!(item)
|
|
33
|
+
additional = item.delete(:additionalOperations)
|
|
34
|
+
return unless additional.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
additional.each { |verb, operation| item[verb.to_s.downcase.to_sym] = operation }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def each_path_item(spec)
|
|
40
|
+
paths = spec[:paths]
|
|
41
|
+
return spec unless paths.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
paths.each_value { |item| yield item if item.is_a?(Hash) }
|
|
44
|
+
spec
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'logger'
|
|
3
4
|
require 'action_dispatch'
|
|
4
5
|
require 'rspec/openapi/record'
|
|
5
6
|
|
|
@@ -8,7 +9,7 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
8
9
|
# @param [RSpec::Core::Example] example
|
|
9
10
|
# @return [RSpec::OpenAPI::Record,nil]
|
|
10
11
|
def build(context, example:, extractor:)
|
|
11
|
-
request, response =
|
|
12
|
+
request, response = select_request_response(context, example, extractor)
|
|
12
13
|
return if request.nil?
|
|
13
14
|
|
|
14
15
|
attributes = extractor.request_attributes(request, example)
|
|
@@ -20,6 +21,13 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
20
21
|
|
|
21
22
|
private
|
|
22
23
|
|
|
24
|
+
def select_request_response(context, example, extractor)
|
|
25
|
+
pattern = RSpec::OpenAPI::ExchangeRecorder.pattern_for(example)
|
|
26
|
+
return RSpec::OpenAPI::ExchangeRecorder.fetch(pattern) if pattern
|
|
27
|
+
|
|
28
|
+
extractor.request_response(context)
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
def build_record(title, request, response, attributes)
|
|
24
32
|
request_headers, response_headers = extract_headers(request, response)
|
|
25
33
|
RSpec::OpenAPI::Record.new(
|
|
@@ -42,6 +50,8 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
42
50
|
def safe_parse_body(response, media_type)
|
|
43
51
|
# Use raw body, because Nokogiri-parsed HTML are modified (new lines injection, meta injection, and so on) :(
|
|
44
52
|
return response.body if media_type == 'text/html'
|
|
53
|
+
# Keep streaming bodies raw so they can be split per item (see StreamParser).
|
|
54
|
+
return response.body if RSpec::OpenAPI.sequential_media_type?(media_type)
|
|
45
55
|
|
|
46
56
|
response.parsed_body
|
|
47
57
|
rescue JSON::ParserError
|
|
@@ -7,31 +7,16 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def record_results!
|
|
10
|
-
@path_records.each do |
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
puts "WARNING: Unable to load #{config_file}: #{e}"
|
|
17
|
-
end
|
|
10
|
+
@path_records.each do |paths, records|
|
|
11
|
+
# A single record set may target multiple output files (e.g. both YAML and
|
|
12
|
+
# JSON). The first path is the canonical source we read/merge into; the rest
|
|
13
|
+
# mirror the same built spec, each formatted by its own extension.
|
|
14
|
+
primary, *mirrors = Array(paths)
|
|
15
|
+
next if primary.nil?
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
schema[:info].merge!(RSpec::OpenAPI.info)
|
|
23
|
-
RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
|
|
24
|
-
new_from_zero = {}
|
|
25
|
-
records.each do |record|
|
|
26
|
-
record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
|
|
27
|
-
RSpec::OpenAPI::SchemaMerger.merge!(spec, record_schema)
|
|
28
|
-
RSpec::OpenAPI::SchemaMerger.merge!(new_from_zero, record_schema)
|
|
29
|
-
rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
|
|
30
|
-
@error_records[e] = record # Avoid failing the build
|
|
31
|
-
end
|
|
32
|
-
cleanup_schema!(new_from_zero, spec)
|
|
33
|
-
execute_post_process_hook(path, records, spec)
|
|
34
|
-
end
|
|
17
|
+
load_path_config(primary)
|
|
18
|
+
built_spec = build_spec(primary, records)
|
|
19
|
+
mirrors.each { |mirror_path| RSpec::OpenAPI::SchemaFile.new(mirror_path).write(built_spec) }
|
|
35
20
|
end
|
|
36
21
|
end
|
|
37
22
|
|
|
@@ -49,6 +34,37 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
49
34
|
|
|
50
35
|
private
|
|
51
36
|
|
|
37
|
+
# Look for a path-specific config file and run it.
|
|
38
|
+
def load_path_config(path)
|
|
39
|
+
config_file = File.join(File.dirname(path), RSpec::OpenAPI.config_filename)
|
|
40
|
+
require config_file if File.exist?(config_file)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
puts "WARNING: Unable to load #{config_file}: #{e}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_spec(primary, records)
|
|
46
|
+
title = records.first.title
|
|
47
|
+
RSpec::OpenAPI::SchemaFile.new(primary).edit do |spec|
|
|
48
|
+
# Normalize an existing file back to the internal form so merge/cleanup is
|
|
49
|
+
# version-agnostic; cleanup_schema! re-applies the target form.
|
|
50
|
+
RSpec::OpenAPI::NullableConverter.normalize!(spec)
|
|
51
|
+
RSpec::OpenAPI::OperationConverter.normalize!(spec)
|
|
52
|
+
schema = RSpec::OpenAPI::DefaultSchema.build(title)
|
|
53
|
+
schema[:info].merge!(RSpec::OpenAPI.info)
|
|
54
|
+
RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
|
|
55
|
+
new_from_zero = {}
|
|
56
|
+
records.each do |record|
|
|
57
|
+
record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
|
|
58
|
+
RSpec::OpenAPI::SchemaMerger.merge!(spec, record_schema)
|
|
59
|
+
RSpec::OpenAPI::SchemaMerger.merge!(new_from_zero, record_schema)
|
|
60
|
+
rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
|
|
61
|
+
@error_records[e] = record # Avoid failing the build
|
|
62
|
+
end
|
|
63
|
+
cleanup_schema!(new_from_zero, spec)
|
|
64
|
+
execute_post_process_hook(primary, records, spec)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
52
68
|
def execute_post_process_hook(path, records, spec)
|
|
53
69
|
RSpec::OpenAPI.post_process_hook.call(path, records, spec) if RSpec::OpenAPI.post_process_hook.is_a?(Proc)
|
|
54
70
|
end
|
|
@@ -58,6 +74,8 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
58
74
|
RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
|
|
59
75
|
RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
|
|
60
76
|
RSpec::OpenAPI::SchemaCleaner.cleanup_empty_required_array!(spec)
|
|
77
|
+
RSpec::OpenAPI::OperationConverter.to_additional_operations!(spec) if RSpec::OpenAPI.supports_additional_operations?
|
|
61
78
|
RSpec::OpenAPI::SchemaSorter.deep_sort!(spec)
|
|
79
|
+
RSpec::OpenAPI::NullableConverter.to_json_schema!(spec) if RSpec::OpenAPI.json_schema_based?
|
|
62
80
|
end
|
|
63
81
|
end
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require 'rspec/core'
|
|
4
4
|
|
|
5
|
+
RSpec.configuration.before(:each) do |example|
|
|
6
|
+
RSpec::OpenAPI::ExchangeRecorder.reset!(example)
|
|
7
|
+
end
|
|
8
|
+
|
|
5
9
|
RSpec.configuration.after(:each) do |example|
|
|
6
10
|
if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false
|
|
7
11
|
path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
|
|
@@ -56,6 +56,12 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
56
56
|
disposition = normalize_content_disposition(record.response_content_disposition)
|
|
57
57
|
content_type = normalize_content_type(record.response_content_type)
|
|
58
58
|
ctx = BuildContext.new(record: record, context: :response)
|
|
59
|
+
|
|
60
|
+
if RSpec::OpenAPI.supports_item_schema? && RSpec::OpenAPI.sequential_media_type?(content_type)
|
|
61
|
+
item_schema = build_stream_item_schema(record.response_body, content_type, ctx)
|
|
62
|
+
return { content_type => { itemSchema: item_schema } } if item_schema
|
|
63
|
+
end
|
|
64
|
+
|
|
59
65
|
schema = build_property(record.response_body, ctx, disposition: disposition)
|
|
60
66
|
example = response_example(record, disposition: disposition)
|
|
61
67
|
|
|
@@ -63,6 +69,14 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
63
69
|
{ content_type => body }
|
|
64
70
|
end
|
|
65
71
|
|
|
72
|
+
def build_stream_item_schema(raw, content_type, ctx)
|
|
73
|
+
items = RSpec::OpenAPI::StreamParser.items(raw, content_type)
|
|
74
|
+
return nil if items.empty?
|
|
75
|
+
|
|
76
|
+
variations = items.map { |item| build_property(item, ctx) }
|
|
77
|
+
build_merged_schema_from_variations(variations)
|
|
78
|
+
end
|
|
79
|
+
|
|
66
80
|
# Returns the per-content-type body (schema + optional example/examples).
|
|
67
81
|
# Shared by response content and request body to keep example_mode handling in one place.
|
|
68
82
|
def build_example_body(schema, record, mode:, example:)
|
|
@@ -14,11 +14,30 @@ class RSpec::OpenAPI::SchemaFile
|
|
|
14
14
|
@path = path
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Reads the existing spec, lets the block mutate it, writes the result back
|
|
18
|
+
# and returns the (symbolized) spec so it can be mirrored to other files.
|
|
19
|
+
# @return [Hash]
|
|
17
20
|
def edit(&block)
|
|
18
21
|
spec = read
|
|
19
22
|
block.call(spec)
|
|
23
|
+
spec
|
|
20
24
|
ensure
|
|
21
|
-
write(
|
|
25
|
+
write(spec)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Writes an already-built spec to this file, choosing the format from the
|
|
29
|
+
# file extension.
|
|
30
|
+
# @param [Hash] spec
|
|
31
|
+
def write(spec)
|
|
32
|
+
stringified = RSpec::OpenAPI::KeyTransformer.stringify(spec)
|
|
33
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
34
|
+
output =
|
|
35
|
+
if json?
|
|
36
|
+
JSON.pretty_generate(stringified)
|
|
37
|
+
else
|
|
38
|
+
prepend_comment(YAML.dump(stringified))
|
|
39
|
+
end
|
|
40
|
+
File.write(@path, output)
|
|
22
41
|
end
|
|
23
42
|
|
|
24
43
|
private
|
|
@@ -33,18 +52,6 @@ class RSpec::OpenAPI::SchemaFile
|
|
|
33
52
|
RSpec::OpenAPI::KeyTransformer.symbolize(content)
|
|
34
53
|
end
|
|
35
54
|
|
|
36
|
-
# @param [Hash] spec
|
|
37
|
-
def write(spec)
|
|
38
|
-
FileUtils.mkdir_p(File.dirname(@path))
|
|
39
|
-
output =
|
|
40
|
-
if json?
|
|
41
|
-
JSON.pretty_generate(spec)
|
|
42
|
-
else
|
|
43
|
-
prepend_comment(YAML.dump(spec))
|
|
44
|
-
end
|
|
45
|
-
File.write(@path, output)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
55
|
def prepend_comment(content)
|
|
49
56
|
return content if RSpec::OpenAPI.comment.nil?
|
|
50
57
|
|
|
@@ -4,18 +4,27 @@ class << RSpec::OpenAPI::SchemaSorter = Object.new
|
|
|
4
4
|
# Sort some unpredictably ordered properties in a lexicographical manner to make the order more predictable.
|
|
5
5
|
#
|
|
6
6
|
# @param [Hash|Array]
|
|
7
|
+
# Operation containers: fixed methods sit directly under the path item, while
|
|
8
|
+
# 3.2 puts non-standard verbs (COPY, MOVE, ...) one level deeper, under
|
|
9
|
+
# `additionalOperations`. Both need the same response/content sorting, or their
|
|
10
|
+
# order follows RSpec's random execution order and produces churny diffs.
|
|
11
|
+
OPERATION_SELECTORS = ['paths.*.*', 'paths.*.additionalOperations.*'].freeze
|
|
12
|
+
|
|
7
13
|
def deep_sort!(spec)
|
|
8
14
|
# paths
|
|
9
15
|
deep_sort_by_selector!(spec, 'paths')
|
|
10
16
|
|
|
11
|
-
# methods
|
|
17
|
+
# methods (and the additionalOperations verb map)
|
|
12
18
|
deep_sort_by_selector!(spec, 'paths.*')
|
|
19
|
+
deep_sort_by_selector!(spec, 'paths.*.additionalOperations')
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
OPERATION_SELECTORS.each do |operation|
|
|
22
|
+
# response status code
|
|
23
|
+
deep_sort_by_selector!(spec, "#{operation}.responses")
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
# content-type
|
|
26
|
+
deep_sort_by_selector!(spec, "#{operation}.responses.*.content")
|
|
27
|
+
end
|
|
19
28
|
end
|
|
20
29
|
|
|
21
30
|
private
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
# Splits a streaming body into its items (for `itemSchema`), skipping unparseable
|
|
6
|
+
# chunks. Supported content types: RSpec::OpenAPI::SEQUENTIAL_MEDIA_TYPES.
|
|
7
|
+
class << RSpec::OpenAPI::StreamParser = Object.new
|
|
8
|
+
def items(raw, content_type)
|
|
9
|
+
chunks =
|
|
10
|
+
case content_type
|
|
11
|
+
when 'application/json-seq' then raw.split("\x1e") # RFC 7464 record separator
|
|
12
|
+
when 'text/event-stream' then sse_data(raw)
|
|
13
|
+
else raw.split("\n") # NDJSON / JSON Lines
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
chunks.filter_map { |chunk| parse_json(chunk) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def parse_json(chunk)
|
|
22
|
+
text = chunk.to_s.strip
|
|
23
|
+
return nil if text.empty?
|
|
24
|
+
|
|
25
|
+
JSON.parse(text)
|
|
26
|
+
rescue JSON::ParserError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# SSE: events are blank-line separated; join the `data:` lines of each.
|
|
31
|
+
def sse_data(raw)
|
|
32
|
+
events = []
|
|
33
|
+
current = []
|
|
34
|
+
raw.each_line do |line|
|
|
35
|
+
line = line.chomp
|
|
36
|
+
if line.empty?
|
|
37
|
+
events << current.join("\n") unless current.empty?
|
|
38
|
+
current = []
|
|
39
|
+
elsif line.start_with?('data:')
|
|
40
|
+
current << line.sub(/\Adata: ?/, '')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
events << current.join("\n") unless current.empty?
|
|
44
|
+
events
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/rspec/openapi.rb
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
require 'rspec/openapi/version'
|
|
4
4
|
require 'rspec/openapi/components_updater'
|
|
5
5
|
require 'rspec/openapi/default_schema'
|
|
6
|
+
require 'rspec/openapi/nullable_converter'
|
|
7
|
+
require 'rspec/openapi/operation_converter'
|
|
8
|
+
require 'rspec/openapi/stream_parser'
|
|
6
9
|
require 'rspec/openapi/record_builder'
|
|
10
|
+
require 'rspec/openapi/exchange_recorder'
|
|
7
11
|
require 'rspec/openapi/result_recorder'
|
|
8
12
|
require 'rspec/openapi/schema_builder'
|
|
9
13
|
require 'rspec/openapi/schema_file'
|
|
@@ -18,6 +22,15 @@ require 'rspec/openapi/extractors/shared_extractor'
|
|
|
18
22
|
require 'rspec/openapi/extractors/rack'
|
|
19
23
|
|
|
20
24
|
module RSpec::OpenAPI
|
|
25
|
+
# Streaming media types whose body is a sequence of items, not one document.
|
|
26
|
+
# Their raw body is kept unparsed and split per item (see StreamParser).
|
|
27
|
+
SEQUENTIAL_MEDIA_TYPES = [
|
|
28
|
+
'application/jsonl',
|
|
29
|
+
'application/x-ndjson',
|
|
30
|
+
'application/json-seq',
|
|
31
|
+
'text/event-stream',
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
21
34
|
class Config
|
|
22
35
|
class << self
|
|
23
36
|
attr_accessor :debug_enabled
|
|
@@ -40,9 +53,11 @@ module RSpec::OpenAPI
|
|
|
40
53
|
@formats_builder = ->(example) { example.metadata[:formats] }
|
|
41
54
|
@info = {}
|
|
42
55
|
@application_version = '1.0.0'
|
|
56
|
+
@openapi_version = '3.2.0'
|
|
43
57
|
@request_headers = []
|
|
44
58
|
@servers = []
|
|
45
59
|
@security_schemes = []
|
|
60
|
+
@root_tags = []
|
|
46
61
|
@example_types = [:request]
|
|
47
62
|
@response_headers = []
|
|
48
63
|
@path_records = Hash.new { |h, k| h[k] = [] }
|
|
@@ -69,6 +84,7 @@ module RSpec::OpenAPI
|
|
|
69
84
|
:request_headers,
|
|
70
85
|
:servers,
|
|
71
86
|
:security_schemes,
|
|
87
|
+
:root_tags,
|
|
72
88
|
:example_types,
|
|
73
89
|
:response_headers,
|
|
74
90
|
:path_records,
|
|
@@ -76,7 +92,57 @@ module RSpec::OpenAPI
|
|
|
76
92
|
:ignored_path_params,
|
|
77
93
|
:post_process_hook
|
|
78
94
|
|
|
79
|
-
attr_reader :config_filename
|
|
95
|
+
attr_reader :config_filename, :openapi_version
|
|
96
|
+
|
|
97
|
+
SUPPORTED_OPENAPI_MAJOR_MINORS = ['3.0', '3.1', '3.2'].freeze
|
|
98
|
+
|
|
99
|
+
def openapi_version=(version)
|
|
100
|
+
major_minor = Gem::Version.new(version).segments.first(2).join('.')
|
|
101
|
+
unless SUPPORTED_OPENAPI_MAJOR_MINORS.include?(major_minor)
|
|
102
|
+
raise ArgumentError, "Unsupported OpenAPI version: #{version.inspect}. " \
|
|
103
|
+
"Supported: #{SUPPORTED_OPENAPI_MAJOR_MINORS.map { |v| "#{v}.x" }.join(', ')}"
|
|
104
|
+
end
|
|
105
|
+
@openapi_version = version
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def openapi_version_at_least?(version)
|
|
109
|
+
Gem::Version.new(openapi_version) >= Gem::Version.new(version)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 3.1+ drops `nullable` in favour of JSON Schema null types.
|
|
113
|
+
def json_schema_based?
|
|
114
|
+
openapi_version_at_least?('3.1')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 3.2 adds the `query` field and the `additionalOperations` map.
|
|
118
|
+
def supports_additional_operations?
|
|
119
|
+
openapi_version_at_least?('3.2')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# 3.2 adds `itemSchema` for sequential (streaming) media types.
|
|
123
|
+
def supports_item_schema?
|
|
124
|
+
openapi_version_at_least?('3.2')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def sequential_media_type?(media_type)
|
|
128
|
+
SEQUENTIAL_MEDIA_TYPES.include?(media_type)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Allow Rails request specs to issue extra verbs (e.g. QUERY); ActionDispatch
|
|
132
|
+
# otherwise rejects unknown verbs. No-op outside Rails.
|
|
133
|
+
def register_http_methods(methods)
|
|
134
|
+
# simplecov:disable branch non-Rails guard for roda/hanami; the suite always loads Rails
|
|
135
|
+
return unless defined?(ActionDispatch::Request::HTTP_METHODS)
|
|
136
|
+
|
|
137
|
+
# simplecov:enable
|
|
138
|
+
Array(methods).each do |method|
|
|
139
|
+
verb = method.to_s.upcase
|
|
140
|
+
next if ActionDispatch::Request::HTTP_METHODS.include?(verb)
|
|
141
|
+
|
|
142
|
+
ActionDispatch::Request::HTTP_METHODS << verb
|
|
143
|
+
ActionDispatch::Request::HTTP_METHOD_LOOKUP[verb] = verb.downcase.to_sym
|
|
144
|
+
end
|
|
145
|
+
end
|
|
80
146
|
end
|
|
81
147
|
end
|
|
82
148
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspec-openapi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Takashi Kokubun
|
|
@@ -66,6 +66,7 @@ files:
|
|
|
66
66
|
- lib/rspec/openapi/components_updater.rb
|
|
67
67
|
- lib/rspec/openapi/default_schema.rb
|
|
68
68
|
- lib/rspec/openapi/example_key.rb
|
|
69
|
+
- lib/rspec/openapi/exchange_recorder.rb
|
|
69
70
|
- lib/rspec/openapi/extractors.rb
|
|
70
71
|
- lib/rspec/openapi/extractors/hanami.rb
|
|
71
72
|
- lib/rspec/openapi/extractors/rack.rb
|
|
@@ -74,6 +75,8 @@ files:
|
|
|
74
75
|
- lib/rspec/openapi/hash_helper.rb
|
|
75
76
|
- lib/rspec/openapi/key_transformer.rb
|
|
76
77
|
- lib/rspec/openapi/minitest_hooks.rb
|
|
78
|
+
- lib/rspec/openapi/nullable_converter.rb
|
|
79
|
+
- lib/rspec/openapi/operation_converter.rb
|
|
77
80
|
- lib/rspec/openapi/record.rb
|
|
78
81
|
- lib/rspec/openapi/record_builder.rb
|
|
79
82
|
- lib/rspec/openapi/result_recorder.rb
|
|
@@ -85,6 +88,7 @@ files:
|
|
|
85
88
|
- lib/rspec/openapi/schema_merger.rb
|
|
86
89
|
- lib/rspec/openapi/schema_sorter.rb
|
|
87
90
|
- lib/rspec/openapi/shared_hooks.rb
|
|
91
|
+
- lib/rspec/openapi/stream_parser.rb
|
|
88
92
|
- lib/rspec/openapi/version.rb
|
|
89
93
|
- rspec-openapi.gemspec
|
|
90
94
|
homepage: https://github.com/exoego/rspec-openapi
|
|
@@ -93,7 +97,7 @@ licenses:
|
|
|
93
97
|
metadata:
|
|
94
98
|
homepage_uri: https://github.com/exoego/rspec-openapi
|
|
95
99
|
source_code_uri: https://github.com/exoego/rspec-openapi
|
|
96
|
-
changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.
|
|
100
|
+
changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.30.0
|
|
97
101
|
rubygems_mfa_required: 'true'
|
|
98
102
|
rdoc_options: []
|
|
99
103
|
require_paths:
|