rspec-openapi 0.21.4 → 0.22.1
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/.github/workflows/create_release.yml +26 -10
- data/.github/workflows/publish.yml +2 -2
- data/.github/workflows/rubocop.yml +1 -1
- data/.github/workflows/test.yml +2 -0
- data/Gemfile +9 -5
- data/README.md +94 -1
- data/lib/rspec/openapi/example_key.rb +10 -0
- data/lib/rspec/openapi/extractors/hanami.rb +6 -27
- data/lib/rspec/openapi/extractors/rack.rb +7 -29
- data/lib/rspec/openapi/extractors/rails.rb +6 -28
- data/lib/rspec/openapi/extractors/shared_extractor.rb +61 -0
- data/lib/rspec/openapi/key_transformer.rb +22 -1
- data/lib/rspec/openapi/record.rb +4 -0
- data/lib/rspec/openapi/record_builder.rb +6 -2
- data/lib/rspec/openapi/schema_builder.rb +81 -19
- data/lib/rspec/openapi/schema_cleaner.rb +24 -0
- data/lib/rspec/openapi/schema_merger.rb +23 -0
- data/lib/rspec/openapi/version.rb +1 -1
- data/lib/rspec/openapi.rb +6 -0
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f37b771a178f8725b58190cecd8833eb49254bff604de7fef59bcb89b64cbf0
|
|
4
|
+
data.tar.gz: 956a8fd4c973a3c25112bdf13110cae26a6e35d146a037bd0a3af17f8b871596
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a833eaee2f5d656eda27a40982aea8f8a8e737c82035cff713045a99fc19e2a29fcc810aa301da130d2739eb17fa1540d2df1b656489cbf21f1a889fe49397f5
|
|
7
|
+
data.tar.gz: a2a04437d06f870317d4c36bc4cbe9990beda5b5f2068eeca6f8b0e99b823a565862bb4572e924d4f49272c0a1e06ee7b4fb487d585a8b1bc5819755e57f9cd7
|
|
@@ -4,7 +4,7 @@ on:
|
|
|
4
4
|
workflow_dispatch:
|
|
5
5
|
inputs:
|
|
6
6
|
version:
|
|
7
|
-
description: 'Version to release (e.g. 0.
|
|
7
|
+
description: 'Version to release (e.g. 0.22.2 or 0.23.0)'
|
|
8
8
|
required: true
|
|
9
9
|
|
|
10
10
|
jobs:
|
|
@@ -12,15 +12,11 @@ jobs:
|
|
|
12
12
|
name: Prepare release PR
|
|
13
13
|
runs-on: ubuntu-latest
|
|
14
14
|
|
|
15
|
-
permissions:
|
|
16
|
-
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
|
17
|
-
contents: write # required to push release branch and open PR
|
|
18
|
-
pull-requests: write # required to create the release PR with GITHUB_TOKEN
|
|
19
|
-
|
|
20
15
|
steps:
|
|
21
|
-
- uses: actions/checkout@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
22
17
|
with:
|
|
23
18
|
fetch-depth: 0
|
|
19
|
+
token: ${{ secrets.PREPARE_RELEASE_PAT }}
|
|
24
20
|
|
|
25
21
|
- name: Bump version.rb
|
|
26
22
|
run: |
|
|
@@ -47,8 +43,27 @@ jobs:
|
|
|
47
43
|
exit 1
|
|
48
44
|
end
|
|
49
45
|
RUBY
|
|
50
|
-
|
|
51
46
|
ruby -pi -e "sub(/VERSION = .*/, \"VERSION = '$version'\")" lib/rspec/openapi/version.rb
|
|
47
|
+
|
|
48
|
+
VERSION_NO_V="$version" ruby - <<'RUBY'
|
|
49
|
+
version = ENV.fetch('VERSION_NO_V')
|
|
50
|
+
segments = version.split('.').map(&:to_i)
|
|
51
|
+
unless segments.size >= 3
|
|
52
|
+
warn "Version must have at least major.minor.patch: #{version}"
|
|
53
|
+
exit 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
major, minor, patch = segments
|
|
57
|
+
patch_bump = [major, minor, patch + 1].join('.')
|
|
58
|
+
minor_bump = [major, minor + 1, 0].join('.')
|
|
59
|
+
example = "Version to release (e.g. #{patch_bump} or #{minor_bump})"
|
|
60
|
+
|
|
61
|
+
path = ".github/workflows/create_release.yml"
|
|
62
|
+
content = File.read(path)
|
|
63
|
+
content.sub!(/description:\s*'Version to release \(e\.g\. [^']+\)'/,
|
|
64
|
+
"description: '#{example}'")
|
|
65
|
+
File.write(path, content)
|
|
66
|
+
RUBY
|
|
52
67
|
git status --short
|
|
53
68
|
|
|
54
69
|
- name: Commit version bump
|
|
@@ -65,11 +80,12 @@ jobs:
|
|
|
65
80
|
git push origin "HEAD:${release_branch}"
|
|
66
81
|
|
|
67
82
|
- name: Open release PR
|
|
68
|
-
uses: peter-evans/create-pull-request@
|
|
83
|
+
uses: peter-evans/create-pull-request@v8
|
|
69
84
|
with:
|
|
70
|
-
token: ${{ secrets.
|
|
85
|
+
token: ${{ secrets.PREPARE_RELEASE_PAT }}
|
|
71
86
|
add-paths: |
|
|
72
87
|
lib/rspec/openapi/version.rb
|
|
88
|
+
.github/workflows/create_release.yml
|
|
73
89
|
branch: ${{ env.RELEASE_BRANCH }}
|
|
74
90
|
title: Release v${{ env.VERSION_NO_V }}
|
|
75
91
|
commit-message: Bump version to ${{ env.VERSION_NO_V }}
|
|
@@ -15,7 +15,7 @@ jobs:
|
|
|
15
15
|
contents: write # to create GitHub release
|
|
16
16
|
|
|
17
17
|
steps:
|
|
18
|
-
- uses: actions/checkout@
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
19
|
with:
|
|
20
20
|
fetch-depth: 0
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ jobs:
|
|
|
23
23
|
uses: ruby/setup-ruby@v1
|
|
24
24
|
with:
|
|
25
25
|
bundler-cache: true
|
|
26
|
-
ruby-version:
|
|
26
|
+
ruby-version: '4.0'
|
|
27
27
|
|
|
28
28
|
- name: Verify tag matches version.rb
|
|
29
29
|
run: |
|
data/.github/workflows/test.yml
CHANGED
data/Gemfile
CHANGED
|
@@ -8,11 +8,15 @@ gemspec
|
|
|
8
8
|
gem 'rails', ENV['RAILS_VERSION'] || '6.0.6.1'
|
|
9
9
|
|
|
10
10
|
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.0.0')
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('4.0.0')
|
|
12
|
+
gem 'dry-logger', '1.2.1'
|
|
13
|
+
gem 'hanami', ENV['HANAMI_VERSION'] || '2.3.2'
|
|
14
|
+
else
|
|
15
|
+
gem 'dry-logger', '1.0.3'
|
|
16
|
+
gem 'hanami', ENV['HANAMI_VERSION'] || '2.1.0'
|
|
17
|
+
end
|
|
18
|
+
gem 'hanami-controller'
|
|
19
|
+
gem 'hanami-router'
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
gem 'concurrent-ruby', '1.3.4'
|
data/README.md
CHANGED
|
@@ -135,9 +135,15 @@ RSpec::OpenAPI.title = -> (example) {
|
|
|
135
135
|
end
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
# Disable generating `example`
|
|
138
|
+
# Disable generating `example` globally
|
|
139
139
|
RSpec::OpenAPI.enable_example = false
|
|
140
140
|
|
|
141
|
+
# Customize example name generation (used for multiple examples)
|
|
142
|
+
RSpec::OpenAPI.example_name_builder = -> (example) { example.description }
|
|
143
|
+
|
|
144
|
+
# Disable generating example summaries for `examples`
|
|
145
|
+
RSpec::OpenAPI.enable_example_summary = false
|
|
146
|
+
|
|
141
147
|
# Change `info.version`
|
|
142
148
|
RSpec::OpenAPI.application_version = '1.0.0'
|
|
143
149
|
|
|
@@ -360,6 +366,93 @@ Some examples' attributes can be overwritten via RSpec metadata options. Example
|
|
|
360
366
|
|
|
361
367
|
**NOTE**: `description` key will override also the one provided by `RSpec::OpenAPI.description_builder` method.
|
|
362
368
|
|
|
369
|
+
### Multiple Examples Mode
|
|
370
|
+
|
|
371
|
+
You can generate multiple named examples for the same endpoint using `example_mode`:
|
|
372
|
+
|
|
373
|
+
```rb
|
|
374
|
+
describe '#index', openapi: { example_mode: :multiple } do
|
|
375
|
+
it 'with pagination' do
|
|
376
|
+
get '/tables', params: { page: 1, per: 10 }
|
|
377
|
+
expect(response.status).to eq(200)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
it 'with filter' do
|
|
381
|
+
get '/tables', params: { filter: { name: 'test' } }
|
|
382
|
+
expect(response.status).to eq(200)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
This generates OpenAPI with multiple named examples:
|
|
388
|
+
|
|
389
|
+
```yaml
|
|
390
|
+
responses:
|
|
391
|
+
'200':
|
|
392
|
+
content:
|
|
393
|
+
application/json:
|
|
394
|
+
schema: { ... }
|
|
395
|
+
examples:
|
|
396
|
+
with_pagination:
|
|
397
|
+
value: { ... }
|
|
398
|
+
with_filter:
|
|
399
|
+
value: { ... }
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Available `example_mode` values:
|
|
403
|
+
- `:single` (default) - generates single `example` field
|
|
404
|
+
- `:multiple` - generates named `examples` with test descriptions as keys
|
|
405
|
+
- `:none` - generates only schema, no examples
|
|
406
|
+
|
|
407
|
+
The mode is inherited by nested contexts and can be overridden at any level.
|
|
408
|
+
|
|
409
|
+
**Note:** If multiple examples resolve to the same example key for a single endpoint, the last one wins (overwrites).
|
|
410
|
+
|
|
411
|
+
#### Merge Behavior with Mixed Modes
|
|
412
|
+
|
|
413
|
+
When multiple tests target the same endpoint with different `example_mode` settings (even from different spec files), the merger automatically converts to `examples` format:
|
|
414
|
+
|
|
415
|
+
```rb
|
|
416
|
+
# spec/requests/api_spec.rb
|
|
417
|
+
describe 'GET /users' do
|
|
418
|
+
it 'returns users' do # default :single mode
|
|
419
|
+
get '/users'
|
|
420
|
+
expect(response.status).to eq(200)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# spec/requests/admin_spec.rb
|
|
425
|
+
describe 'GET /users', openapi: { example_mode: :multiple } do
|
|
426
|
+
it 'with admin privileges' do
|
|
427
|
+
get '/users', headers: { 'X-Admin': 'true' }
|
|
428
|
+
expect(response.status).to eq(200)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Result - both examples merged into `examples`:
|
|
434
|
+
```yaml
|
|
435
|
+
responses:
|
|
436
|
+
'200':
|
|
437
|
+
content:
|
|
438
|
+
application/json:
|
|
439
|
+
examples:
|
|
440
|
+
returns_users:
|
|
441
|
+
value: { ... }
|
|
442
|
+
with_admin_privileges:
|
|
443
|
+
value: { ... }
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
To exclude specific tests from example generation, use `example_mode: :none`:
|
|
447
|
+
|
|
448
|
+
```rb
|
|
449
|
+
describe 'GET /users', openapi: { example_mode: :none } do
|
|
450
|
+
it 'edge case test' do
|
|
451
|
+
# This won't add examples to OpenAPI spec
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
```
|
|
455
|
+
|
|
363
456
|
## Experimental minitest support
|
|
364
457
|
|
|
365
458
|
Even if you are not using `rspec` this gem might help you with its experimental support for `minitest`.
|
|
@@ -56,15 +56,9 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
56
56
|
|
|
57
57
|
return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless route.routable?
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example)
|
|
63
|
-
operation_id = metadata[:operation_id]
|
|
64
|
-
required_request_params = metadata[:required_request_params] || []
|
|
65
|
-
security = metadata[:security]
|
|
66
|
-
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
|
|
67
|
-
deprecated = metadata[:deprecated]
|
|
59
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
|
|
60
|
+
example_key, example_name = SharedExtractor.attributes(example)
|
|
61
|
+
|
|
68
62
|
path = request.path
|
|
69
63
|
|
|
70
64
|
raw_path_params = route.params
|
|
@@ -88,6 +82,9 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
88
82
|
security,
|
|
89
83
|
deprecated,
|
|
90
84
|
formats,
|
|
85
|
+
example_mode,
|
|
86
|
+
example_key,
|
|
87
|
+
example_name,
|
|
91
88
|
]
|
|
92
89
|
end
|
|
93
90
|
|
|
@@ -102,24 +99,6 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
102
99
|
|
|
103
100
|
private
|
|
104
101
|
|
|
105
|
-
def merge_openapi_metadata(metadata)
|
|
106
|
-
collect_openapi_metadata(metadata).reduce({}, &:merge)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def collect_openapi_metadata(metadata)
|
|
110
|
-
[].tap do |result|
|
|
111
|
-
current = metadata
|
|
112
|
-
|
|
113
|
-
while current
|
|
114
|
-
[current[:example_group], current].each do |meta|
|
|
115
|
-
result.unshift(meta[:openapi]) if meta&.dig(:openapi)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
current = current[:parent_example_group]
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
102
|
def add_id(path, route)
|
|
124
103
|
return path if route.params.empty?
|
|
125
104
|
|
|
@@ -6,18 +6,13 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
|
|
|
6
6
|
# @param [RSpec::Core::Example] example
|
|
7
7
|
# @return Array
|
|
8
8
|
def request_attributes(request, example)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example)
|
|
13
|
-
operation_id = metadata[:operation_id]
|
|
14
|
-
required_request_params = metadata[:required_request_params] || []
|
|
15
|
-
security = metadata[:security]
|
|
16
|
-
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
|
|
17
|
-
deprecated = metadata[:deprecated]
|
|
9
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
|
|
10
|
+
example_key, example_name = SharedExtractor.attributes(example)
|
|
11
|
+
|
|
18
12
|
raw_path_params = request.path_parameters
|
|
19
13
|
path = request.path
|
|
20
14
|
summary ||= "#{request.method} #{path}"
|
|
15
|
+
|
|
21
16
|
[
|
|
22
17
|
path,
|
|
23
18
|
summary,
|
|
@@ -29,6 +24,9 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
|
|
|
29
24
|
security,
|
|
30
25
|
deprecated,
|
|
31
26
|
formats,
|
|
27
|
+
example_mode,
|
|
28
|
+
example_key,
|
|
29
|
+
example_name,
|
|
32
30
|
]
|
|
33
31
|
end
|
|
34
32
|
|
|
@@ -40,24 +38,4 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
|
|
|
40
38
|
|
|
41
39
|
[request, response]
|
|
42
40
|
end
|
|
43
|
-
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
def merge_openapi_metadata(metadata)
|
|
47
|
-
collect_openapi_metadata(metadata).reduce({}, &:merge)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def collect_openapi_metadata(metadata)
|
|
51
|
-
[].tap do |result|
|
|
52
|
-
current = metadata
|
|
53
|
-
|
|
54
|
-
while current
|
|
55
|
-
[current[:example_group], current].each do |meta|
|
|
56
|
-
result.unshift(meta[:openapi]) if meta&.dig(:openapi)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
current = current[:parent_example_group]
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
41
|
end
|
|
@@ -16,16 +16,9 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
|
|
|
16
16
|
|
|
17
17
|
raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil?
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example)
|
|
23
|
-
|
|
24
|
-
operation_id = metadata[:operation_id]
|
|
25
|
-
required_request_params = metadata[:required_request_params] || []
|
|
26
|
-
security = metadata[:security]
|
|
27
|
-
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
|
|
28
|
-
deprecated = metadata[:deprecated]
|
|
19
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
|
|
20
|
+
example_key, example_name = SharedExtractor.attributes(example)
|
|
21
|
+
|
|
29
22
|
raw_path_params = request.path_parameters
|
|
30
23
|
|
|
31
24
|
summary ||= route.requirements[:action]
|
|
@@ -47,6 +40,9 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
|
|
|
47
40
|
security,
|
|
48
41
|
deprecated,
|
|
49
42
|
formats,
|
|
43
|
+
example_mode,
|
|
44
|
+
example_key,
|
|
45
|
+
example_name,
|
|
50
46
|
]
|
|
51
47
|
end
|
|
52
48
|
|
|
@@ -57,24 +53,6 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
|
|
|
57
53
|
|
|
58
54
|
private
|
|
59
55
|
|
|
60
|
-
def merge_openapi_metadata(metadata)
|
|
61
|
-
collect_openapi_metadata(metadata).reduce({}, &:merge)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def collect_openapi_metadata(metadata)
|
|
65
|
-
[].tap do |result|
|
|
66
|
-
current = metadata
|
|
67
|
-
|
|
68
|
-
while current
|
|
69
|
-
[current[:example_group], current].each do |meta|
|
|
70
|
-
result.unshift(meta[:openapi]) if meta&.dig(:openapi)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
current = current[:parent_example_group]
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
56
|
# @param [ActionDispatch::Request] request
|
|
79
57
|
def find_rails_route(request, app: Rails.application, path_prefix: '')
|
|
80
58
|
app.routes.router.recognize(request) do |route, _parameters|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared extractor for extracting OpenAPI metadata from RSpec examples
|
|
4
|
+
class SharedExtractor
|
|
5
|
+
VALID_EXAMPLE_MODES = %i[none single multiple].freeze
|
|
6
|
+
|
|
7
|
+
def self.attributes(example)
|
|
8
|
+
metadata = merge_openapi_metadata(example.metadata)
|
|
9
|
+
summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
|
|
10
|
+
tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
|
|
11
|
+
formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example)
|
|
12
|
+
operation_id = metadata[:operation_id]
|
|
13
|
+
required_request_params = metadata[:required_request_params] || []
|
|
14
|
+
security = metadata[:security]
|
|
15
|
+
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
|
|
16
|
+
deprecated = metadata[:deprecated]
|
|
17
|
+
example_mode = normalize_example_mode(metadata[:example_mode], example)
|
|
18
|
+
example_name = metadata[:example_name] || RSpec::OpenAPI.example_name_builder.call(example)
|
|
19
|
+
raw_example_key = metadata[:example_key] || example_name
|
|
20
|
+
example_key = RSpec::OpenAPI::ExampleKey.normalize(raw_example_key)
|
|
21
|
+
example_key = 'default' if example_key.nil? || example_key.empty?
|
|
22
|
+
|
|
23
|
+
[summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
|
|
24
|
+
example_key, example_name,]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.merge_openapi_metadata(metadata)
|
|
28
|
+
collect_openapi_metadata(metadata).reduce({}, &:merge)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.collect_openapi_metadata(metadata)
|
|
32
|
+
[].tap do |result|
|
|
33
|
+
current = metadata
|
|
34
|
+
|
|
35
|
+
while current
|
|
36
|
+
[current[:example_group], current].each do |meta|
|
|
37
|
+
result.unshift(meta[:openapi]) if meta&.dig(:openapi)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
current = current[:parent_example_group]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.normalize_example_mode(value, example = nil)
|
|
46
|
+
return :single if value.nil?
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, example_mode_error(value, example) unless value.is_a?(String) || value.is_a?(Symbol)
|
|
49
|
+
|
|
50
|
+
mode = value.to_s.strip.downcase.to_sym
|
|
51
|
+
return mode if VALID_EXAMPLE_MODES.include?(mode)
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, example_mode_error(value, example)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.example_mode_error(value, example)
|
|
57
|
+
context = example&.full_description
|
|
58
|
+
context = " (example: #{context})" if context
|
|
59
|
+
"example_mode must be one of #{VALID_EXAMPLE_MODES.inspect}, got #{value.inspect}#{context}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -4,7 +4,28 @@ class << RSpec::OpenAPI::KeyTransformer = Object.new
|
|
|
4
4
|
def symbolize(value)
|
|
5
5
|
case value
|
|
6
6
|
when Hash
|
|
7
|
-
value.to_h
|
|
7
|
+
value.to_h do |k, v|
|
|
8
|
+
if k.to_sym == :examples
|
|
9
|
+
[k.to_sym, symbolize_examples(v)]
|
|
10
|
+
else
|
|
11
|
+
[k.to_sym, symbolize(v)]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
when Array
|
|
15
|
+
value.map { |v| symbolize(v) }
|
|
16
|
+
else
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def symbolize_examples(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash
|
|
24
|
+
value.to_h do |k, v|
|
|
25
|
+
k = k.downcase.tr(' ', '_') unless k.is_a?(Symbol)
|
|
26
|
+
|
|
27
|
+
[k.to_sym, symbolize(v)]
|
|
28
|
+
end
|
|
8
29
|
when Array
|
|
9
30
|
value.map { |v| symbolize(v) }
|
|
10
31
|
else
|
data/lib/rspec/openapi/record.rb
CHANGED
|
@@ -15,8 +15,12 @@ RSpec::OpenAPI::Record = Struct.new(
|
|
|
15
15
|
:formats, # @param [Proc] - ->(key) { key.end_with?('_at') ? 'date-time' : nil }
|
|
16
16
|
:operation_id, # @param [String] - "request-1234"
|
|
17
17
|
:description, # @param [String] - "returns a status"
|
|
18
|
+
:example_key, # @param [String] - "with_flat_query_parameters"
|
|
19
|
+
:example_name, # @param [String] - "with flat query parameters"
|
|
18
20
|
:security, # @param [Array] - [{securityScheme1: []}]
|
|
19
21
|
:deprecated, # @param [Boolean] - true
|
|
22
|
+
:example_enabled, # @param [Boolean] - true
|
|
23
|
+
:example_mode, # @param [Symbol] - :none | :single | :multiple
|
|
20
24
|
:status, # @param [Integer] - 200
|
|
21
25
|
:response_body, # @param [Object] - {"status" => "ok"}
|
|
22
26
|
:response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
|
|
@@ -12,8 +12,8 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
12
12
|
return if request.nil?
|
|
13
13
|
|
|
14
14
|
title = RSpec::OpenAPI.title.then { |t| t.is_a?(Proc) ? t.call(example) : t }
|
|
15
|
-
path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated,
|
|
16
|
-
extractor.request_attributes(request, example)
|
|
15
|
+
path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated,
|
|
16
|
+
formats, example_mode, example_key, example_name = extractor.request_attributes(request, example)
|
|
17
17
|
|
|
18
18
|
return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }
|
|
19
19
|
|
|
@@ -41,6 +41,10 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
41
41
|
response_headers: response_headers,
|
|
42
42
|
response_content_type: response.media_type,
|
|
43
43
|
response_content_disposition: response.header['Content-Disposition'],
|
|
44
|
+
example_enabled: RSpec::OpenAPI.enable_example,
|
|
45
|
+
example_mode: example_mode,
|
|
46
|
+
example_key: example_key,
|
|
47
|
+
example_name: example_name,
|
|
44
48
|
).freeze
|
|
45
49
|
end
|
|
46
50
|
|
|
@@ -15,14 +15,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
15
15
|
disposition = normalize_content_disposition(record.response_content_disposition)
|
|
16
16
|
|
|
17
17
|
has_content = !normalize_content_type(record.response_content_type).nil?
|
|
18
|
-
if has_content
|
|
19
|
-
response[:content] = {
|
|
20
|
-
normalize_content_type(record.response_content_type) => {
|
|
21
|
-
schema: build_property(record.response_body, disposition: disposition, record: record),
|
|
22
|
-
example: response_example(record, disposition: disposition),
|
|
23
|
-
}.compact,
|
|
24
|
-
}
|
|
25
|
-
end
|
|
18
|
+
response[:content] = build_content(disposition, record) if has_content
|
|
26
19
|
end
|
|
27
20
|
|
|
28
21
|
http_method = record.http_method.downcase
|
|
@@ -52,19 +45,74 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
52
45
|
%w[delete get].include?(http_method)
|
|
53
46
|
end
|
|
54
47
|
|
|
48
|
+
def build_content(disposition, record)
|
|
49
|
+
content_type = normalize_content_type(record.response_content_type)
|
|
50
|
+
schema = build_property(record.response_body, disposition: disposition, record: record)
|
|
51
|
+
|
|
52
|
+
# If examples are globally disabled, always return schema-only content.
|
|
53
|
+
return { content_type => { schema: schema }.compact } unless example_enabled?(record)
|
|
54
|
+
|
|
55
|
+
case record.example_mode
|
|
56
|
+
when :none
|
|
57
|
+
# Only schema, no examples
|
|
58
|
+
{
|
|
59
|
+
content_type => {
|
|
60
|
+
schema: schema,
|
|
61
|
+
}.compact,
|
|
62
|
+
}
|
|
63
|
+
when :multiple
|
|
64
|
+
# Multiple named examples
|
|
65
|
+
{
|
|
66
|
+
content_type => {
|
|
67
|
+
schema: schema,
|
|
68
|
+
examples: { record.example_key => build_example_object(record, disposition: disposition) },
|
|
69
|
+
}.compact,
|
|
70
|
+
}
|
|
71
|
+
else # :single (default)
|
|
72
|
+
# Single example + store name for possible merger conversion
|
|
73
|
+
{
|
|
74
|
+
content_type => {
|
|
75
|
+
schema: schema,
|
|
76
|
+
example: response_example(record, disposition: disposition),
|
|
77
|
+
_example_key: record.example_key,
|
|
78
|
+
_example_summary: example_summary(record),
|
|
79
|
+
}.compact,
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
55
84
|
def enrich_with_required_keys(obj)
|
|
56
85
|
obj[:required] = obj[:properties]&.keys || []
|
|
57
86
|
obj
|
|
58
87
|
end
|
|
59
88
|
|
|
60
89
|
def response_example(record, disposition:)
|
|
61
|
-
return nil if !example_enabled? || disposition
|
|
90
|
+
return nil if !example_enabled?(record) || disposition
|
|
62
91
|
|
|
63
92
|
record.response_body
|
|
64
93
|
end
|
|
65
94
|
|
|
66
|
-
def
|
|
67
|
-
|
|
95
|
+
def build_example_object(record, disposition:)
|
|
96
|
+
summary = example_summary(record)
|
|
97
|
+
example = {}
|
|
98
|
+
example[:summary] = summary if summary
|
|
99
|
+
example[:value] = response_example(record, disposition: disposition)
|
|
100
|
+
example
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def example_summary(record)
|
|
104
|
+
return nil unless example_summary_enabled?
|
|
105
|
+
return nil if record.example_name.nil? || record.example_name.empty?
|
|
106
|
+
|
|
107
|
+
record.example_name
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def example_enabled?(record)
|
|
111
|
+
record.example_enabled
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def example_summary_enabled?
|
|
115
|
+
RSpec::OpenAPI.enable_example_summary
|
|
68
116
|
end
|
|
69
117
|
|
|
70
118
|
def build_parameters(record)
|
|
@@ -74,7 +122,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
74
122
|
in: 'path',
|
|
75
123
|
required: true,
|
|
76
124
|
schema: build_property(try_cast(value), key: key, record: record),
|
|
77
|
-
example: (try_cast(value) if example_enabled?),
|
|
125
|
+
example: (try_cast(value) if example_enabled?(record)),
|
|
78
126
|
}.compact
|
|
79
127
|
end
|
|
80
128
|
|
|
@@ -84,7 +132,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
84
132
|
in: 'query',
|
|
85
133
|
required: record.required_request_params.include?(key),
|
|
86
134
|
schema: build_property(try_cast(value), key: key, record: record),
|
|
87
|
-
example: (try_cast(value) if example_enabled?),
|
|
135
|
+
example: (try_cast(value) if example_enabled?(record)),
|
|
88
136
|
}.compact
|
|
89
137
|
end
|
|
90
138
|
|
|
@@ -94,7 +142,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
94
142
|
in: 'header',
|
|
95
143
|
required: true,
|
|
96
144
|
schema: build_property(try_cast(value), key: key, record: record),
|
|
97
|
-
example: (try_cast(value) if example_enabled?),
|
|
145
|
+
example: (try_cast(value) if example_enabled?(record)),
|
|
98
146
|
}.compact
|
|
99
147
|
end
|
|
100
148
|
|
|
@@ -135,7 +183,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
135
183
|
content: {
|
|
136
184
|
normalize_content_type(record.request_content_type) => {
|
|
137
185
|
schema: build_property(record.request_params, record: record),
|
|
138
|
-
example: (build_example(record.request_params) if example_enabled?),
|
|
186
|
+
example: (build_example(record.request_params) if example_enabled?(record)),
|
|
139
187
|
}.compact,
|
|
140
188
|
},
|
|
141
189
|
}
|
|
@@ -273,7 +321,6 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
273
321
|
unique_types = property_variations.map { |p| p[:type] }.compact.uniq
|
|
274
322
|
|
|
275
323
|
if unique_types.size > 1
|
|
276
|
-
# Different types detected - create oneOf
|
|
277
324
|
unique_props = property_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
|
|
278
325
|
merged_schema[:properties][key] = { oneOf: unique_props }
|
|
279
326
|
else
|
|
@@ -315,7 +362,9 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
315
362
|
nullable_only = all_prop_variations.select { |p| p && p.keys == [:nullable] }
|
|
316
363
|
prop_variations = all_prop_variations.select { |p| p && p.keys != [:nullable] }.compact
|
|
317
364
|
|
|
318
|
-
has_nullable = all_prop_variations.any?
|
|
365
|
+
has_nullable = all_prop_variations.any? do |v|
|
|
366
|
+
v.nil? || (v.is_a?(Hash) && v[:nullable] == true)
|
|
367
|
+
end || nullable_only.any?
|
|
319
368
|
|
|
320
369
|
if prop_variations.empty? && has_nullable
|
|
321
370
|
merged[:properties][key] = { nullable: true }
|
|
@@ -324,8 +373,21 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
324
373
|
merged[:properties][key][:nullable] = true if has_nullable
|
|
325
374
|
elsif prop_variations.size > 1
|
|
326
375
|
prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
|
|
327
|
-
|
|
328
|
-
|
|
376
|
+
has_one_of = prop_variations.any? { |p| p.key?(:oneOf) }
|
|
377
|
+
|
|
378
|
+
if has_one_of
|
|
379
|
+
all_options = []
|
|
380
|
+
prop_variations.each do |prop|
|
|
381
|
+
clean_prop = prop.reject { |k, _| k == :nullable }
|
|
382
|
+
if clean_prop.key?(:oneOf)
|
|
383
|
+
all_options.concat(clean_prop[:oneOf])
|
|
384
|
+
else
|
|
385
|
+
all_options << clean_prop unless clean_prop.empty?
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
all_options.uniq!
|
|
389
|
+
merged[:properties][key] = { oneOf: all_options }
|
|
390
|
+
elsif prop_types.size == 1
|
|
329
391
|
# Only recursively merge if it's an object type
|
|
330
392
|
merged[:properties][key] = if prop_types.first == 'object'
|
|
331
393
|
build_merged_schema_from_variations(prop_variations)
|
|
@@ -32,10 +32,16 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
32
32
|
# cleanup requestBody
|
|
33
33
|
cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.schema.properties.*')
|
|
34
34
|
cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.example.*')
|
|
35
|
+
cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.examples.*')
|
|
35
36
|
|
|
36
37
|
# cleanup responses
|
|
37
38
|
cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.schema.properties.*')
|
|
38
39
|
cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.example.*')
|
|
40
|
+
cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.examples.*')
|
|
41
|
+
|
|
42
|
+
# cleanup temporary fields used for internal processing
|
|
43
|
+
cleanup_temporary_fields!(base)
|
|
44
|
+
|
|
39
45
|
base
|
|
40
46
|
end
|
|
41
47
|
|
|
@@ -71,6 +77,24 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
71
77
|
|
|
72
78
|
private
|
|
73
79
|
|
|
80
|
+
# Recursively remove temporary fields like :_example_key and :_example_name from the schema
|
|
81
|
+
def cleanup_temporary_fields!(hash)
|
|
82
|
+
return unless hash.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
hash.delete(:_example_key)
|
|
85
|
+
hash.delete(:_example_summary)
|
|
86
|
+
hash.delete(:_example_name)
|
|
87
|
+
|
|
88
|
+
hash.each_value do |value|
|
|
89
|
+
case value
|
|
90
|
+
when Hash
|
|
91
|
+
cleanup_temporary_fields!(value)
|
|
92
|
+
when Array
|
|
93
|
+
value.each { |item| cleanup_temporary_fields!(item) if item.is_a?(Hash) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
74
98
|
def remove_parameters_conflicting_with_security_scheme!(path_definition, security_scheme, security_scheme_name)
|
|
75
99
|
return unless path_definition[:security]
|
|
76
100
|
return unless path_definition[:parameters]
|
|
@@ -25,6 +25,9 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
|
|
|
25
25
|
|
|
26
26
|
spec.each do |key, value|
|
|
27
27
|
if base[key].is_a?(Hash) && value.is_a?(Hash)
|
|
28
|
+
# Handle example/examples conflict - convert to examples when mixed
|
|
29
|
+
normalize_example_fields!(base[key], value)
|
|
30
|
+
|
|
28
31
|
# If the new value has oneOf, replace the entire value instead of merging
|
|
29
32
|
if value.key?(:oneOf)
|
|
30
33
|
base[key] = value
|
|
@@ -77,6 +80,26 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
|
|
|
77
80
|
|
|
78
81
|
SIMILARITY_THRESHOLD = 0.5
|
|
79
82
|
|
|
83
|
+
# Normalize example/examples fields when there's a conflict
|
|
84
|
+
# OpenAPI spec doesn't allow both example and examples in the same object
|
|
85
|
+
def normalize_example_fields!(base, spec)
|
|
86
|
+
if base.key?(:example) && spec.key?(:examples)
|
|
87
|
+
convert_example_to_examples!(base)
|
|
88
|
+
elsif base.key?(:examples) && spec.key?(:example)
|
|
89
|
+
convert_example_to_examples!(spec)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def convert_example_to_examples!(hash)
|
|
94
|
+
name = RSpec::OpenAPI::ExampleKey.normalize(hash.delete(:_example_key)) || 'default'
|
|
95
|
+
summary = hash.delete(:_example_summary)
|
|
96
|
+
value = hash.delete(:example)
|
|
97
|
+
example = {}
|
|
98
|
+
example[:summary] = summary if summary
|
|
99
|
+
example[:value] = value
|
|
100
|
+
hash[:examples] = { name => example }
|
|
101
|
+
end
|
|
102
|
+
|
|
80
103
|
def merge_closest_match!(options, spec)
|
|
81
104
|
score, option = options.map { |option| [similarity(option, spec), option] }.max_by(&:first)
|
|
82
105
|
|
data/lib/rspec/openapi.rb
CHANGED
|
@@ -7,12 +7,14 @@ require 'rspec/openapi/record_builder'
|
|
|
7
7
|
require 'rspec/openapi/result_recorder'
|
|
8
8
|
require 'rspec/openapi/schema_builder'
|
|
9
9
|
require 'rspec/openapi/schema_file'
|
|
10
|
+
require 'rspec/openapi/example_key'
|
|
10
11
|
require 'rspec/openapi/schema_merger'
|
|
11
12
|
require 'rspec/openapi/schema_cleaner'
|
|
12
13
|
require 'rspec/openapi/schema_sorter'
|
|
13
14
|
require 'rspec/openapi/key_transformer'
|
|
14
15
|
require 'rspec/openapi/shared_hooks'
|
|
15
16
|
require 'rspec/openapi/extractors'
|
|
17
|
+
require 'rspec/openapi/extractors/shared_extractor'
|
|
16
18
|
require 'rspec/openapi/extractors/rack'
|
|
17
19
|
|
|
18
20
|
module RSpec::OpenAPI
|
|
@@ -30,7 +32,9 @@ module RSpec::OpenAPI
|
|
|
30
32
|
@title = File.basename(Dir.pwd)
|
|
31
33
|
@comment = nil
|
|
32
34
|
@enable_example = true
|
|
35
|
+
@enable_example_summary = true
|
|
33
36
|
@description_builder = ->(example) { example.description }
|
|
37
|
+
@example_name_builder = :description.to_proc
|
|
34
38
|
@summary_builder = ->(example) { example.metadata[:summary] }
|
|
35
39
|
@tags_builder = ->(example) { example.metadata[:tags] }
|
|
36
40
|
@formats_builder = ->(example) { example.metadata[:formats] }
|
|
@@ -54,7 +58,9 @@ module RSpec::OpenAPI
|
|
|
54
58
|
:title,
|
|
55
59
|
:comment,
|
|
56
60
|
:enable_example,
|
|
61
|
+
:enable_example_summary,
|
|
57
62
|
:description_builder,
|
|
63
|
+
:example_name_builder,
|
|
58
64
|
:summary_builder,
|
|
59
65
|
:tags_builder,
|
|
60
66
|
:formats_builder,
|
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.22.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Takashi Kokubun
|
|
@@ -82,10 +82,12 @@ files:
|
|
|
82
82
|
- lib/rspec/openapi.rb
|
|
83
83
|
- lib/rspec/openapi/components_updater.rb
|
|
84
84
|
- lib/rspec/openapi/default_schema.rb
|
|
85
|
+
- lib/rspec/openapi/example_key.rb
|
|
85
86
|
- lib/rspec/openapi/extractors.rb
|
|
86
87
|
- lib/rspec/openapi/extractors/hanami.rb
|
|
87
88
|
- lib/rspec/openapi/extractors/rack.rb
|
|
88
89
|
- lib/rspec/openapi/extractors/rails.rb
|
|
90
|
+
- lib/rspec/openapi/extractors/shared_extractor.rb
|
|
89
91
|
- lib/rspec/openapi/hash_helper.rb
|
|
90
92
|
- lib/rspec/openapi/key_transformer.rb
|
|
91
93
|
- lib/rspec/openapi/minitest_hooks.rb
|
|
@@ -110,7 +112,7 @@ licenses:
|
|
|
110
112
|
metadata:
|
|
111
113
|
homepage_uri: https://github.com/exoego/rspec-openapi
|
|
112
114
|
source_code_uri: https://github.com/exoego/rspec-openapi
|
|
113
|
-
changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.
|
|
115
|
+
changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.22.1
|
|
114
116
|
rubygems_mfa_required: 'true'
|
|
115
117
|
rdoc_options: []
|
|
116
118
|
require_paths:
|
|
@@ -126,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
126
128
|
- !ruby/object:Gem::Version
|
|
127
129
|
version: '0'
|
|
128
130
|
requirements: []
|
|
129
|
-
rubygems_version:
|
|
131
|
+
rubygems_version: 4.0.3
|
|
130
132
|
specification_version: 4
|
|
131
133
|
summary: Generate OpenAPI schema from RSpec request specs
|
|
132
134
|
test_files: []
|