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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7298e79ff60a23925281c724390eed37544bef37a47f7ba85e6f5ef2d28bf3f
4
- data.tar.gz: b1798ffc05aa81f206b4f50e5fffeb6317ac902e113c1c3449bf1f0ed4be437d
3
+ metadata.gz: a9ca3c8b1eadd4f1b45f1a3c6fcfe533ac85e2eb836ff2dd849fec8f22f7a42d
4
+ data.tar.gz: 43ef531b0a5141c1d95e075fda5a5020b0c1792c73f4df686ce152801e7c29e7
5
5
  SHA512:
6
- metadata.gz: 8d5b3eb347258591c559c26f4450179c7a329a67bc1a6b01e7f92b1666dd8ab251c604f93ac28c92387277649d5b17b94d37a38be31875f86872028e431e6fad
7
- data.tar.gz: 4ee74f1f40a64081044245d88dfe01ac43a40ef397467477e99cf54ea3368b2af080c44712f3873727989cf94e7c27229e2aaed900db0df58f1cfc2d0b818005
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
@@ -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
- # Response code can be an integer
103
- paths = paths.map { |path| path.is_a?(Integer) ? path : path.to_sym }
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
- item_schema || object_schema || one_of_schema
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
- 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
@@ -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.split('.').map(&:to_sym)
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
- matches[index] << path.dup if path.size >= prefix_size + parts.size && suffix_matches?(path, parts)
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
- 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)
38
40
  case value
39
41
  when Hash
40
- result = {}
41
- value.each { |k, v| result[k.to_s] = stringify(v) }
42
- result
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.map { |v| stringify(v) }
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
- 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
@@ -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| transform_record(record) { |value| encode(value) } }
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| transform_record(record) { |value| decode(value) } }
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 transform_record(record, &block)
87
- RSpec::OpenAPI::Record.new(**record.to_h.transform_values(&block))
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
@@ -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
- def initialize(path_records)
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
- RSpec::OpenAPI::SchemaMerger.merge_normalized!(spec, record_schema)
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
- RSpec::OpenAPI.post_process_hook.call(path, records, spec) if RSpec::OpenAPI.post_process_hook.is_a?(Proc)
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
- RSpec::OpenAPI::SchemaCleaner.cleanup_empty_required_array!(spec)
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?