rspec-openapi 0.29.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 +85 -0
- 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 +6 -0
- 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 +10 -1
- data/lib/rspec/openapi/result_recorder.rb +6 -0
- data/lib/rspec/openapi/rspec_hooks.rb +4 -0
- data/lib/rspec/openapi/schema_builder.rb +14 -0
- 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
|
@@ -159,6 +159,10 @@ RSpec::OpenAPI.enable_example_summary = false
|
|
|
159
159
|
# Change `info.version`
|
|
160
160
|
RSpec::OpenAPI.application_version = '1.0.0'
|
|
161
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
|
+
|
|
162
166
|
# Set the info header details
|
|
163
167
|
RSpec::OpenAPI.info = {
|
|
164
168
|
description: 'My beautiful API',
|
|
@@ -187,6 +191,14 @@ RSpec::OpenAPI.security_schemes = {
|
|
|
187
191
|
},
|
|
188
192
|
}
|
|
189
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
|
+
|
|
190
202
|
# Generate a comment on top of a schema file
|
|
191
203
|
RSpec::OpenAPI.comment = <<~EOS
|
|
192
204
|
This file is auto-generated by rspec-openapi https://github.com/k0kubun/rspec-openapi
|
|
@@ -238,6 +250,46 @@ RSpec::OpenAPI.post_process_hook = -> (path, records, spec) do
|
|
|
238
250
|
end
|
|
239
251
|
```
|
|
240
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
|
+
|
|
241
293
|
### Can I use rspec-openapi with `$ref` to minimize duplication of schema?
|
|
242
294
|
|
|
243
295
|
Yes, rspec-openapi v0.7.0+ supports [`$ref` mechanism](https://swagger.io/docs/specification/using-ref/) and generates
|
|
@@ -405,6 +457,39 @@ The merge happens at the top level of the `openapi:` hash. Scalar keys (`summary
|
|
|
405
457
|
…) follow last-wins, and a nested level that re-declares a structured key (`tags`, `security`,
|
|
406
458
|
`enum`, …) replaces the inherited value for that key rather than deep-merging it.
|
|
407
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
|
+
|
|
408
493
|
### Enum Support
|
|
409
494
|
|
|
410
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)
|
|
@@ -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
|
|
@@ -9,7 +9,7 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
9
9
|
# @param [RSpec::Core::Example] example
|
|
10
10
|
# @return [RSpec::OpenAPI::Record,nil]
|
|
11
11
|
def build(context, example:, extractor:)
|
|
12
|
-
request, response =
|
|
12
|
+
request, response = select_request_response(context, example, extractor)
|
|
13
13
|
return if request.nil?
|
|
14
14
|
|
|
15
15
|
attributes = extractor.request_attributes(request, example)
|
|
@@ -21,6 +21,13 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
21
21
|
|
|
22
22
|
private
|
|
23
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
|
+
|
|
24
31
|
def build_record(title, request, response, attributes)
|
|
25
32
|
request_headers, response_headers = extract_headers(request, response)
|
|
26
33
|
RSpec::OpenAPI::Record.new(
|
|
@@ -43,6 +50,8 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
43
50
|
def safe_parse_body(response, media_type)
|
|
44
51
|
# Use raw body, because Nokogiri-parsed HTML are modified (new lines injection, meta injection, and so on) :(
|
|
45
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)
|
|
46
55
|
|
|
47
56
|
response.parsed_body
|
|
48
57
|
rescue JSON::ParserError
|
|
@@ -45,6 +45,10 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
45
45
|
def build_spec(primary, records)
|
|
46
46
|
title = records.first.title
|
|
47
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)
|
|
48
52
|
schema = RSpec::OpenAPI::DefaultSchema.build(title)
|
|
49
53
|
schema[:info].merge!(RSpec::OpenAPI.info)
|
|
50
54
|
RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
|
|
@@ -70,6 +74,8 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
70
74
|
RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
|
|
71
75
|
RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
|
|
72
76
|
RSpec::OpenAPI::SchemaCleaner.cleanup_empty_required_array!(spec)
|
|
77
|
+
RSpec::OpenAPI::OperationConverter.to_additional_operations!(spec) if RSpec::OpenAPI.supports_additional_operations?
|
|
73
78
|
RSpec::OpenAPI::SchemaSorter.deep_sort!(spec)
|
|
79
|
+
RSpec::OpenAPI::NullableConverter.to_json_schema!(spec) if RSpec::OpenAPI.json_schema_based?
|
|
74
80
|
end
|
|
75
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:)
|
|
@@ -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:
|