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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d493f4696b8bf07c0dcdfe1cbfb4d06736ac7b7efc3c103b8692f6d7aab01db7
4
- data.tar.gz: 38e4d7f3268a4d84e60ad6f047c3176b1d161ef895f5648422d7231e34ccfff5
3
+ metadata.gz: a9ca3c8b1eadd4f1b45f1a3c6fcfe533ac85e2eb836ff2dd849fec8f22f7a42d
4
+ data.tar.gz: 43ef531b0a5141c1d95e075fda5a5020b0c1792c73f4df686ce152801e7c29e7
5
5
  SHA512:
6
- metadata.gz: c1dbea1b90c265e2e636a77f406456c2e871fdefa476845538cd44d4486887e09298ce3e55406cb1e2f0c9305a030328a1300638a9804753c56d3b56b40e96cb
7
- data.tar.gz: 597cf318feef44fa58393dc94c3f4101d990e755028df0df16f76024aa6d79baff86a6d689b5f968c792a8e86e695ddd8f7abfdf89b2c314c9d4c381b83a4309
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
- def update!(base, fresh)
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
- RSpec::OpenAPI::SchemaMerger.merge_normalized!(base, { components: { schemas: fresh_schemas } })
26
- RSpec::OpenAPI::SchemaCleaner.cleanup_components_schemas!(base, { components: { schemas: fresh_schemas } })
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.dig(*paths[0..-2])
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)).to_sym
64
- fresh_schemas[schema_name] ||= {}
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.inject({}) do |acc, paths|
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
- RSpec::OpenAPI::SchemaMerger.merge_normalized!(acc, { schema_name => schema_body })
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
- # Response code can be an integer
80
- paths = paths.map { |path| path.is_a?(Integer) ? path : path.to_sym }
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
- item_schema || object_schema || one_of_schema
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
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties.*.$ref'),
102
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties.*.items.$ref'),
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
- request_example_mode: request_example_mode,
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}#{context}"
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, selector_parts, 0, [], matches)
11
+ collect_matches(obj, split_selector(selector), 0, [], matches)
13
12
  matches
14
13
  end
15
14
 
16
- def matched_paths_deeply_nested(obj, begin_selector, end_selector)
17
- begin_depth = begin_selector.to_s.count('.')
18
- end_depth = end_selector.to_s.count('.')
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
- # Every path in obj has all of its prefixes in obj too, so the depths that
21
- # occur are exactly 1 up to the deepest one.
22
- (1..max_depth(obj)).flat_map do |depth|
23
- gap = depth - begin_depth - end_depth
24
- next [] if gap.negative?
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
- matched_paths(obj, "#{begin_selector}.#{'*.' * gap}#{end_selector}")
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 collect_matches(obj, selector_parts, depth, prefix, matches)
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 = [*prefix, key]
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 each_child(obj)
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.each { |key, value| yield key.to_sym, value }
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
- def max_depth(obj)
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 then obj.empty? ? 0 : 1 + obj.each_value.map { |value| max_depth(value) }.max
58
- when Array then obj.empty? ? 0 : 1 + obj.map { |value| max_depth(value) }.max
59
- else 0
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
- value.to_h do |k, v|
8
- if k.to_sym == :examples
9
- [k.to_sym, symbolize_examples(v)]
10
- else
11
- [k.to_sym, symbolize(v)]
12
- end
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
- value.to_h do |k, v|
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, symbolize(v)]
32
+ result[k.to_sym] = symbolize(v)
31
33
  end
34
+ result
32
35
  end
33
36
 
34
- def stringify(value)
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.to_h { |k, v| [k.to_s, stringify(v)] }
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.map { |v| stringify(v) }
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
- result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records)
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