rspec-openapi 0.34.0 → 0.36.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 +101 -0
- data/lib/rspec/openapi/components_updater.rb +49 -24
- data/lib/rspec/openapi/extractors/shared_extractor.rb +42 -6
- data/lib/rspec/openapi/hash_helper.rb +125 -21
- data/lib/rspec/openapi/key_transformer.rb +28 -13
- data/lib/rspec/openapi/minitest_hooks.rb +2 -1
- data/lib/rspec/openapi/nullable_converter.rb +24 -19
- data/lib/rspec/openapi/parallel_records.rb +59 -4
- data/lib/rspec/openapi/partial_run.rb +84 -0
- data/lib/rspec/openapi/record.rb +2 -0
- data/lib/rspec/openapi/result_recorder.rb +24 -10
- data/lib/rspec/openapi/rspec_hooks.rb +11 -3
- data/lib/rspec/openapi/schema_builder/build_context.rb +20 -6
- data/lib/rspec/openapi/schema_builder/variation_merger.rb +58 -20
- data/lib/rspec/openapi/schema_builder.rb +106 -64
- data/lib/rspec/openapi/schema_cleaner.rb +108 -62
- data/lib/rspec/openapi/schema_file.rb +27 -20
- data/lib/rspec/openapi/schema_merger.rb +142 -64
- data/lib/rspec/openapi/schema_sorter.rb +25 -4
- data/lib/rspec/openapi/version.rb +1 -1
- data/lib/rspec/openapi/yaml_dumper.rb +103 -0
- data/lib/rspec/openapi/yaml_loader.rb +118 -0
- data/lib/rspec/openapi.rb +14 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a9ca3c8b1eadd4f1b45f1a3c6fcfe533ac85e2eb836ff2dd849fec8f22f7a42d
|
|
4
|
+
data.tar.gz: 43ef531b0a5141c1d95e075fda5a5020b0c1792c73f4df686ce152801e7c29e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81ce9e7146b012243391d4c15b19e18d836350aec0a7547ad8d8d629acfa424b35ec7a467b32f033f812bc88cc8ceea4b740cf13703180ee6e7054ea6e4993da
|
|
7
|
+
data.tar.gz: a3312e057a52f28090989a26c701aab02b0b5850270d73e04e30b963dfcb5e0bb612a48c2b2fcca275ebfebdbb9df52e87378d35675336ccd71a5af920a02709
|
data/README.md
CHANGED
|
@@ -228,6 +228,10 @@ RSpec::OpenAPI.tags_builder = -> (example) { example.metadata.dig(:example_group
|
|
|
228
228
|
# This example assigns 'date-time' format to properties with names ending in '_at'
|
|
229
229
|
RSpec::OpenAPI.formats_builder = ->(_example, key) { key.end_with?('_at') ? 'date-time' : nil }
|
|
230
230
|
|
|
231
|
+
# Mark server-assigned properties as `readOnly`. Off by default.
|
|
232
|
+
# A builder of your own gets the RSpec example and a property name. See "Can I mark `id` and timestamps as `readOnly`?" below.
|
|
233
|
+
RSpec::OpenAPI.read_only_builder = RSpec::OpenAPI::DEFAULT_READ_ONLY_BUILDER
|
|
234
|
+
|
|
231
235
|
# Change the example type(s) that will generate schema
|
|
232
236
|
RSpec::OpenAPI.example_types = %i[request]
|
|
233
237
|
|
|
@@ -242,6 +246,10 @@ RSpec::OpenAPI.ignored_path_params = %i[controller action format]
|
|
|
242
246
|
# String or Regexp is acceptable.
|
|
243
247
|
RSpec::OpenAPI.ignored_paths = ["/admin/full/path/", Regexp.new("^/_internal/")]
|
|
244
248
|
|
|
249
|
+
# Whether a run adds and updates without removing anything. `nil` (default) lets each run decide,
|
|
250
|
+
# see "Can I run a subset of specs?" below. `true` or `false` forces it, as does `OPENAPI_PARTIAL_UPDATE=1` or `=0`.
|
|
251
|
+
RSpec::OpenAPI.partial_update = nil
|
|
252
|
+
|
|
245
253
|
# Your custom post-processing hook (like unrandomizing IDs)
|
|
246
254
|
RSpec::OpenAPI.post_process_hook = -> (path, records, spec) do
|
|
247
255
|
RSpec::OpenAPI::HashHelper.matched_paths(spec, 'paths.*.*.responses.*.content.*.*.*.id').each do |paths|
|
|
@@ -396,11 +404,66 @@ components:
|
|
|
396
404
|
Note that automatic `schemas` update feature is still new and may not work in complex scenario.
|
|
397
405
|
If you find a room for improvement, open an issue.
|
|
398
406
|
|
|
407
|
+
### Can I mark `id` and timestamps as `readOnly`?
|
|
408
|
+
|
|
409
|
+
Yes, with `read_only_builder`. It is off by default.
|
|
410
|
+
|
|
411
|
+
```rb
|
|
412
|
+
RSpec::OpenAPI.read_only_builder = RSpec::OpenAPI::DEFAULT_READ_ONLY_BUILDER
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`DEFAULT_READ_ONLY_BUILDER` picks the names in `DEFAULT_READ_ONLY_KEYS`: `id`, `uuid`, `created_at`
|
|
416
|
+
and `updated_at`. Foreign keys such as `user_id` are not in it, since clients usually send them. A
|
|
417
|
+
builder of your own gets the RSpec example and a property name, and may return `true` for any name.
|
|
418
|
+
The names can also be listed per example, as whole names or patterns.
|
|
419
|
+
|
|
420
|
+
```rb
|
|
421
|
+
describe 'GET /posts', openapi: { read_only: ['id', /_count\z/] } do
|
|
422
|
+
# `read_only: false` turns it off for an example, and `read_only: true` hands it back to the builder.
|
|
423
|
+
end
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
A property the builder picks gets `readOnly: true` in response schemas. Keep in mind what that
|
|
427
|
+
means. A client should not send the property (OpenAPI 3.0), or the server may ignore or reject it
|
|
428
|
+
(3.1 and later). So a request that sends the property says the opposite, and the request wins.
|
|
429
|
+
That holds under `read_only: false` too, which only stops an example from marking.
|
|
430
|
+
In a schema shared by a request and a response through `$ref`, the component then keeps
|
|
431
|
+
`readOnly: false`. That explicit `false` lets partial runs (see "Can I run a subset of specs?"
|
|
432
|
+
below), such as `parallel_tests` workers, agree whichever order they run in. A full run decides
|
|
433
|
+
it anew. Inline request schemas never carry `readOnly`.
|
|
434
|
+
|
|
399
435
|
### How can I add information which can't be generated from RSpec?
|
|
400
436
|
|
|
401
437
|
rspec-openapi tries to preserve manual modifications as much as possible when generating specs.
|
|
402
438
|
You can directly edit `doc/openapi.yaml` as you like without spoiling the automatic generation capability.
|
|
403
439
|
|
|
440
|
+
### Can I run a subset of specs?
|
|
441
|
+
|
|
442
|
+
Yes. A run over part of the suite adds and updates what it records and removes nothing.
|
|
443
|
+
It prints a notice when it updates the file this way.
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
$ OPENAPI=1 bundle exec rspec spec/requests/tables_spec.rb
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
A run counts as partial in two cases.
|
|
450
|
+
|
|
451
|
+
- The examples are narrowed, by `-e`, `--tag`, a line number, `--only-failures` or a leftover `fit`.
|
|
452
|
+
An exclusion such as `--tag ~slow` does not count.
|
|
453
|
+
- A spec file next to one that recorded a request was left out. So `rspec spec/requests/tables_spec.rb`
|
|
454
|
+
is partial, while `rspec spec/requests` and `rspec spec/requests/*_spec.rb` are not.
|
|
455
|
+
|
|
456
|
+
Every other run also removes the paths, operations, parameters and properties it did not record.
|
|
457
|
+
This is how removed endpoints disappear from the file, so run all specs for that.
|
|
458
|
+
|
|
459
|
+
A `parallel_tests` worker sees only part of the files, so it never removes anything, whichever files it got.
|
|
460
|
+
rspec-openapi knows a worker by the `TEST_ENV_NUMBER` variable that `parallel_tests` sets.
|
|
461
|
+
Run the suite in one process when stale entries should go.
|
|
462
|
+
|
|
463
|
+
Set `RSpec::OpenAPI.partial_update` to `true` or `false`, or `OPENAPI_PARTIAL_UPDATE=1` or `=0`, to force one mode.
|
|
464
|
+
Minitest has no run information to decide from. It removes nothing in a `parallel_tests` worker or when the
|
|
465
|
+
option is `true`, and removes stale entries otherwise.
|
|
466
|
+
|
|
404
467
|
### Can I exclude specific specs from OpenAPI generation?
|
|
405
468
|
|
|
406
469
|
Yes, you can specify `openapi: false` to disable the automatic generation.
|
|
@@ -419,6 +482,44 @@ RSpec.describe '/resources', type: :request do
|
|
|
419
482
|
end
|
|
420
483
|
```
|
|
421
484
|
|
|
485
|
+
### How can I keep the generated file stable across runs?
|
|
486
|
+
|
|
487
|
+
rspec-openapi sorts `paths`, HTTP methods, response status codes, content types and the names of `examples`.
|
|
488
|
+
So the structure of the file does not depend on the order in which RSpec runs your tests.
|
|
489
|
+
|
|
490
|
+
The values inside `example` are different. They are copied from the real requests and responses
|
|
491
|
+
in your tests. Anything random in your app or test data shows up as a diff on every run.
|
|
492
|
+
Common sources are Faker, `SecureRandom`, `Time.now` and auto-increment IDs.
|
|
493
|
+
|
|
494
|
+
One option is to make the values stable in your test setup. This is plain RSpec configuration
|
|
495
|
+
and is not specific to rspec-openapi. For example:
|
|
496
|
+
|
|
497
|
+
```rb
|
|
498
|
+
if ENV['OPENAPI']
|
|
499
|
+
seed = ENV.fetch('OPENAPI_SEED', 1234).to_i
|
|
500
|
+
srand(seed)
|
|
501
|
+
Faker::Config.random = Random.new(seed) if defined?(Faker)
|
|
502
|
+
|
|
503
|
+
RSpec.configure do |config|
|
|
504
|
+
config.include ActiveSupport::Testing::TimeHelpers
|
|
505
|
+
|
|
506
|
+
config.around do |example|
|
|
507
|
+
travel_to(Time.zone.local(2024, 1, 1, 12, 0, 0)) { example.run }
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
The other option is to keep the random values out of the file. Set `RSpec::OpenAPI.enable_example = false`
|
|
514
|
+
to drop `example` everywhere, use `example_mode: :none` on the noisy tests only, or rewrite the values
|
|
515
|
+
in `RSpec::OpenAPI.post_process_hook`. See [Configuration](#configuration) for both.
|
|
516
|
+
|
|
517
|
+
One more thing to know. When several tests hit the same endpoint in the default `:single` mode,
|
|
518
|
+
the `example` comes from the test that ran last. With `config.order = :random` the winner changes
|
|
519
|
+
from run to run. Run with `--order defined` while generating the file, or switch those tests to
|
|
520
|
+
`example_mode: :multiple` so that each test writes its own named example.
|
|
521
|
+
See [Multiple Examples Mode](#multiple-examples-mode).
|
|
522
|
+
|
|
422
523
|
## Customizations
|
|
423
524
|
|
|
424
525
|
Some examples' attributes can be overwritten via RSpec metadata options. Example:
|
|
@@ -7,7 +7,8 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
|
|
|
7
7
|
|
|
8
8
|
# @param [Hash] base
|
|
9
9
|
# @param [Hash] fresh
|
|
10
|
-
|
|
10
|
+
# @param [Boolean] partial add to the schemas without removing anything
|
|
11
|
+
def update!(base, fresh, partial: false)
|
|
11
12
|
# Top-level schema: Used as the body of request or response
|
|
12
13
|
top_level_refs = paths_to_top_level_refs(base)
|
|
13
14
|
fresh_schemas = build_fresh_schemas(top_level_refs, base, fresh)
|
|
@@ -22,8 +23,9 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
|
|
|
22
23
|
|
|
23
24
|
return if fresh_schemas.empty?
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
RSpec::OpenAPI::
|
|
26
|
+
fresh_components = { components: { schemas: fresh_schemas } }
|
|
27
|
+
RSpec::OpenAPI::SchemaMerger.merge_normalized!(base, fresh_components)
|
|
28
|
+
RSpec::OpenAPI::SchemaCleaner.cleanup_components_schemas!(base, fresh_components) unless partial
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
private
|
|
@@ -52,37 +54,65 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
|
|
|
52
54
|
# relative path in the raw document instead.
|
|
53
55
|
def apply_inline_nested_refs!(fresh_schemas, base, fresh)
|
|
54
56
|
find_inline_nested_refs(base, fresh_schemas.keys).each do |paths|
|
|
55
|
-
nested_schema = fresh
|
|
57
|
+
nested_schema = dig_like(fresh, base, paths[0..-2])
|
|
56
58
|
next if nested_schema.nil?
|
|
57
59
|
|
|
58
60
|
merge_fresh_schema!(fresh_schemas, base, paths, nested_schema)
|
|
59
61
|
end
|
|
60
62
|
end
|
|
61
63
|
|
|
64
|
+
# Digs fresh along a path of base. This run may have recorded one shape of a
|
|
65
|
+
# oneOf only, or its variants in another order, so at a variant index the step
|
|
66
|
+
# picks the fresh variant of the type base has there.
|
|
67
|
+
def dig_like(fresh, base, paths)
|
|
68
|
+
node = fresh
|
|
69
|
+
paths.each_with_index do |step, index|
|
|
70
|
+
node = if index.positive? && paths[index - 1] == :oneOf
|
|
71
|
+
RSpec::OpenAPI::HashHelper.variant_of_type(node, base.dig(*paths.take(index + 1), :type))
|
|
72
|
+
elsif step != :oneOf
|
|
73
|
+
node[step]
|
|
74
|
+
else
|
|
75
|
+
node
|
|
76
|
+
end
|
|
77
|
+
return nil unless node.is_a?(Hash)
|
|
78
|
+
end
|
|
79
|
+
node
|
|
80
|
+
end
|
|
81
|
+
|
|
62
82
|
def merge_fresh_schema!(fresh_schemas, base, paths, nested_schema)
|
|
63
|
-
schema_name = extract_schema_name(base.dig(*paths))
|
|
64
|
-
fresh_schemas
|
|
65
|
-
RSpec::OpenAPI::SchemaMerger.merge_normalized!(fresh_schemas[schema_name], nested_schema)
|
|
83
|
+
schema_name = extract_schema_name(base.dig(*paths))
|
|
84
|
+
merge_schema!(fresh_schemas, schema_name, nested_schema)
|
|
66
85
|
end
|
|
67
86
|
|
|
68
87
|
def build_fresh_schemas(references, base, fresh)
|
|
69
|
-
references.
|
|
88
|
+
references.each_with_object({}) do |paths, fresh_schemas|
|
|
70
89
|
ref_link = dig_schema(base, paths)[:$ref]
|
|
71
90
|
schema_name = extract_schema_name(ref_link)
|
|
72
91
|
schema_body = dig_schema(fresh, paths.grep_v(Integer))
|
|
92
|
+
# Nothing was recorded for this body, so there is nothing to fill the
|
|
93
|
+
# schema from. A partial run leaves such operations in base, and so does
|
|
94
|
+
# a status code no example produces any more.
|
|
95
|
+
next if schema_body.nil?
|
|
73
96
|
|
|
74
|
-
|
|
97
|
+
merge_schema!(fresh_schemas, schema_name, schema_body)
|
|
75
98
|
end
|
|
76
99
|
end
|
|
77
100
|
|
|
101
|
+
# Under a `schemas` key the merger knows it merges schemas, so two shapes of
|
|
102
|
+
# one component become a oneOf like they would inline.
|
|
103
|
+
def merge_schema!(fresh_schemas, schema_name, schema)
|
|
104
|
+
RSpec::OpenAPI::SchemaMerger.merge_normalized!({ schemas: fresh_schemas }, { schemas: { schema_name => schema } })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The paths come from matched_paths, so their segments are symbols and the
|
|
108
|
+
# indexes of oneOf variants. A path to a media type ends in the content
|
|
109
|
+
# type, one to a variant in its index under the oneOf of the schema.
|
|
78
110
|
def dig_schema(obj, paths)
|
|
79
|
-
|
|
80
|
-
paths
|
|
81
|
-
item_schema = obj.dig(*paths, :schema, :items)
|
|
82
|
-
object_schema = obj.dig(*paths, :schema)
|
|
83
|
-
one_of_schema = obj.dig(*paths.take(paths.size - 1), :schema, :oneOf, paths.last)
|
|
111
|
+
media_type = obj.dig(*paths)
|
|
112
|
+
return obj.dig(*paths[0...-1], :schema, :oneOf, paths.last) unless media_type.is_a?(Hash)
|
|
84
113
|
|
|
85
|
-
|
|
114
|
+
schema = media_type[:schema]
|
|
115
|
+
(schema.is_a?(Hash) && schema[:items]) || schema
|
|
86
116
|
end
|
|
87
117
|
|
|
88
118
|
def paths_to_top_level_refs(base)
|
|
@@ -97,11 +127,9 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
|
|
|
97
127
|
end
|
|
98
128
|
|
|
99
129
|
def find_non_top_level_nested_refs(base, generated_names)
|
|
100
|
-
nested_refs =
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
*RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'oneOf.*.$ref'),
|
|
104
|
-
]
|
|
130
|
+
nested_refs = RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(
|
|
131
|
+
base, 'components.schemas', 'properties.*.$ref', 'properties.*.items.$ref', 'oneOf.*.$ref',
|
|
132
|
+
)
|
|
105
133
|
reject_already_generated_refs(nested_refs, base, generated_names)
|
|
106
134
|
end
|
|
107
135
|
|
|
@@ -113,10 +141,7 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
|
|
|
113
141
|
'paths.*.*.responses.*.content.application/json.schema',
|
|
114
142
|
]
|
|
115
143
|
nested_refs = roots.flat_map do |root|
|
|
116
|
-
|
|
117
|
-
*RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, root, 'properties.*.$ref'),
|
|
118
|
-
*RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, root, 'properties.*.items.$ref'),
|
|
119
|
-
]
|
|
144
|
+
RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, root, 'properties.*.$ref', 'properties.*.items.$ref')
|
|
120
145
|
end
|
|
121
146
|
reject_already_generated_refs(nested_refs, base, generated_names)
|
|
122
147
|
end
|
|
@@ -20,7 +20,6 @@ class SharedExtractor
|
|
|
20
20
|
|
|
21
21
|
def self.attributes(example)
|
|
22
22
|
metadata = merge_openapi_metadata(example.metadata)
|
|
23
|
-
request_example_mode, response_example_mode = normalize_example_mode(metadata[:example_mode], example)
|
|
24
23
|
response_additional_properties, request_additional_properties = resolve_additional_properties(metadata)
|
|
25
24
|
response_hybrid_additional_properties, request_hybrid_additional_properties =
|
|
26
25
|
resolve_hybrid_additional_properties(metadata)
|
|
@@ -31,13 +30,13 @@ class SharedExtractor
|
|
|
31
30
|
summary: metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example),
|
|
32
31
|
tags: metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example),
|
|
33
32
|
formats: metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example),
|
|
33
|
+
**read_only_lookups(metadata, example),
|
|
34
34
|
operation_id: metadata[:operation_id],
|
|
35
35
|
required_request_params: metadata[:required_request_params] || [],
|
|
36
36
|
security: metadata[:security],
|
|
37
37
|
description: metadata[:description] || RSpec::OpenAPI.description_builder.call(example),
|
|
38
38
|
deprecated: metadata[:deprecated],
|
|
39
|
-
|
|
40
|
-
response_example_mode: response_example_mode,
|
|
39
|
+
**example_modes(metadata, example),
|
|
41
40
|
example_key: resolve_example_key(metadata, example),
|
|
42
41
|
example_name: metadata[:example_name] || RSpec::OpenAPI.example_name_builder.call(example),
|
|
43
42
|
response_enum: normalize_enum(metadata[:response_enum]) || base_enum,
|
|
@@ -71,6 +70,40 @@ class SharedExtractor
|
|
|
71
70
|
[response, request]
|
|
72
71
|
end
|
|
73
72
|
|
|
73
|
+
def self.example_modes(metadata, example)
|
|
74
|
+
request_example_mode, response_example_mode = normalize_example_mode(metadata[:example_mode], example)
|
|
75
|
+
{ request_example_mode: request_example_mode, response_example_mode: response_example_mode }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The metadata wins over the builder for what a response marks. `false` marks
|
|
79
|
+
# nothing, and `true` hands it back to the builder, for a group that turned it
|
|
80
|
+
# off. A request that sends a property is evidence whatever its example
|
|
81
|
+
# marks, so its side takes the builder's candidates as well.
|
|
82
|
+
def self.read_only_lookups(metadata, example)
|
|
83
|
+
builder = RSpec::OpenAPI.read_only_builder&.curry&.call(example)
|
|
84
|
+
value = metadata.fetch(:read_only, true)
|
|
85
|
+
return { response_read_only: builder, request_read_only: builder } if value == true
|
|
86
|
+
|
|
87
|
+
listed = normalize_read_only(value, example)
|
|
88
|
+
request = listed && builder ? ->(key) { listed[key] || builder[key] } : listed || builder
|
|
89
|
+
{ response_read_only: listed, request_read_only: request }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
READ_ONLY_PATTERN_CLASSES = [String, Symbol, Regexp].freeze
|
|
93
|
+
|
|
94
|
+
def self.normalize_read_only(value, example)
|
|
95
|
+
return nil unless value
|
|
96
|
+
|
|
97
|
+
patterns = Array(value)
|
|
98
|
+
unless patterns.all? { |pattern| READ_ONLY_PATTERN_CLASSES.any? { |klass| pattern.is_a?(klass) } }
|
|
99
|
+
raise ArgumentError, 'read_only must be true, false, or property names as Strings, Symbols or Regexps, ' \
|
|
100
|
+
"got #{value.inspect}#{example_context(example)}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
patterns = patterns.map { |pattern| pattern.is_a?(Regexp) ? pattern : pattern.to_s }
|
|
104
|
+
->(key) { patterns.any? { |pattern| pattern.is_a?(Regexp) ? pattern.match?(key) : pattern == key } }
|
|
105
|
+
end
|
|
106
|
+
|
|
74
107
|
def self.normalize_enum(enum_hash)
|
|
75
108
|
return nil if enum_hash.nil? || enum_hash.empty?
|
|
76
109
|
|
|
@@ -153,9 +186,12 @@ class SharedExtractor
|
|
|
153
186
|
end
|
|
154
187
|
|
|
155
188
|
def self.example_mode_error(value, example)
|
|
156
|
-
context = example&.full_description
|
|
157
|
-
context = " (example: #{context})" if context
|
|
158
189
|
"example_mode must be a Symbol/String in #{VALID_EXAMPLE_MODES.inspect} " \
|
|
159
|
-
"or a Hash with :request/:response keys, got #{value.inspect}#{
|
|
190
|
+
"or a Hash with :request/:response keys, got #{value.inspect}#{example_context(example)}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.example_context(example)
|
|
194
|
+
context = example&.full_description
|
|
195
|
+
context ? " (example: #{context})" : ''
|
|
160
196
|
end
|
|
161
197
|
end
|
|
@@ -7,56 +7,160 @@ class << RSpec::OpenAPI::HashHelper = Object.new
|
|
|
7
7
|
# document and filtering. A document built from a large suite holds tens of
|
|
8
8
|
# thousands of nodes and these selectors run dozens of times per build.
|
|
9
9
|
def matched_paths(obj, selector)
|
|
10
|
-
selector_parts = selector.split('.').map(&:to_sym)
|
|
11
10
|
matches = []
|
|
12
|
-
collect_matches(obj,
|
|
11
|
+
collect_matches(obj, split_selector(selector), 0, [], matches)
|
|
13
12
|
matches
|
|
14
13
|
end
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# Yields each match of selector in obj as its parent, the matching key and
|
|
16
|
+
# the value, with the value at the same path in other (nil when other has
|
|
17
|
+
# none). Callers that only edit the matched nodes, or compare them with a
|
|
18
|
+
# fresh document, get them without a path to dig back up from the root for
|
|
19
|
+
# every match.
|
|
20
|
+
def each_matched(obj, other, selector, &block)
|
|
21
|
+
walk_matched(obj, other, split_selector(selector), 0, &block)
|
|
22
|
+
end
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
# Paths that start with a match of begin_selector and end with a match of an
|
|
25
|
+
# end selector, with any number of keys in between: the matches of the first
|
|
26
|
+
# end selector sorted by length, then those of the next, each length in
|
|
27
|
+
# document order.
|
|
28
|
+
#
|
|
29
|
+
# The subtree under a begin match is walked once for all the end selectors,
|
|
30
|
+
# checking the tail of every path against each. Walking it once per possible
|
|
31
|
+
# length instead makes this cost the depth of the document times its size.
|
|
32
|
+
def matched_paths_deeply_nested(obj, begin_selector, *end_selectors)
|
|
33
|
+
end_parts = end_selectors.map { |selector| split_selector(selector) }
|
|
34
|
+
matches = end_parts.map { [] }
|
|
35
|
+
matched_paths(obj, begin_selector).each do |prefix|
|
|
36
|
+
collect_suffix_matches(obj.dig(*prefix), end_parts, prefix.dup, prefix.size, matches)
|
|
37
|
+
end
|
|
38
|
+
matches.flat_map { |found| found.group_by(&:size).sort_by(&:first).flat_map(&:last) }
|
|
39
|
+
end
|
|
25
40
|
|
|
26
|
-
|
|
41
|
+
# Keys whose values hold user data, not schemas. Their contents must never be
|
|
42
|
+
# rewritten, or a recorded example/default that happens to contain a field
|
|
43
|
+
# named `type` or `nullable` would be silently mangled.
|
|
44
|
+
DATA_KEYS = [:example, :examples, :default, :enum, :const].freeze
|
|
45
|
+
|
|
46
|
+
# Yields every hash in obj outside the data subtrees in DATA_KEYS, each after
|
|
47
|
+
# its descendants. The entries of a `properties` map are schemas whatever
|
|
48
|
+
# their names, so the map itself is skipped and each entry is yielded.
|
|
49
|
+
def each_schema(obj, &block)
|
|
50
|
+
case obj
|
|
51
|
+
when Hash
|
|
52
|
+
obj.each do |key, value|
|
|
53
|
+
if key == :properties && value.is_a?(Hash)
|
|
54
|
+
value.each_value { |property| each_schema(property, &block) }
|
|
55
|
+
elsif !DATA_KEYS.include?(key)
|
|
56
|
+
each_schema(value, &block)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
yield obj
|
|
60
|
+
when Array
|
|
61
|
+
obj.each { |value| each_schema(value, &block) }
|
|
27
62
|
end
|
|
28
63
|
end
|
|
29
64
|
|
|
65
|
+
# The variant of a schema that has the type, where a schema without a oneOf
|
|
66
|
+
# is its only variant.
|
|
67
|
+
def variant_of_type(schema, type)
|
|
68
|
+
(schema[:oneOf] || [schema]).find { |variant| variant.is_a?(Hash) && variant[:type] == type }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The selectors are a fixed set of literals, some of them matched once per
|
|
72
|
+
# media type of the document, so each is split once.
|
|
73
|
+
SELECTOR_PARTS = Hash.new { |cache, selector| cache[selector] = selector.split('.').map(&:to_sym).freeze }
|
|
74
|
+
private_constant :SELECTOR_PARTS
|
|
75
|
+
|
|
30
76
|
private
|
|
31
77
|
|
|
32
|
-
def
|
|
78
|
+
def split_selector(selector)
|
|
79
|
+
SELECTOR_PARTS[selector]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `path` is the stack of keys from the root to obj, pushed and popped in
|
|
83
|
+
# place, so a path is only ever copied for a match.
|
|
84
|
+
def collect_matches(obj, selector_parts, depth, path, matches)
|
|
33
85
|
part = selector_parts[depth]
|
|
34
86
|
last = depth == selector_parts.size - 1
|
|
35
87
|
|
|
36
88
|
each_child(obj) do |key, value|
|
|
37
89
|
next unless part == :* || part == key
|
|
38
90
|
|
|
39
|
-
path
|
|
91
|
+
path.push(key)
|
|
40
92
|
if last
|
|
41
|
-
matches << path
|
|
93
|
+
matches << path.dup
|
|
42
94
|
else
|
|
43
95
|
collect_matches(value, selector_parts, depth + 1, path, matches)
|
|
44
96
|
end
|
|
97
|
+
path.pop
|
|
45
98
|
end
|
|
46
99
|
end
|
|
47
100
|
|
|
48
|
-
def
|
|
101
|
+
def walk_matched(obj, other, selector_parts, depth, &block)
|
|
102
|
+
part = selector_parts[depth]
|
|
103
|
+
last = depth == selector_parts.size - 1
|
|
104
|
+
|
|
105
|
+
each_child(obj) do |key, value|
|
|
106
|
+
next unless part == :* || part == key
|
|
107
|
+
|
|
108
|
+
if last
|
|
109
|
+
yield obj, key, value, child_of(other, key)
|
|
110
|
+
else
|
|
111
|
+
walk_matched(value, child_of(other, key), selector_parts, depth + 1, &block)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def child_of(obj, key)
|
|
49
117
|
case obj
|
|
50
|
-
when Hash then obj
|
|
51
|
-
when Array then obj.each_with_index { |value, index| yield index, value }
|
|
118
|
+
when Hash, Array then obj[key]
|
|
52
119
|
end
|
|
53
120
|
end
|
|
54
121
|
|
|
55
|
-
|
|
122
|
+
# The loops below run once per node of the document, so they index by hand:
|
|
123
|
+
# each_with_index and Integer#times allocate per call. Most nodes fail on
|
|
124
|
+
# their own key, which is checked before the rest of the suffix.
|
|
125
|
+
def collect_suffix_matches(obj, end_parts, path, prefix_size, matches)
|
|
126
|
+
each_child(obj) do |key, value|
|
|
127
|
+
path.push(key)
|
|
128
|
+
index = 0
|
|
129
|
+
while index < end_parts.size
|
|
130
|
+
parts = end_parts[index]
|
|
131
|
+
last = parts[-1]
|
|
132
|
+
if (last == :* || last == key) && path.size >= prefix_size + parts.size && suffix_matches?(path, parts)
|
|
133
|
+
matches[index] << path.dup
|
|
134
|
+
end
|
|
135
|
+
index += 1
|
|
136
|
+
end
|
|
137
|
+
collect_suffix_matches(value, end_parts, path, prefix_size, matches)
|
|
138
|
+
path.pop
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def suffix_matches?(path, parts)
|
|
143
|
+
offset = path.size - parts.size
|
|
144
|
+
index = 0
|
|
145
|
+
while index < parts.size
|
|
146
|
+
part = parts[index]
|
|
147
|
+
return false unless part == :* || part == path[offset + index]
|
|
148
|
+
|
|
149
|
+
index += 1
|
|
150
|
+
end
|
|
151
|
+
true
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def each_child(obj)
|
|
56
155
|
case obj
|
|
57
|
-
when Hash
|
|
58
|
-
|
|
59
|
-
|
|
156
|
+
when Hash
|
|
157
|
+
obj.each { |key, value| yield key.to_sym, value }
|
|
158
|
+
when Array
|
|
159
|
+
index = 0
|
|
160
|
+
obj.each do |value|
|
|
161
|
+
yield index, value
|
|
162
|
+
index += 1
|
|
163
|
+
end
|
|
60
164
|
end
|
|
61
165
|
end
|
|
62
166
|
end
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class << RSpec::OpenAPI::KeyTransformer = Object.new
|
|
4
|
+
# The hashes are rebuilt with each rather than to_h, which yields every
|
|
5
|
+
# entry as a fresh pair array.
|
|
4
6
|
def symbolize(value)
|
|
5
7
|
case value
|
|
6
8
|
when Hash
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
[k.to_sym, symbolize(v)]
|
|
12
|
-
end
|
|
9
|
+
result = {}
|
|
10
|
+
value.each do |k, v|
|
|
11
|
+
k = k.to_sym
|
|
12
|
+
result[k] = k == :examples ? symbolize_examples(v) : symbolize(v)
|
|
13
13
|
end
|
|
14
|
+
result
|
|
14
15
|
when Array
|
|
15
16
|
value.map { |v| symbolize(v) }
|
|
16
17
|
else
|
|
@@ -24,21 +25,35 @@ class << RSpec::OpenAPI::KeyTransformer = Object.new
|
|
|
24
25
|
def symbolize_examples(value)
|
|
25
26
|
return symbolize(value) unless value.is_a?(Hash)
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
result = {}
|
|
29
|
+
value.each do |k, v|
|
|
28
30
|
k = k.downcase.tr(' ', '_') unless k.is_a?(Symbol)
|
|
29
31
|
|
|
30
|
-
[k.to_sym
|
|
32
|
+
result[k.to_sym] = symbolize(v)
|
|
31
33
|
end
|
|
34
|
+
result
|
|
32
35
|
end
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
# Normalizes the names of examples in place, for a document that already
|
|
38
|
+
# holds symbol keys.
|
|
39
|
+
def normalize_example_names!(value)
|
|
35
40
|
case value
|
|
36
41
|
when Hash
|
|
37
|
-
value.
|
|
42
|
+
value.each do |k, v|
|
|
43
|
+
rename_examples!(v) if k == :examples && v.is_a?(Hash)
|
|
44
|
+
normalize_example_names!(v)
|
|
45
|
+
end
|
|
38
46
|
when Array
|
|
39
|
-
value.
|
|
40
|
-
else
|
|
41
|
-
value
|
|
47
|
+
value.each { |v| normalize_example_names!(v) }
|
|
42
48
|
end
|
|
49
|
+
value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def rename_examples!(examples)
|
|
55
|
+
renamed = {}
|
|
56
|
+
examples.each { |name, example| renamed[RSpec::OpenAPI::ExampleKey.normalize(name).to_sym] = example }
|
|
57
|
+
examples.replace(renamed)
|
|
43
58
|
end
|
|
44
59
|
end
|
|
@@ -53,7 +53,8 @@ if ENV['OPENAPI']
|
|
|
53
53
|
|
|
54
54
|
Minitest.after_run do
|
|
55
55
|
RSpec::OpenAPI::ParallelRecords.merge!
|
|
56
|
-
|
|
56
|
+
partial = RSpec::OpenAPI::PartialRun.minitest?
|
|
57
|
+
result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records, partial: partial)
|
|
57
58
|
result_recorder.record_results!
|
|
58
59
|
puts result_recorder.error_message if result_recorder.errors?
|
|
59
60
|
end
|