rspec-openapi 0.35.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 +16 -9
- data/lib/rspec/openapi/extractors/shared_extractor.rb +42 -6
- data/lib/rspec/openapi/hash_helper.rb +42 -3
- data/lib/rspec/openapi/key_transformer.rb +17 -7
- data/lib/rspec/openapi/minitest_hooks.rb +2 -1
- 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 +21 -8
- data/lib/rspec/openapi/rspec_hooks.rb +11 -3
- data/lib/rspec/openapi/schema_builder/build_context.rb +18 -4
- data/lib/rspec/openapi/schema_builder/variation_merger.rb +28 -9
- data/lib/rspec/openapi/schema_builder.rb +91 -58
- data/lib/rspec/openapi/schema_cleaner.rb +73 -56
- data/lib/rspec/openapi/schema_file.rb +27 -20
- data/lib/rspec/openapi/schema_merger.rb +22 -1
- data/lib/rspec/openapi/schema_sorter.rb +24 -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 +5 -2
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
|
|
@@ -87,6 +89,10 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
|
|
|
87
89
|
ref_link = dig_schema(base, paths)[:$ref]
|
|
88
90
|
schema_name = extract_schema_name(ref_link)
|
|
89
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?
|
|
90
96
|
|
|
91
97
|
merge_schema!(fresh_schemas, schema_name, schema_body)
|
|
92
98
|
end
|
|
@@ -98,14 +104,15 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
|
|
|
98
104
|
RSpec::OpenAPI::SchemaMerger.merge_normalized!({ schemas: fresh_schemas }, { schemas: { schema_name => schema } })
|
|
99
105
|
end
|
|
100
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.
|
|
101
110
|
def dig_schema(obj, paths)
|
|
102
|
-
|
|
103
|
-
paths
|
|
104
|
-
item_schema = obj.dig(*paths, :schema, :items)
|
|
105
|
-
object_schema = obj.dig(*paths, :schema)
|
|
106
|
-
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)
|
|
107
113
|
|
|
108
|
-
|
|
114
|
+
schema = media_type[:schema]
|
|
115
|
+
(schema.is_a?(Hash) && schema[:items]) || schema
|
|
109
116
|
end
|
|
110
117
|
|
|
111
118
|
def paths_to_top_level_refs(base)
|
|
@@ -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
|
|
@@ -12,6 +12,15 @@ class << RSpec::OpenAPI::HashHelper = Object.new
|
|
|
12
12
|
matches
|
|
13
13
|
end
|
|
14
14
|
|
|
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
|
|
23
|
+
|
|
15
24
|
# Paths that start with a match of begin_selector and end with a match of an
|
|
16
25
|
# end selector, with any number of keys in between: the matches of the first
|
|
17
26
|
# end selector sorted by length, then those of the next, each length in
|
|
@@ -59,10 +68,15 @@ class << RSpec::OpenAPI::HashHelper = Object.new
|
|
|
59
68
|
(schema[:oneOf] || [schema]).find { |variant| variant.is_a?(Hash) && variant[:type] == type }
|
|
60
69
|
end
|
|
61
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
|
+
|
|
62
76
|
private
|
|
63
77
|
|
|
64
78
|
def split_selector(selector)
|
|
65
|
-
selector
|
|
79
|
+
SELECTOR_PARTS[selector]
|
|
66
80
|
end
|
|
67
81
|
|
|
68
82
|
# `path` is the stack of keys from the root to obj, pushed and popped in
|
|
@@ -84,15 +98,40 @@ class << RSpec::OpenAPI::HashHelper = Object.new
|
|
|
84
98
|
end
|
|
85
99
|
end
|
|
86
100
|
|
|
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)
|
|
117
|
+
case obj
|
|
118
|
+
when Hash, Array then obj[key]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
87
122
|
# The loops below run once per node of the document, so they index by hand:
|
|
88
|
-
# each_with_index and Integer#times allocate per call.
|
|
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.
|
|
89
125
|
def collect_suffix_matches(obj, end_parts, path, prefix_size, matches)
|
|
90
126
|
each_child(obj) do |key, value|
|
|
91
127
|
path.push(key)
|
|
92
128
|
index = 0
|
|
93
129
|
while index < end_parts.size
|
|
94
130
|
parts = end_parts[index]
|
|
95
|
-
|
|
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
|
|
96
135
|
index += 1
|
|
97
136
|
end
|
|
98
137
|
collect_suffix_matches(value, end_parts, path, prefix_size, matches)
|
|
@@ -34,16 +34,26 @@ class << RSpec::OpenAPI::KeyTransformer = Object.new
|
|
|
34
34
|
result
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
# Normalizes the names of examples in place, for a document that already
|
|
38
|
+
# holds symbol keys.
|
|
39
|
+
def normalize_example_names!(value)
|
|
38
40
|
case value
|
|
39
41
|
when Hash
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
value.each do |k, v|
|
|
43
|
+
rename_examples!(v) if k == :examples && v.is_a?(Hash)
|
|
44
|
+
normalize_example_names!(v)
|
|
45
|
+
end
|
|
43
46
|
when Array
|
|
44
|
-
value.
|
|
45
|
-
else
|
|
46
|
-
value
|
|
47
|
+
value.each { |v| normalize_example_names!(v) }
|
|
47
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)
|
|
48
58
|
end
|
|
49
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
|
|
@@ -30,6 +30,16 @@ module RSpec::OpenAPI::ParallelRecords
|
|
|
30
30
|
# its content, so the file itself does not need to survive the trip.
|
|
31
31
|
UPLOADED_FILE_MARKER = '__rspec_openapi_uploaded_file'
|
|
32
32
|
|
|
33
|
+
# Members that hold a builder's lookup, a Proc when a builder is set.
|
|
34
|
+
LOOKUP_MEMBERS = [:formats, :response_read_only, :request_read_only].freeze
|
|
35
|
+
|
|
36
|
+
# Tags an error a lookup raised in a worker. It travels in place of the
|
|
37
|
+
# answers as [LOOKUP_ERROR_MARKER, message], a shape no answer Hash can take.
|
|
38
|
+
LOOKUP_ERROR_MARKER = '__rspec_openapi_lookup_error'
|
|
39
|
+
|
|
40
|
+
# Raised in the main process for an error a lookup raised in a worker.
|
|
41
|
+
class LookupError < StandardError; end
|
|
42
|
+
|
|
33
43
|
class << self
|
|
34
44
|
def worker?
|
|
35
45
|
Process.pid != MAIN_PID
|
|
@@ -52,7 +62,7 @@ module RSpec::OpenAPI::ParallelRecords
|
|
|
52
62
|
|
|
53
63
|
FileUtils.mkdir_p(dump_dir, mode: 0o700)
|
|
54
64
|
encoded = RSpec::OpenAPI.path_records.transform_values do |records|
|
|
55
|
-
records.map { |record|
|
|
65
|
+
records.map { |record| encode_record(answer_lookups(record)) }
|
|
56
66
|
end
|
|
57
67
|
File.write(File.join(dump_dir, "#{Process.pid}.yaml"), YAML.dump(encoded))
|
|
58
68
|
end
|
|
@@ -61,7 +71,7 @@ module RSpec::OpenAPI::ParallelRecords
|
|
|
61
71
|
Dir.glob(File.join(dump_dir, '*.yaml')).sort.each do |file|
|
|
62
72
|
records_by_path = YAML.safe_load(File.read(file), permitted_classes: permitted_classes, aliases: true)
|
|
63
73
|
records_by_path.each do |path, records|
|
|
64
|
-
decoded = records.map { |record|
|
|
74
|
+
decoded = records.map { |record| decode_record(record) }
|
|
65
75
|
RSpec::OpenAPI.path_records[path].concat(decoded)
|
|
66
76
|
end
|
|
67
77
|
end
|
|
@@ -83,8 +93,53 @@ module RSpec::OpenAPI::ParallelRecords
|
|
|
83
93
|
classes
|
|
84
94
|
end
|
|
85
95
|
|
|
86
|
-
def
|
|
87
|
-
RSpec::OpenAPI::Record.new(**record.to_h.transform_values(
|
|
96
|
+
def encode_record(record)
|
|
97
|
+
RSpec::OpenAPI::Record.new(**record.to_h.transform_values { |value| encode(value) })
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Only a lookup member can hold a lookup error, so only there is one looked for.
|
|
101
|
+
def decode_record(record)
|
|
102
|
+
members = record.to_h.to_h do |member, value|
|
|
103
|
+
[member, LOOKUP_MEMBERS.include?(member) ? decode_lookup(value) : decode(value)]
|
|
104
|
+
end
|
|
105
|
+
RSpec::OpenAPI::Record.new(**members)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def decode_lookup(value)
|
|
109
|
+
return value unless value.is_a?(Array) && value.first == LOOKUP_ERROR_MARKER
|
|
110
|
+
|
|
111
|
+
message = value.last
|
|
112
|
+
->(*) { raise LookupError, message }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# No serializer can carry a Proc. Building the schema here asks each lookup
|
|
116
|
+
# about every key the main process will ask about, and the answers travel
|
|
117
|
+
# as a Hash, which the builder reads the same way.
|
|
118
|
+
def answer_lookups(record)
|
|
119
|
+
lookups = LOOKUP_MEMBERS.select { |member| record[member].is_a?(Proc) }
|
|
120
|
+
return record if lookups.empty?
|
|
121
|
+
|
|
122
|
+
answers = lookups.to_h { |member| [member, {}] }
|
|
123
|
+
probe = record.dup
|
|
124
|
+
lookups.each { |member| probe[member] = recording_lookup(member, record[member], answers) }
|
|
125
|
+
begin
|
|
126
|
+
RSpec::OpenAPI::SchemaBuilder.build(probe)
|
|
127
|
+
rescue StandardError, NotImplementedError
|
|
128
|
+
# A value the builder cannot type, or a lookup error kept below. The
|
|
129
|
+
# main process builds this record again and reports the error itself.
|
|
130
|
+
end
|
|
131
|
+
record.dup.tap { |answered| answers.each { |member, value| answered[member] = value } }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The main process cannot call the lookup again, so an error it raises
|
|
135
|
+
# travels in place of its answers, and decode_lookup raises it there.
|
|
136
|
+
def recording_lookup(member, lookup, answers)
|
|
137
|
+
lambda do |key|
|
|
138
|
+
answers[member][key] = lookup.call(key)
|
|
139
|
+
rescue StandardError, NotImplementedError => e
|
|
140
|
+
answers[member] = [LOOKUP_ERROR_MARKER, "#{member} lookup raised #{e.class}: #{e.message}"]
|
|
141
|
+
raise
|
|
142
|
+
end
|
|
88
143
|
end
|
|
89
144
|
|
|
90
145
|
# Uploads hold an open Tempfile, which no serializer can represent, so
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
# Decides whether a run adds and updates the document without removing anything.
|
|
6
|
+
#
|
|
7
|
+
# A run over part of the suite cannot tell an endpoint that was removed from
|
|
8
|
+
# one whose spec did not run, so it removes nothing. Only a run over the whole
|
|
9
|
+
# suite does. RSpec::OpenAPI.partial_update forces either mode.
|
|
10
|
+
module RSpec::OpenAPI::PartialRun
|
|
11
|
+
class << self
|
|
12
|
+
# The spec file of an example that recorded a request. The run is partial
|
|
13
|
+
# when a spec file next to one of these was left out.
|
|
14
|
+
def record_file(path)
|
|
15
|
+
recorded_files << path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rspec?
|
|
19
|
+
configured = RSpec::OpenAPI.partial_update
|
|
20
|
+
return configured unless configured.nil?
|
|
21
|
+
|
|
22
|
+
parallel_worker? || filtered? || files_left_out?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Minitest tells nothing about the run, so only the option and the
|
|
26
|
+
# parallel_tests marker count.
|
|
27
|
+
def minitest?
|
|
28
|
+
configured = RSpec::OpenAPI.partial_update
|
|
29
|
+
configured == true || (configured.nil? && parallel_worker?)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Printed when the run decided on its own, since the user may expect the
|
|
33
|
+
# removals of a full run.
|
|
34
|
+
def notice(partial)
|
|
35
|
+
return unless partial && RSpec::OpenAPI.partial_update.nil? && recorded_files.any?
|
|
36
|
+
|
|
37
|
+
'rspec-openapi: this run covered part of the suite, so nothing was removed from the document. ' \
|
|
38
|
+
'Run all specs to remove what no longer exists.'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def recorded_files
|
|
44
|
+
@recorded_files ||= Set.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# parallel_tests marks each worker with TEST_ENV_NUMBER, an empty string
|
|
48
|
+
# for the first one. A worker sees part of the files, whichever files it
|
|
49
|
+
# got, so the directory check below cannot tell it from a full run.
|
|
50
|
+
def parallel_worker?
|
|
51
|
+
ENV.key?('TEST_ENV_NUMBER')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# -e, --tag, a line number, --only-failures, and a `fit` once an example
|
|
55
|
+
# carries it. An exclusion such as `--tag ~slow` does not narrow the run.
|
|
56
|
+
def filtered?
|
|
57
|
+
RSpec.configuration.inclusion_filter.rules.any?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `rspec spec/requests/users_spec.rb` leaves out the other files of
|
|
61
|
+
# spec/requests. `rspec spec/requests` and `rspec spec/requests/*_spec.rb`
|
|
62
|
+
# do not, nor does a run that leaves out files of a directory that
|
|
63
|
+
# recorded nothing.
|
|
64
|
+
def files_left_out?
|
|
65
|
+
running = RSpec.configuration.files_to_run
|
|
66
|
+
recorded_files.map { |file| File.dirname(file) }.uniq.any? do |dir|
|
|
67
|
+
(spec_files_in(dir) - running).any?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The spec files directly in dir, by the pattern and exclude pattern of
|
|
72
|
+
# the run.
|
|
73
|
+
def spec_files_in(dir)
|
|
74
|
+
config = RSpec.configuration
|
|
75
|
+
files = Dir[glob(dir, config.pattern)] - Dir[glob(dir, config.exclude_pattern)]
|
|
76
|
+
files.map { |file| File.expand_path(file) }.select { |file| File.dirname(file) == dir }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A pattern may list alternatives, which glob as a brace group like RSpec does.
|
|
80
|
+
def glob(dir, pattern)
|
|
81
|
+
File.join(dir, "{#{pattern.to_s.gsub(/\s*,\s*/, ',')}}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/rspec/openapi/record.rb
CHANGED
|
@@ -13,6 +13,8 @@ RSpec::OpenAPI::Record = Struct.new(
|
|
|
13
13
|
:summary, # @param [String] - "v1/statuses #show"
|
|
14
14
|
:tags, # @param [Array] - ["Status"]
|
|
15
15
|
:formats, # @param [Proc] - ->(key) { key.end_with?('_at') ? 'date-time' : nil }
|
|
16
|
+
:response_read_only, # @param [Proc] - ->(key) { key == 'id' }, what a response marks readOnly
|
|
17
|
+
:request_read_only, # @param [Proc] - ->(key) { key == 'id' }, what a request may cancel
|
|
16
18
|
:operation_id, # @param [String] - "request-1234"
|
|
17
19
|
:description, # @param [String] - "returns a status"
|
|
18
20
|
:example_key, # @param [String] - "with_flat_query_parameters"
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class RSpec::OpenAPI::ResultRecorder
|
|
4
|
-
|
|
4
|
+
# @param [Boolean] partial add and update without removing anything
|
|
5
|
+
def initialize(path_records, partial: false)
|
|
5
6
|
@path_records = path_records
|
|
7
|
+
@partial = partial
|
|
6
8
|
@error_records = {}
|
|
7
9
|
end
|
|
8
10
|
|
|
@@ -45,6 +47,10 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
45
47
|
def build_spec(primary, records)
|
|
46
48
|
title = records.first.title
|
|
47
49
|
RSpec::OpenAPI::SchemaFile.new(primary).edit do |spec|
|
|
50
|
+
# The cleanup compares the document with one built from this run alone.
|
|
51
|
+
# Without an existing document nothing can be stale, so the document is
|
|
52
|
+
# that one and the records are merged once.
|
|
53
|
+
new_from_zero = spec.empty? ? spec : {}
|
|
48
54
|
# Normalize an existing file back to the internal form so merge/cleanup is
|
|
49
55
|
# version-agnostic; cleanup_schema! re-applies the target form.
|
|
50
56
|
RSpec::OpenAPI::NullableConverter.normalize!(spec)
|
|
@@ -54,11 +60,11 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
54
60
|
# SchemaFile#read already symbolized the document, so no merge here needs
|
|
55
61
|
# to normalize it again.
|
|
56
62
|
RSpec::OpenAPI::SchemaMerger.merge_normalized!(spec, schema)
|
|
57
|
-
new_from_zero = {}
|
|
58
63
|
records.each do |record|
|
|
59
64
|
record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
|
|
60
|
-
|
|
61
|
-
RSpec::OpenAPI::SchemaMerger.merge_normalized!(new_from_zero, record_schema)
|
|
65
|
+
# The document takes the schema over, so the fresh one gets a copy.
|
|
66
|
+
RSpec::OpenAPI::SchemaMerger.merge_normalized!(new_from_zero, record_schema) unless new_from_zero.equal?(spec)
|
|
67
|
+
RSpec::OpenAPI::SchemaMerger.merge_built!(spec, record_schema)
|
|
62
68
|
rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
|
|
63
69
|
@error_records[e] = record # Avoid failing the build
|
|
64
70
|
end
|
|
@@ -68,14 +74,21 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
def execute_post_process_hook(path, records, spec)
|
|
71
|
-
|
|
77
|
+
return unless RSpec::OpenAPI.post_process_hook.is_a?(Proc)
|
|
78
|
+
|
|
79
|
+
RSpec::OpenAPI.post_process_hook.call(path, records, spec)
|
|
80
|
+
# The hook may add string keys beside the symbol ones, and the files are
|
|
81
|
+
# written from the keys as they are. Folding them keeps one entry per name.
|
|
82
|
+
spec.replace(RSpec::OpenAPI::KeyTransformer.symbolize(spec))
|
|
72
83
|
end
|
|
73
84
|
|
|
74
85
|
def cleanup_schema!(new_from_zero, spec)
|
|
75
86
|
RSpec::OpenAPI::SchemaCleaner.cleanup_conflicting_security_parameters!(spec)
|
|
76
|
-
RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
|
|
77
|
-
RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
|
|
78
|
-
|
|
87
|
+
RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero) unless @partial
|
|
88
|
+
RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero, partial: @partial)
|
|
89
|
+
# After the update, which reads the request side's readOnly: false from
|
|
90
|
+
# new_from_zero, the very spec when there was no document yet.
|
|
91
|
+
RSpec::OpenAPI::SchemaCleaner.cleanup_schemas!(spec)
|
|
79
92
|
RSpec::OpenAPI::OperationConverter.to_additional_operations!(spec) if RSpec::OpenAPI.supports_additional_operations?
|
|
80
93
|
RSpec::OpenAPI::SchemaSorter.deep_sort!(spec)
|
|
81
94
|
RSpec::OpenAPI::NullableConverter.to_json_schema!(spec) if RSpec::OpenAPI.json_schema_based?
|