rspec-openapi 0.25.1 → 0.26.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/.github/workflows/create_release.yml +1 -1
- data/.github/workflows/publish.yml +1 -1
- data/.github/workflows/validate-openapi.yml +21 -0
- data/.gitignore +2 -0
- data/.rubocop_todo.yml +1 -1
- data/README.md +337 -36
- data/lib/rspec/openapi/extractors/hanami.rb +11 -3
- data/lib/rspec/openapi/extractors/rack.rb +11 -3
- data/lib/rspec/openapi/extractors/rails.rb +16 -3
- data/lib/rspec/openapi/extractors/shared_extractor.rb +82 -5
- data/lib/rspec/openapi/record.rb +6 -1
- data/lib/rspec/openapi/record_builder.rb +10 -3
- data/lib/rspec/openapi/schema_builder.rb +100 -21
- data/lib/rspec/openapi/schema_cleaner.rb +3 -0
- data/lib/rspec/openapi/schema_merger.rb +11 -0
- data/lib/rspec/openapi/version.rb +1 -1
- data/redocly.yaml +31 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 254e5e91a3965a417ad69c8b61d4bfdde6af42ab7aba14854d5531d73f4a793d
|
|
4
|
+
data.tar.gz: 3fa43709cf02b95bb1f4815eb4eebefde3ad5e5e2aabf0439267dce2c06cba36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 486180ee4a5b04de2f3539864e6f5be519ea262f0b30afc661377aa2da69c9c6fba883128ff02c006a9b073766848fc8b2ef5ad99cbb2fc55f8201de1a5975de
|
|
7
|
+
data.tar.gz: a074396a8847d7d9950fc414591e68da5c1def3121fdae896aa6e626e512c5860dc58b5be83248979bb6612628f224d367d066a8d5939991fbe98f8000626934
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: validate-openapi
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- master
|
|
7
|
+
pull_request:
|
|
8
|
+
types:
|
|
9
|
+
- opened
|
|
10
|
+
- synchronize
|
|
11
|
+
- reopened
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
redocly-lint:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
|
+
- uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: '22'
|
|
21
|
+
- run: npx --yes @redocly/cli@2.30.4 lint
|
data/.gitignore
CHANGED
data/.rubocop_todo.yml
CHANGED
|
@@ -24,7 +24,7 @@ Metrics/CyclomaticComplexity:
|
|
|
24
24
|
# Offense count: 22
|
|
25
25
|
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
|
|
26
26
|
Metrics/MethodLength:
|
|
27
|
-
Max:
|
|
27
|
+
Max: 37
|
|
28
28
|
|
|
29
29
|
# Offense count: 5
|
|
30
30
|
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
data/README.md
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# rspec-openapi [](https://rubygems.org/gems/rspec-openapi) [](https://github.com/exoego/rspec-openapi/actions/workflows/test.yml) [](https://codecov.io/gh/exoego/rspec-openapi) [](https://www.ruby-toolbox.com/projects/rspec-openapi) [](https://deepwiki.com/exoego/rspec-openapi)
|
|
2
2
|
|
|
3
|
-
|
|
4
3
|
Generate OpenAPI schema from RSpec request specs.
|
|
5
4
|
|
|
6
5
|
## What's this?
|
|
@@ -8,7 +7,8 @@ Generate OpenAPI schema from RSpec request specs.
|
|
|
8
7
|
There are some gems which generate OpenAPI specs from RSpec request specs.
|
|
9
8
|
However, they require a special DSL specific to these gems, and we can't reuse existing request specs as they are.
|
|
10
9
|
|
|
11
|
-
Unlike such [existing gems](#links), rspec-openapi can generate OpenAPI specs from request specs without requiring any
|
|
10
|
+
Unlike such [existing gems](#links), rspec-openapi can generate OpenAPI specs from request specs without requiring any
|
|
11
|
+
special DSL.
|
|
12
12
|
Furthermore, rspec-openapi keeps manual modifications when it merges automated changes to OpenAPI specs
|
|
13
13
|
in case we can't generate everything from request specs.
|
|
14
14
|
|
|
@@ -30,7 +30,9 @@ $ OPENAPI=1 bundle exec rspec
|
|
|
30
30
|
|
|
31
31
|
### Example
|
|
32
32
|
|
|
33
|
-
Let's say you
|
|
33
|
+
Let's say you
|
|
34
|
+
have [a request spec](https://github.com/exoego/rspec-openapi/blob/24e5c567c2e90945c7a41f19f71634ac028cc314/spec/requests/rails_spec.rb#L38)
|
|
35
|
+
like this:
|
|
34
36
|
|
|
35
37
|
```rb
|
|
36
38
|
RSpec.describe 'Tables', type: :request do
|
|
@@ -67,18 +69,18 @@ paths:
|
|
|
67
69
|
get:
|
|
68
70
|
summary: index
|
|
69
71
|
tags:
|
|
70
|
-
|
|
72
|
+
- Table
|
|
71
73
|
parameters:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
- name: page
|
|
75
|
+
in: query
|
|
76
|
+
schema:
|
|
77
|
+
type: integer
|
|
78
|
+
example: 1
|
|
79
|
+
- name: per
|
|
80
|
+
in: query
|
|
81
|
+
schema:
|
|
82
|
+
type: integer
|
|
83
|
+
example: 10
|
|
82
84
|
responses:
|
|
83
85
|
'200':
|
|
84
86
|
description: returns a list of tables
|
|
@@ -96,11 +98,11 @@ paths:
|
|
|
96
98
|
# ...
|
|
97
99
|
```
|
|
98
100
|
|
|
99
|
-
and the schema file can be used as an input of [Swagger UI](https://github.com/swagger-api/swagger-ui)
|
|
101
|
+
and the schema file can be used as an input of [Swagger UI](https://github.com/swagger-api/swagger-ui)
|
|
102
|
+
or [Redoc](https://github.com/Redocly/redoc).
|
|
100
103
|
|
|
101
104
|

|
|
102
105
|
|
|
103
|
-
|
|
104
106
|
### Configuration
|
|
105
107
|
|
|
106
108
|
The following configurations are optional.
|
|
@@ -251,7 +253,8 @@ paths:
|
|
|
251
253
|
# Note) #/components/schemas is not needed to be defined.
|
|
252
254
|
```
|
|
253
255
|
|
|
254
|
-
3. Then, re-run rspec-openapi. It will generate `#/components/schemas` with the referenced schema (`User` for example)
|
|
256
|
+
3. Then, re-run rspec-openapi. It will generate `#/components/schemas` with the referenced schema (`User` for example)
|
|
257
|
+
newly-generated or updated.
|
|
255
258
|
|
|
256
259
|
```yaml
|
|
257
260
|
paths:
|
|
@@ -354,21 +357,22 @@ Some examples' attributes can be overwritten via RSpec metadata options. Example
|
|
|
354
357
|
|
|
355
358
|
```rb
|
|
356
359
|
describe 'GET /api/v1/posts', openapi: {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
360
|
+
summary: 'list all posts',
|
|
361
|
+
description: 'list all posts ordered by pub_date',
|
|
362
|
+
tags: %w[v1 posts],
|
|
363
|
+
required_request_params: %w[limit],
|
|
364
|
+
security: [{ "MyToken" => [] }],
|
|
365
|
+
} do
|
|
366
|
+
# ...
|
|
367
|
+
end
|
|
365
368
|
```
|
|
366
369
|
|
|
367
370
|
**NOTE**: `description` key will override also the one provided by `RSpec::OpenAPI.description_builder` method.
|
|
368
371
|
|
|
369
372
|
### Enum Support
|
|
370
373
|
|
|
371
|
-
You can specify enum values for string properties that should have a fixed set of allowed values. Since enums cannot be
|
|
374
|
+
You can specify enum values for string properties that should have a fixed set of allowed values. Since enums cannot be
|
|
375
|
+
reliably inferred from test data, you can define them via the `enum` metadata option:
|
|
372
376
|
|
|
373
377
|
```rb
|
|
374
378
|
it 'returns user status', openapi: {
|
|
@@ -431,7 +435,8 @@ end
|
|
|
431
435
|
|
|
432
436
|
#### Request vs Response Enums
|
|
433
437
|
|
|
434
|
-
By default, `enum` applies to both request and response bodies. If you need different enum values for request and
|
|
438
|
+
By default, `enum` applies to both request and response bodies. If you need different enum values for request and
|
|
439
|
+
response, use `request_enum` and `response_enum`:
|
|
435
440
|
|
|
436
441
|
```rb
|
|
437
442
|
it 'creates a task', openapi: {
|
|
@@ -447,6 +452,173 @@ it 'creates a task', openapi: {
|
|
|
447
452
|
end
|
|
448
453
|
```
|
|
449
454
|
|
|
455
|
+
### Dynamic-key Object Support (`additionalProperties`)
|
|
456
|
+
|
|
457
|
+
When an endpoint returns (or accepts) an object whose keys are not part of its
|
|
458
|
+
schema — for example a permissions map `{ "can_edit": true, "can_delete": false }`
|
|
459
|
+
where new permissions can appear without an API change — rspec-openapi would
|
|
460
|
+
otherwise capture the test response literally and emit each observed key as a
|
|
461
|
+
fixed property. You can override this with the `additional_properties` metadata,
|
|
462
|
+
which replaces the captured `properties` / `required` of the matched object with
|
|
463
|
+
[`additionalProperties`](https://swagger.io/docs/specification/data-models/dictionaries/).
|
|
464
|
+
|
|
465
|
+
Keys are dot-notation paths (the same scheme as `enum`); the empty string `''`
|
|
466
|
+
targets the body root.
|
|
467
|
+
|
|
468
|
+
```rb
|
|
469
|
+
it 'returns a permission map', openapi: {
|
|
470
|
+
additional_properties: { 'data' => { type: 'boolean' } },
|
|
471
|
+
} do
|
|
472
|
+
get '/api/v1/permissions'
|
|
473
|
+
# Response: { "data": { "can_edit": true, "can_delete": false } }
|
|
474
|
+
expect(response.status).to eq(200)
|
|
475
|
+
end
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
This generates:
|
|
479
|
+
|
|
480
|
+
```yaml
|
|
481
|
+
schema:
|
|
482
|
+
type: object
|
|
483
|
+
properties:
|
|
484
|
+
data:
|
|
485
|
+
type: object
|
|
486
|
+
additionalProperties:
|
|
487
|
+
type: boolean
|
|
488
|
+
required:
|
|
489
|
+
- data
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
#### Root-level dynamic keys
|
|
493
|
+
|
|
494
|
+
When the entire body is a dynamic dict, use `''` as the path:
|
|
495
|
+
|
|
496
|
+
```rb
|
|
497
|
+
it 'lists organisation memberships', openapi: {
|
|
498
|
+
additional_properties: { '' => { type: 'boolean' } },
|
|
499
|
+
} do
|
|
500
|
+
get '/api/v1/organisations/acme/check_memberships'
|
|
501
|
+
# Response: { "user-hash-a": true, "user-hash-b": false }
|
|
502
|
+
expect(response.status).to eq(200)
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
```yaml
|
|
507
|
+
schema:
|
|
508
|
+
type: object
|
|
509
|
+
additionalProperties:
|
|
510
|
+
type: boolean
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
#### Schemas referenced via `$ref`
|
|
514
|
+
|
|
515
|
+
The value of `additionalProperties` can be any schema, including a `$ref`:
|
|
516
|
+
|
|
517
|
+
```rb
|
|
518
|
+
it 'returns tags by id', openapi: {
|
|
519
|
+
additional_properties: { '' => { '$ref' => '#/components/schemas/Tag' } },
|
|
520
|
+
} do
|
|
521
|
+
get '/tags'
|
|
522
|
+
expect(response.status).to eq(200)
|
|
523
|
+
end
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
```yaml
|
|
527
|
+
schema:
|
|
528
|
+
type: object
|
|
529
|
+
additionalProperties:
|
|
530
|
+
$ref: '#/components/schemas/Tag'
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
#### Request vs Response
|
|
534
|
+
|
|
535
|
+
`additional_properties` applies to both request and response by default. Use
|
|
536
|
+
`request_additional_properties` / `response_additional_properties` to scope to
|
|
537
|
+
one side:
|
|
538
|
+
|
|
539
|
+
```rb
|
|
540
|
+
it 'records arbitrary metrics', openapi: {
|
|
541
|
+
request_additional_properties: { '' => { type: 'integer' } },
|
|
542
|
+
} do
|
|
543
|
+
post '/metrics', params: { 'page_views' => 100, 'signups' => 5 }
|
|
544
|
+
expect(response.status).to eq(201)
|
|
545
|
+
end
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
#### Forbidding extras with `false` (or `true`)
|
|
549
|
+
|
|
550
|
+
Attaching `additionalProperties: false` forbid dynamic keys while `: true` (default)
|
|
551
|
+
explicitly allow those.
|
|
552
|
+
This is useful mostly for the `false` case, to prevent unexpected extras:
|
|
553
|
+
|
|
554
|
+
```rb
|
|
555
|
+
it 'returns a closed object', openapi: {
|
|
556
|
+
additional_properties: { '' => false },
|
|
557
|
+
} do
|
|
558
|
+
get '/api/v1/profile'
|
|
559
|
+
# Response: { "id": 1, "name": "alice" }
|
|
560
|
+
expect(response.status).to eq(200)
|
|
561
|
+
end
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
```yaml
|
|
565
|
+
schema:
|
|
566
|
+
type: object
|
|
567
|
+
properties:
|
|
568
|
+
id: { type: integer }
|
|
569
|
+
name: { type: string }
|
|
570
|
+
required: [ id, name ]
|
|
571
|
+
additionalProperties: false
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
#### Hybrid objects (known keys + dynamic keys)
|
|
575
|
+
|
|
576
|
+
When an object has both a fixed shape and dynamic keys, use `hybrid_additional_properties`
|
|
577
|
+
(`request_hybrid_additional_properties` and `response_hybrid_additional_properties` to scope
|
|
578
|
+
to one side). Captured properties stay; the supplied schema is attached as `additionalProperties`.
|
|
579
|
+
|
|
580
|
+
At the root (whole body is the hybrid object):
|
|
581
|
+
|
|
582
|
+
```rb
|
|
583
|
+
it 'returns an item with custom attributes', openapi: {
|
|
584
|
+
hybrid_additional_properties: { '' => { type: 'string' } },
|
|
585
|
+
} do
|
|
586
|
+
get '/api/v1/items/42'
|
|
587
|
+
# Response: { "id": 42, "attr_color": "red", "attr_size": "large" }
|
|
588
|
+
expect(response.status).to eq(200)
|
|
589
|
+
end
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
```yaml
|
|
593
|
+
schema:
|
|
594
|
+
type: object
|
|
595
|
+
properties:
|
|
596
|
+
id: { type: integer }
|
|
597
|
+
attr_color: { type: string }
|
|
598
|
+
attr_size: { type: string }
|
|
599
|
+
required: [ id, attr_color, attr_size ]
|
|
600
|
+
additionalProperties:
|
|
601
|
+
type: string
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
#### Notes on behavior
|
|
605
|
+
|
|
606
|
+
- A hash schema in `additional_properties` fully replaces the captured
|
|
607
|
+
`properties` / `required` at the matched node. To keep observed properties
|
|
608
|
+
alongside `additionalProperties`, use `hybrid_additional_properties` or pass
|
|
609
|
+
a boolean.
|
|
610
|
+
- Recursion stops once `additional_properties` matches a path with a hash
|
|
611
|
+
schema: nested overrides underneath (e.g. setting both `'data'` and
|
|
612
|
+
`'data.meta'`) won't be applied, because the supplied dictionary value
|
|
613
|
+
schema describes every child uniformly and isn't traversed further.
|
|
614
|
+
`hybrid_additional_properties` keeps observed properties so children are
|
|
615
|
+
still walked normally.
|
|
616
|
+
- When migrating an existing schema (one previously generated with concrete
|
|
617
|
+
`properties` / `required`) by adding this metadata, the next regeneration
|
|
618
|
+
prunes the stale `properties` / `required` automatically. If you had
|
|
619
|
+
manually added `additionalProperties` to an object that also has
|
|
620
|
+
`properties`, that hybrid shape is preserved.
|
|
621
|
+
|
|
450
622
|
### Multiple Examples Mode
|
|
451
623
|
|
|
452
624
|
You can generate multiple named examples for the same endpoint using `example_mode`:
|
|
@@ -480,23 +652,144 @@ responses:
|
|
|
480
652
|
value: { ... }
|
|
481
653
|
```
|
|
482
654
|
|
|
655
|
+
|
|
483
656
|
Available `example_mode` values:
|
|
657
|
+
|
|
484
658
|
- `:single` (default) - generates single `example` field
|
|
485
659
|
- `:multiple` - generates named `examples` with test descriptions as keys
|
|
486
660
|
- `:none` - generates only schema, no examples
|
|
487
661
|
|
|
662
|
+
`example_mode` also accepts a hash form so you can configure the request body and the response independently:
|
|
663
|
+
|
|
664
|
+
```rb
|
|
665
|
+
describe 'POST /sign_in', openapi: { example_mode: { request: :multiple, response: :multiple } } do
|
|
666
|
+
...
|
|
667
|
+
end
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Missing keys default to `:single`, so `{ example_mode: { request: :multiple } }` keeps the response on `:single`.
|
|
671
|
+
|
|
672
|
+
The bare symbol form maps as follows:
|
|
673
|
+
|
|
674
|
+
| `example_mode` value | request side | response side |
|
|
675
|
+
|-----------------------------------------------|--------------|---------------|
|
|
676
|
+
| `:single` (default) | `:single` | `:single` |
|
|
677
|
+
| `:none` | `:none` | `:none` |
|
|
678
|
+
| `:multiple` | `:single` ⚠️ | `:multiple` |
|
|
679
|
+
| `{ request: :multiple, response: :multiple }` | `:multiple` | `:multiple` |
|
|
680
|
+
|
|
681
|
+
⚠️ The bare `:multiple` shorthand is currently **response-only** to preserve the behavior that existed before
|
|
682
|
+
request-side multi-examples were introduced. A future major release will change it to mean
|
|
683
|
+
`{ request: :multiple, response: :multiple }`.
|
|
684
|
+
|
|
488
685
|
The mode is inherited by nested contexts and can be overridden at any level.
|
|
489
686
|
|
|
490
687
|
**Note:** If multiple examples resolve to the same example key for a single endpoint, the last one wins (overwrites).
|
|
491
688
|
|
|
689
|
+
#### Request Body Multiple Examples
|
|
690
|
+
|
|
691
|
+
With `example_mode: { request: :multiple }`, the generator produces named `examples:` for `requestBody`.
|
|
692
|
+
This is useful when the same endpoint accepts mutually exclusive request shapes (e.g. OTP vs email/password sign-in),
|
|
693
|
+
where merging them into a single `example:` would produce a nonsensical mixed payload.
|
|
694
|
+
|
|
695
|
+
```rb
|
|
696
|
+
describe 'POST /sign_in',
|
|
697
|
+
openapi: { example_mode: { request: :multiple, response: :multiple } } do
|
|
698
|
+
it 'with otp' do
|
|
699
|
+
post '/sign_in',
|
|
700
|
+
params: { auth_type: 'otp', otp: '123456' }.to_json,
|
|
701
|
+
headers: { 'CONTENT_TYPE' => 'application/json' }
|
|
702
|
+
expect(response.status).to eq(200)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
it 'with email password' do
|
|
706
|
+
post '/sign_in',
|
|
707
|
+
params: { auth_type: 'email', email: 'a@b.c', password: 'pw' }.to_json,
|
|
708
|
+
headers: { 'CONTENT_TYPE' => 'application/json' }
|
|
709
|
+
expect(response.status).to eq(200)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
The generated `requestBody` keeps each shape as its own keyed example:
|
|
715
|
+
|
|
716
|
+
```yaml
|
|
717
|
+
requestBody:
|
|
718
|
+
content:
|
|
719
|
+
application/json:
|
|
720
|
+
schema:
|
|
721
|
+
type: object
|
|
722
|
+
properties:
|
|
723
|
+
auth_type: { type: string }
|
|
724
|
+
otp: { type: string }
|
|
725
|
+
email: { type: string }
|
|
726
|
+
password: { type: string }
|
|
727
|
+
required:
|
|
728
|
+
- auth_type
|
|
729
|
+
examples:
|
|
730
|
+
with_otp:
|
|
731
|
+
summary: with otp
|
|
732
|
+
value: { auth_type: otp, otp: '123456' }
|
|
733
|
+
with_email_password:
|
|
734
|
+
summary: with email password
|
|
735
|
+
value: { auth_type: email, email: a@b.c, password: pw }
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
The keys default to the test's description (normalized to lowercase + underscores).
|
|
739
|
+
Override with `openapi: { example_key: 'custom_key', example_name: 'Custom Summary' }`,
|
|
740
|
+
just like for response examples.
|
|
741
|
+
|
|
742
|
+
##### Capturing 4xx requestBody examples
|
|
743
|
+
|
|
744
|
+
When `request_example_mode` is `:single` (the default), tests with `status >= 400`
|
|
745
|
+
are intentionally skipped for `requestBody` so that error payloads don't pollute
|
|
746
|
+
the request schema. When `request: :multiple` is set, this short-circuit is lifted:
|
|
747
|
+
each failing test contributes its own keyed example, so you can document
|
|
748
|
+
validation-failure shapes alongside success shapes:
|
|
749
|
+
|
|
750
|
+
```rb
|
|
751
|
+
describe 'POST /sign_in',
|
|
752
|
+
openapi: { example_mode: { request: :multiple, response: :multiple } } do
|
|
753
|
+
it 'with otp' do
|
|
754
|
+
post '/sign_in', params: { auth_type: 'otp', otp: '123456' }.to_json,
|
|
755
|
+
headers: { 'CONTENT_TYPE' => 'application/json' }
|
|
756
|
+
expect(response.status).to eq(200)
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
it 'with missing fields' do
|
|
760
|
+
post '/sign_in', params: { auth_type: 'email' }.to_json,
|
|
761
|
+
headers: { 'CONTENT_TYPE' => 'application/json' }
|
|
762
|
+
expect(response.status).to eq(400)
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
```yaml
|
|
768
|
+
requestBody:
|
|
769
|
+
content:
|
|
770
|
+
application/json:
|
|
771
|
+
examples:
|
|
772
|
+
with_otp: { summary: with otp, value: { ... } }
|
|
773
|
+
with_missing_fields: { summary: with missing fields, value: { ... } }
|
|
774
|
+
responses:
|
|
775
|
+
'200': { ... }
|
|
776
|
+
'400': { ... }
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
Mixing `:single` (some tests) with `:multiple` (others) on the same endpoint also works for `requestBody` -
|
|
780
|
+
the merger up-converts the singular `example:` into the `examples:` map automatically
|
|
781
|
+
(see [Merge Behavior with Mixed Modes](#merge-behavior-with-mixed-modes) below).
|
|
782
|
+
|
|
492
783
|
#### Merge Behavior with Mixed Modes
|
|
493
784
|
|
|
494
|
-
When multiple tests target the same endpoint with different `example_mode` settings (even from different spec files),
|
|
785
|
+
When multiple tests target the same endpoint with different `example_mode` settings (even from different spec files),
|
|
786
|
+
the merger automatically converts to `examples` format:
|
|
495
787
|
|
|
496
788
|
```rb
|
|
497
789
|
# spec/requests/api_spec.rb
|
|
498
790
|
describe 'GET /users' do
|
|
499
|
-
it 'returns users' do
|
|
791
|
+
it 'returns users' do
|
|
792
|
+
# default :single mode
|
|
500
793
|
get '/users'
|
|
501
794
|
expect(response.status).to eq(200)
|
|
502
795
|
end
|
|
@@ -512,6 +805,7 @@ end
|
|
|
512
805
|
```
|
|
513
806
|
|
|
514
807
|
Result - both examples merged into `examples`:
|
|
808
|
+
|
|
515
809
|
```yaml
|
|
516
810
|
responses:
|
|
517
811
|
'200':
|
|
@@ -541,6 +835,7 @@ Even if you are not using `rspec` this gem might help you with its experimental
|
|
|
541
835
|
Example:
|
|
542
836
|
|
|
543
837
|
```rb
|
|
838
|
+
|
|
544
839
|
class TablesTest < ActionDispatch::IntegrationTest
|
|
545
840
|
openapi!
|
|
546
841
|
|
|
@@ -558,9 +853,11 @@ class TablesTest < ActionDispatch::IntegrationTest
|
|
|
558
853
|
end
|
|
559
854
|
```
|
|
560
855
|
|
|
561
|
-
It should work with both classes inheriting from `ActionDispatch::IntegrationTest` and with classes using `Rack::Test`
|
|
856
|
+
It should work with both classes inheriting from `ActionDispatch::IntegrationTest` and with classes using `Rack::Test`
|
|
857
|
+
directly, as long as you call `openapi!` in your test class.
|
|
562
858
|
|
|
563
|
-
Please note that not all features present in the rspec integration work with minitest (yet). For example, custom per
|
|
859
|
+
Please note that not all features present in the rspec integration work with minitest (yet). For example, custom per
|
|
860
|
+
test case metadata is not supported. A custom `description_builder` will not work either.
|
|
564
861
|
|
|
565
862
|
Run minitest with OPENAPI=1 to generate `doc/openapi.yaml` for your request specs.
|
|
566
863
|
|
|
@@ -579,15 +876,19 @@ Existing RSpec plugins which have OpenAPI integration:
|
|
|
579
876
|
## Acknowledgements
|
|
580
877
|
|
|
581
878
|
* Heavily inspired by [r7kamura/autodoc](https://github.com/r7kamura/autodoc)
|
|
582
|
-
* Orignally created by [k0kubun](https://github.com/k0kubun) and the ownership was transferred
|
|
583
|
-
|
|
879
|
+
* Orignally created by [k0kubun](https://github.com/k0kubun) and the ownership was transferred
|
|
880
|
+
to [exoego](https://github.com/exoego) in 2022-11-29.
|
|
584
881
|
|
|
585
882
|
## Releasing
|
|
586
883
|
|
|
587
|
-
1. Ensure RubyGems trusted publishing is configured for this repo and gem ownership (
|
|
588
|
-
|
|
884
|
+
1. Ensure RubyGems trusted publishing is configured for this repo and gem ownership (
|
|
885
|
+
see [Trusted publishing](https://guides.rubygems.org/trusted-publishing/)).
|
|
886
|
+
2. In GitHub Actions, run the `prepare release` workflow manually. It bumps `lib/rspec/openapi/version.rb`, pushes
|
|
887
|
+
`release/v<version>` to origin, and opens a PR.
|
|
589
888
|
3. Review and merge the release PR into the default branch.
|
|
590
|
-
4. Create and push a tag `v<version>` on the merged commit (via the GitHub UI or
|
|
889
|
+
4. Create and push a tag `v<version>` on the merged commit (via the GitHub UI or
|
|
890
|
+
`git tag v<version>; git push origin v<version>`). Tag creation triggers the `Publish to RubyGems` workflow, which
|
|
891
|
+
publishes the gem and creates the GitHub release notes automatically.
|
|
591
892
|
|
|
592
893
|
## License
|
|
593
894
|
|
|
@@ -54,8 +54,11 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
54
54
|
|
|
55
55
|
return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless route.routable?
|
|
56
56
|
|
|
57
|
-
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
58
|
-
|
|
57
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
58
|
+
request_example_mode, response_example_mode,
|
|
59
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
60
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
61
|
+
request_hybrid_additional_properties = SharedExtractor.attributes(example)
|
|
59
62
|
|
|
60
63
|
path = request.path
|
|
61
64
|
|
|
@@ -80,11 +83,16 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
80
83
|
security,
|
|
81
84
|
deprecated,
|
|
82
85
|
formats,
|
|
83
|
-
|
|
86
|
+
request_example_mode,
|
|
87
|
+
response_example_mode,
|
|
84
88
|
example_key,
|
|
85
89
|
example_name,
|
|
86
90
|
response_enum,
|
|
87
91
|
request_enum,
|
|
92
|
+
response_additional_properties,
|
|
93
|
+
request_additional_properties,
|
|
94
|
+
response_hybrid_additional_properties,
|
|
95
|
+
request_hybrid_additional_properties,
|
|
88
96
|
]
|
|
89
97
|
end
|
|
90
98
|
|
|
@@ -6,8 +6,11 @@ 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
|
-
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
10
|
-
|
|
9
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
10
|
+
request_example_mode, response_example_mode,
|
|
11
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
12
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
13
|
+
request_hybrid_additional_properties = SharedExtractor.attributes(example)
|
|
11
14
|
|
|
12
15
|
raw_path_params = request.path_parameters
|
|
13
16
|
path = request.path
|
|
@@ -24,11 +27,16 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
|
|
|
24
27
|
security,
|
|
25
28
|
deprecated,
|
|
26
29
|
formats,
|
|
27
|
-
|
|
30
|
+
request_example_mode,
|
|
31
|
+
response_example_mode,
|
|
28
32
|
example_key,
|
|
29
33
|
example_name,
|
|
30
34
|
response_enum,
|
|
31
35
|
request_enum,
|
|
36
|
+
response_additional_properties,
|
|
37
|
+
request_additional_properties,
|
|
38
|
+
response_hybrid_additional_properties,
|
|
39
|
+
request_hybrid_additional_properties,
|
|
32
40
|
]
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -16,8 +16,11 @@ 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
|
-
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
20
|
-
|
|
19
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
20
|
+
request_example_mode, response_example_mode,
|
|
21
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
22
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
23
|
+
request_hybrid_additional_properties = SharedExtractor.attributes(example)
|
|
21
24
|
|
|
22
25
|
raw_path_params = request.path_parameters
|
|
23
26
|
|
|
@@ -40,16 +43,26 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
|
|
|
40
43
|
security,
|
|
41
44
|
deprecated,
|
|
42
45
|
formats,
|
|
43
|
-
|
|
46
|
+
request_example_mode,
|
|
47
|
+
response_example_mode,
|
|
44
48
|
example_key,
|
|
45
49
|
example_name,
|
|
46
50
|
response_enum,
|
|
47
51
|
request_enum,
|
|
52
|
+
response_additional_properties,
|
|
53
|
+
request_additional_properties,
|
|
54
|
+
response_hybrid_additional_properties,
|
|
55
|
+
request_hybrid_additional_properties,
|
|
48
56
|
]
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
# @param [RSpec::ExampleGroups::*] context
|
|
52
60
|
def request_response(context)
|
|
61
|
+
# Read @integration_session directly so user-defined let(:request)/let(:response)
|
|
62
|
+
# don't shadow the real ActionDispatch objects we need for OpenAPI extraction.
|
|
63
|
+
session = context.instance_variable_get(:@integration_session)
|
|
64
|
+
return [session.request, session.response] if session
|
|
65
|
+
|
|
53
66
|
[context.request, context.response]
|
|
54
67
|
end
|
|
55
68
|
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
class SharedExtractor
|
|
5
5
|
VALID_EXAMPLE_MODES = %i[none single multiple].freeze
|
|
6
6
|
|
|
7
|
+
EXAMPLE_MODE_MULTIPLE_SHORTHAND_WARNING = <<~MSG.tr("\n", ' ').strip.freeze
|
|
8
|
+
[rspec-openapi] DEPRECATION: example_mode: :multiple currently means
|
|
9
|
+
{ request: :single, response: :multiple }. A future major version will
|
|
10
|
+
change it to { request: :multiple, response: :multiple } (both sides
|
|
11
|
+
multi). Specify the hash form explicitly to lock in current behavior or
|
|
12
|
+
opt in early.
|
|
13
|
+
MSG
|
|
14
|
+
|
|
7
15
|
def self.attributes(example)
|
|
8
16
|
metadata = merge_openapi_metadata(example.metadata)
|
|
9
17
|
summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
|
|
@@ -14,7 +22,7 @@ class SharedExtractor
|
|
|
14
22
|
security = metadata[:security]
|
|
15
23
|
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
|
|
16
24
|
deprecated = metadata[:deprecated]
|
|
17
|
-
|
|
25
|
+
request_example_mode, response_example_mode = normalize_example_mode(metadata[:example_mode], example)
|
|
18
26
|
example_name = metadata[:example_name] || RSpec::OpenAPI.example_name_builder.call(example)
|
|
19
27
|
raw_example_key = metadata[:example_key] || example_name
|
|
20
28
|
example_key = RSpec::OpenAPI::ExampleKey.normalize(raw_example_key)
|
|
@@ -25,8 +33,29 @@ class SharedExtractor
|
|
|
25
33
|
response_enum = normalize_enum(metadata[:response_enum]) || base_enum
|
|
26
34
|
request_enum = normalize_enum(metadata[:request_enum]) || base_enum
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
response_additional_properties, request_additional_properties = resolve_additional_properties(metadata)
|
|
37
|
+
response_hybrid_additional_properties, request_hybrid_additional_properties =
|
|
38
|
+
resolve_hybrid_additional_properties(metadata)
|
|
39
|
+
|
|
40
|
+
[summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
41
|
+
request_example_mode, response_example_mode,
|
|
42
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
43
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
44
|
+
request_hybrid_additional_properties,]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.resolve_additional_properties(metadata)
|
|
48
|
+
base = normalize_additional_properties(metadata[:additional_properties])
|
|
49
|
+
response = normalize_additional_properties(metadata[:response_additional_properties]) || base
|
|
50
|
+
request = normalize_additional_properties(metadata[:request_additional_properties]) || base
|
|
51
|
+
[response, request]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.resolve_hybrid_additional_properties(metadata)
|
|
55
|
+
base = normalize_additional_properties(metadata[:hybrid_additional_properties])
|
|
56
|
+
response = normalize_additional_properties(metadata[:response_hybrid_additional_properties]) || base
|
|
57
|
+
request = normalize_additional_properties(metadata[:request_hybrid_additional_properties]) || base
|
|
58
|
+
[response, request]
|
|
30
59
|
end
|
|
31
60
|
|
|
32
61
|
def self.normalize_enum(enum_hash)
|
|
@@ -36,6 +65,14 @@ class SharedExtractor
|
|
|
36
65
|
enum_hash.transform_keys(&:to_s)
|
|
37
66
|
end
|
|
38
67
|
|
|
68
|
+
def self.normalize_additional_properties(hash)
|
|
69
|
+
return nil if hash.nil? || hash.empty?
|
|
70
|
+
|
|
71
|
+
hash.each_with_object({}) do |(path, schema), result|
|
|
72
|
+
result[path.to_s] = RSpec::OpenAPI::KeyTransformer.symbolize(schema)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
39
76
|
def self.merge_openapi_metadata(metadata)
|
|
40
77
|
collect_openapi_metadata(metadata).reduce({}, &:merge)
|
|
41
78
|
end
|
|
@@ -54,9 +91,33 @@ class SharedExtractor
|
|
|
54
91
|
end
|
|
55
92
|
end
|
|
56
93
|
|
|
94
|
+
# Returns [request_mode, response_mode]. Accepts either a bare Symbol/String
|
|
95
|
+
# (applied to both sides, except :multiple which is treated as a backward-compat
|
|
96
|
+
# shorthand for { request: :single, response: :multiple } and emits a one-time
|
|
97
|
+
# deprecation warning) or a Hash with :request / :response keys.
|
|
57
98
|
def self.normalize_example_mode(value, example = nil)
|
|
58
|
-
return
|
|
99
|
+
return %i[single single] if value.nil?
|
|
59
100
|
|
|
101
|
+
case value
|
|
102
|
+
when Hash
|
|
103
|
+
[
|
|
104
|
+
normalize_example_mode_hash_value(value, :request, example),
|
|
105
|
+
normalize_example_mode_hash_value(value, :response, example),
|
|
106
|
+
]
|
|
107
|
+
when Symbol, String
|
|
108
|
+
mode = coerce_example_mode_value(value, example)
|
|
109
|
+
if mode == :multiple
|
|
110
|
+
warn_example_mode_multiple_shorthand
|
|
111
|
+
%i[single multiple]
|
|
112
|
+
else
|
|
113
|
+
[mode, mode]
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
raise ArgumentError, example_mode_error(value, example)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.coerce_example_mode_value(value, example)
|
|
60
121
|
raise ArgumentError, example_mode_error(value, example) unless value.is_a?(String) || value.is_a?(Symbol)
|
|
61
122
|
|
|
62
123
|
mode = value.to_s.strip.downcase.to_sym
|
|
@@ -65,9 +126,25 @@ class SharedExtractor
|
|
|
65
126
|
raise ArgumentError, example_mode_error(value, example)
|
|
66
127
|
end
|
|
67
128
|
|
|
129
|
+
def self.normalize_example_mode_hash_value(hash, key, example)
|
|
130
|
+
raw = hash[key]
|
|
131
|
+
raw = hash[key.to_s] if raw.nil?
|
|
132
|
+
return :single if raw.nil?
|
|
133
|
+
|
|
134
|
+
coerce_example_mode_value(raw, example)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.warn_example_mode_multiple_shorthand
|
|
138
|
+
return if @warned_example_mode_multiple_shorthand
|
|
139
|
+
|
|
140
|
+
@warned_example_mode_multiple_shorthand = true
|
|
141
|
+
Kernel.warn(EXAMPLE_MODE_MULTIPLE_SHORTHAND_WARNING)
|
|
142
|
+
end
|
|
143
|
+
|
|
68
144
|
def self.example_mode_error(value, example)
|
|
69
145
|
context = example&.full_description
|
|
70
146
|
context = " (example: #{context})" if context
|
|
71
|
-
"example_mode must be
|
|
147
|
+
"example_mode must be a Symbol/String in #{VALID_EXAMPLE_MODES.inspect} " \
|
|
148
|
+
"or a Hash with :request/:response keys, got #{value.inspect}#{context}"
|
|
72
149
|
end
|
|
73
150
|
end
|
data/lib/rspec/openapi/record.rb
CHANGED
|
@@ -20,7 +20,8 @@ RSpec::OpenAPI::Record = Struct.new(
|
|
|
20
20
|
:security, # @param [Array] - [{securityScheme1: []}]
|
|
21
21
|
:deprecated, # @param [Boolean] - true
|
|
22
22
|
:example_enabled, # @param [Boolean] - true
|
|
23
|
-
:
|
|
23
|
+
:request_example_mode, # @param [Symbol] - :none | :single | :multiple
|
|
24
|
+
:response_example_mode, # @param [Symbol] - :none | :single | :multiple
|
|
24
25
|
:status, # @param [Integer] - 200
|
|
25
26
|
:response_body, # @param [Object] - {"status" => "ok"}
|
|
26
27
|
:response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
|
|
@@ -28,5 +29,9 @@ RSpec::OpenAPI::Record = Struct.new(
|
|
|
28
29
|
:response_content_disposition, # @param [String] - "inline"
|
|
29
30
|
:response_enum, # @param [Hash] - {"status" => ["active", "inactive"], "user.role" => ["admin", "user"]}
|
|
30
31
|
:request_enum, # @param [Hash] - {"type" => ["create", "update"]}
|
|
32
|
+
:response_additional_properties, # @param [Hash] - {"data" => { type: "boolean" }}
|
|
33
|
+
:request_additional_properties, # @param [Hash] - {"meta" => { type: "string" }}
|
|
34
|
+
:response_hybrid_additional_properties, # @param [Hash] - {"data" => { type: "string" }}
|
|
35
|
+
:request_hybrid_additional_properties, # @param [Hash] - {"meta" => { type: "string" }}
|
|
31
36
|
keyword_init: true,
|
|
32
37
|
)
|
|
@@ -13,8 +13,10 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
13
13
|
|
|
14
14
|
title = RSpec::OpenAPI.title.then { |t| t.is_a?(Proc) ? t.call(example) : t }
|
|
15
15
|
path, summary, tags, operation_id, required_request_params, raw_path_params,
|
|
16
|
-
description, security, deprecated, formats,
|
|
17
|
-
example_name, response_enum, request_enum
|
|
16
|
+
description, security, deprecated, formats, request_example_mode, response_example_mode,
|
|
17
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
18
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
19
|
+
request_hybrid_additional_properties = extractor.request_attributes(request, example)
|
|
18
20
|
|
|
19
21
|
return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }
|
|
20
22
|
|
|
@@ -43,11 +45,16 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
43
45
|
response_content_type: response.media_type,
|
|
44
46
|
response_content_disposition: response.header['Content-Disposition'],
|
|
45
47
|
example_enabled: RSpec::OpenAPI.enable_example,
|
|
46
|
-
|
|
48
|
+
request_example_mode: request_example_mode,
|
|
49
|
+
response_example_mode: response_example_mode,
|
|
47
50
|
example_key: example_key,
|
|
48
51
|
example_name: example_name,
|
|
49
52
|
response_enum: response_enum,
|
|
50
53
|
request_enum: request_enum,
|
|
54
|
+
response_additional_properties: response_additional_properties,
|
|
55
|
+
request_additional_properties: request_additional_properties,
|
|
56
|
+
response_hybrid_additional_properties: response_hybrid_additional_properties,
|
|
57
|
+
request_hybrid_additional_properties: request_hybrid_additional_properties,
|
|
51
58
|
).freeze
|
|
52
59
|
end
|
|
53
60
|
|
|
@@ -4,9 +4,15 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
4
4
|
# @param [RSpec::OpenAPI::Record] record
|
|
5
5
|
# @return [Hash]
|
|
6
6
|
def build(record)
|
|
7
|
-
response =
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
response = if record.response_example_mode == :none
|
|
8
|
+
# `:none` opts out of recording, so the description is provisional.
|
|
9
|
+
# Stash it under a fallback key; SchemaCleaner promotes it to
|
|
10
|
+
# `description` only if no documented test has set one. This makes
|
|
11
|
+
# the merge result independent of RSpec's random execution order.
|
|
12
|
+
{ _fallback_description: record.description }
|
|
13
|
+
else
|
|
14
|
+
{ description: record.description }
|
|
15
|
+
end
|
|
10
16
|
|
|
11
17
|
response_headers = build_response_headers(record)
|
|
12
18
|
response[:headers] = response_headers unless response_headers.empty?
|
|
@@ -52,7 +58,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
52
58
|
# If examples are globally disabled, always return schema-only content.
|
|
53
59
|
return { content_type => { schema: schema }.compact } unless example_enabled?(record)
|
|
54
60
|
|
|
55
|
-
case record.
|
|
61
|
+
case record.response_example_mode
|
|
56
62
|
when :none
|
|
57
63
|
# Only schema, no examples
|
|
58
64
|
{
|
|
@@ -74,8 +80,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
74
80
|
content_type => {
|
|
75
81
|
schema: schema,
|
|
76
82
|
example: response_example(record, disposition: disposition),
|
|
77
|
-
|
|
78
|
-
_example_summary: example_summary(record),
|
|
83
|
+
**example_metadata(record),
|
|
79
84
|
}.compact,
|
|
80
85
|
}
|
|
81
86
|
end
|
|
@@ -93,13 +98,21 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
93
98
|
end
|
|
94
99
|
|
|
95
100
|
def build_example_object(record, disposition:)
|
|
101
|
+
build_named_example(record, response_example(record, disposition: disposition))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_named_example(record, value)
|
|
96
105
|
summary = example_summary(record)
|
|
97
106
|
example = {}
|
|
98
107
|
example[:summary] = summary if summary
|
|
99
|
-
example[:value] =
|
|
108
|
+
example[:value] = value
|
|
100
109
|
example
|
|
101
110
|
end
|
|
102
111
|
|
|
112
|
+
def example_metadata(record)
|
|
113
|
+
{ _example_key: record.example_key, _example_summary: example_summary(record) }
|
|
114
|
+
end
|
|
115
|
+
|
|
103
116
|
def example_summary(record)
|
|
104
117
|
return nil unless example_summary_enabled?
|
|
105
118
|
return nil if record.example_name.nil? || record.example_name.empty?
|
|
@@ -191,16 +204,33 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
191
204
|
|
|
192
205
|
def build_request_body(record)
|
|
193
206
|
return nil if record.request_content_type.nil?
|
|
194
|
-
return nil if record.status >= 400
|
|
207
|
+
return nil if record.status >= 400 && record.request_example_mode != :multiple
|
|
195
208
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
209
|
+
content_type = normalize_content_type(record.request_content_type)
|
|
210
|
+
schema = build_property(record.request_params, record: record, context: :request)
|
|
211
|
+
|
|
212
|
+
return { content: { content_type => { schema: schema }.compact } } unless example_enabled?(record)
|
|
213
|
+
|
|
214
|
+
example = build_example(record.request_params)
|
|
215
|
+
|
|
216
|
+
body =
|
|
217
|
+
case record.request_example_mode
|
|
218
|
+
when :none
|
|
219
|
+
{ schema: schema }
|
|
220
|
+
when :multiple
|
|
221
|
+
{
|
|
222
|
+
schema: schema,
|
|
223
|
+
examples: { record.example_key => build_named_example(record, example) },
|
|
224
|
+
}
|
|
225
|
+
else # :single (default)
|
|
226
|
+
{
|
|
227
|
+
schema: schema,
|
|
228
|
+
example: example,
|
|
229
|
+
**example_metadata(record),
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
{ content: { content_type => body.compact } }
|
|
204
234
|
end
|
|
205
235
|
|
|
206
236
|
def build_property(value, disposition: nil, key: nil, record: nil, path: nil, context: nil)
|
|
@@ -217,13 +247,32 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
217
247
|
build_array_items_schema(value, record: record, path: path, context: context)
|
|
218
248
|
end
|
|
219
249
|
when Hash
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
250
|
+
override = infer_additional_properties(path, record, context)
|
|
251
|
+
hybrid_override = infer_hybrid_additional_properties(path, record, context)
|
|
252
|
+
if override.is_a?(Hash) && !override.empty?
|
|
253
|
+
# Schema override: the object's keys are dynamic — replace captured
|
|
254
|
+
# `properties` / `required` with the supplied dictionary value schema.
|
|
255
|
+
property[:additionalProperties] = override
|
|
256
|
+
else
|
|
257
|
+
property[:properties] = {}.tap do |properties|
|
|
258
|
+
value.each do |k, v|
|
|
259
|
+
child_path = path ? "#{path}.#{k}" : k.to_s
|
|
260
|
+
properties[k] = build_property(v, record: record, key: k, path: child_path, context: context)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
property = enrich_with_required_keys(property)
|
|
264
|
+
# Hybrid: keep the observed `properties` / `required` and attach
|
|
265
|
+
# `additionalProperties` alongside.
|
|
266
|
+
# - Boolean values are constraints (`false` forbids extras, `true`
|
|
267
|
+
# explicitly allows them).
|
|
268
|
+
# - Hash schema values come from the dedicated `hybrid_additional_properties`
|
|
269
|
+
# metadata, expressing "known keys + extras of this type".
|
|
270
|
+
if override == true || override == false
|
|
271
|
+
property[:additionalProperties] = override
|
|
272
|
+
elsif hybrid_override.is_a?(Hash) && !hybrid_override.empty?
|
|
273
|
+
property[:additionalProperties] = hybrid_override
|
|
224
274
|
end
|
|
225
275
|
end
|
|
226
|
-
property = enrich_with_required_keys(property)
|
|
227
276
|
end
|
|
228
277
|
property
|
|
229
278
|
end
|
|
@@ -274,6 +323,36 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
274
323
|
enum_hash[path.to_s]
|
|
275
324
|
end
|
|
276
325
|
|
|
326
|
+
def infer_additional_properties(path, record, context)
|
|
327
|
+
return nil unless record
|
|
328
|
+
|
|
329
|
+
overrides = if context == :request
|
|
330
|
+
record.request_additional_properties
|
|
331
|
+
else
|
|
332
|
+
record.response_additional_properties
|
|
333
|
+
end
|
|
334
|
+
return nil unless overrides
|
|
335
|
+
|
|
336
|
+
# path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
|
|
337
|
+
# Use `key?` so a literal `false` override is distinguishable from "no override".
|
|
338
|
+
return nil unless overrides.key?(path.to_s)
|
|
339
|
+
|
|
340
|
+
overrides[path.to_s]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def infer_hybrid_additional_properties(path, record, context)
|
|
344
|
+
return nil unless record
|
|
345
|
+
|
|
346
|
+
overrides = if context == :request
|
|
347
|
+
record.request_hybrid_additional_properties
|
|
348
|
+
else
|
|
349
|
+
record.response_hybrid_additional_properties
|
|
350
|
+
end
|
|
351
|
+
return nil unless overrides
|
|
352
|
+
|
|
353
|
+
overrides[path.to_s]
|
|
354
|
+
end
|
|
355
|
+
|
|
277
356
|
# Convert an always-String param to an appropriate type
|
|
278
357
|
def try_cast(value)
|
|
279
358
|
Integer(value)
|
|
@@ -86,6 +86,9 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
86
86
|
hash.delete(:_example_key)
|
|
87
87
|
hash.delete(:_example_summary)
|
|
88
88
|
hash.delete(:_example_name)
|
|
89
|
+
if (fallback = hash.delete(:_fallback_description))
|
|
90
|
+
hash[:description] ||= fallback
|
|
91
|
+
end
|
|
89
92
|
|
|
90
93
|
hash.each_value do |value|
|
|
91
94
|
case value
|
|
@@ -25,6 +25,17 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
|
|
|
25
25
|
return base
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# When the new spec converts an object to a dictionary (introduces
|
|
29
|
+
# `additionalProperties` on a node that previously had `properties` /
|
|
30
|
+
# `required`), drop the stale fields so the merged result reflects the
|
|
31
|
+
# new intent. We only prune when base does not already declare
|
|
32
|
+
# `additionalProperties`, to preserve manual edits that intentionally
|
|
33
|
+
# combine fixed and dynamic keys.
|
|
34
|
+
if spec.is_a?(Hash) && spec.key?(:additionalProperties) && !base.key?(:additionalProperties)
|
|
35
|
+
base.delete(:properties)
|
|
36
|
+
base.delete(:required)
|
|
37
|
+
end
|
|
38
|
+
|
|
28
39
|
spec.each do |key, value|
|
|
29
40
|
if base[key].is_a?(Hash) && value.is_a?(Hash)
|
|
30
41
|
# Handle example/examples conflict - convert to examples when mixed
|
data/redocly.yaml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Config for `redocly lint` used by .github/workflows/validate-openapi.yml
|
|
2
|
+
# to validate test-fixture OpenAPI documents shipped under spec/apps/.
|
|
3
|
+
#
|
|
4
|
+
# spec/apps/rails/doc/smart/openapi.yaml is intentionally excluded:
|
|
5
|
+
# it is the input fixture for the smart-merge feature test (it contains
|
|
6
|
+
# unresolved $refs by design), not a complete API description.
|
|
7
|
+
extends:
|
|
8
|
+
- minimal
|
|
9
|
+
apis:
|
|
10
|
+
rails-rspec-yaml:
|
|
11
|
+
root: spec/apps/rails/doc/rspec_openapi.yaml
|
|
12
|
+
rails-rspec-json:
|
|
13
|
+
root: spec/apps/rails/doc/rspec_openapi.json
|
|
14
|
+
rails-minitest-yaml:
|
|
15
|
+
root: spec/apps/rails/doc/minitest_openapi.yaml
|
|
16
|
+
rails-minitest-json:
|
|
17
|
+
root: spec/apps/rails/doc/minitest_openapi.json
|
|
18
|
+
rails-smart-expected:
|
|
19
|
+
root: spec/apps/rails/doc/smart/expected.yaml
|
|
20
|
+
roda-rspec-yaml:
|
|
21
|
+
root: spec/apps/roda/doc/rspec_openapi.yaml
|
|
22
|
+
roda-rspec-json:
|
|
23
|
+
root: spec/apps/roda/doc/rspec_openapi.json
|
|
24
|
+
roda-minitest-yaml:
|
|
25
|
+
root: spec/apps/roda/doc/minitest_openapi.yaml
|
|
26
|
+
roda-minitest-json:
|
|
27
|
+
root: spec/apps/roda/doc/minitest_openapi.json
|
|
28
|
+
hanami-yaml:
|
|
29
|
+
root: spec/apps/hanami/doc/openapi.yaml
|
|
30
|
+
hanami-json:
|
|
31
|
+
root: spec/apps/hanami/doc/openapi.json
|
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.26.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Takashi Kokubun
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- ".github/workflows/publish.yml"
|
|
68
68
|
- ".github/workflows/rubocop.yml"
|
|
69
69
|
- ".github/workflows/test.yml"
|
|
70
|
+
- ".github/workflows/validate-openapi.yml"
|
|
70
71
|
- ".gitignore"
|
|
71
72
|
- ".rspec"
|
|
72
73
|
- ".rubocop.yml"
|
|
@@ -102,6 +103,7 @@ files:
|
|
|
102
103
|
- lib/rspec/openapi/schema_sorter.rb
|
|
103
104
|
- lib/rspec/openapi/shared_hooks.rb
|
|
104
105
|
- lib/rspec/openapi/version.rb
|
|
106
|
+
- redocly.yaml
|
|
105
107
|
- rspec-openapi.gemspec
|
|
106
108
|
- scripts/rspec
|
|
107
109
|
- scripts/rspec_with_simplecov
|
|
@@ -112,7 +114,7 @@ licenses:
|
|
|
112
114
|
metadata:
|
|
113
115
|
homepage_uri: https://github.com/exoego/rspec-openapi
|
|
114
116
|
source_code_uri: https://github.com/exoego/rspec-openapi
|
|
115
|
-
changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.
|
|
117
|
+
changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.26.0
|
|
116
118
|
rubygems_mfa_required: 'true'
|
|
117
119
|
rdoc_options: []
|
|
118
120
|
require_paths:
|