rspec-openapi 0.32.0 → 0.33.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: 64afa135a6f5a64d96bf01dbf50457b98964a0875a21f0e85c159dc16cbd6a11
4
- data.tar.gz: '048bab9ffc8d6c6876a3c2aeb03e7cf68ba992db9da6953d50dfef9106fe5acd'
3
+ metadata.gz: dbbe2720c2fa2a498263a4c54a017982e17bf9aa7f34f422b8ccb2737c2b3ac1
4
+ data.tar.gz: 5725ab9fe452d6aa465d431597ab5f47b218b302b7222a7495fb1423bbf6087b
5
5
  SHA512:
6
- metadata.gz: 9967fb54526fc7d743f4ba7d1de62c5a65b8a47ccc4350a55c575eef27d38110dfcc4ff2614fc02664d4eb622fb302422e1352cc2bbdb28e955989bc051609b7
7
- data.tar.gz: b407e68dee10bf81a9b4ced14e8b767ce9099fd3ebedbecb67f43fd0f994fb06fb10868b422ba0fef9c6d4bbeaca2c06db5f58758f8ac09953c608949a6574ff
6
+ metadata.gz: 9783bfa40b973043bb0f1c1f4fc2d2ddaccde0371a43762024a8f1a7fddeb4245635e6c478ebc70949da2ebe3b5e5fdd53dc97327735eac8ce8c101c1302a78f
7
+ data.tar.gz: bed996f8f40811638b55338d652ec4483a583a631e5defaf3bd67e01d8eadd64533b68340ef28491b57f96fb52bc518acad34845d5e41f26b4fe9f3e50b6c71d
data/README.md CHANGED
@@ -986,6 +986,13 @@ Run minitest with OPENAPI=1 to generate `doc/openapi.yaml` for your request spec
986
986
  $ OPENAPI=1 bundle exec rails t
987
987
  ```
988
988
 
989
+ ### Parallel test execution
990
+
991
+ Minitest's parallel execution is supported, both thread-based (`parallelize_me!`) and
992
+ process-based (Rails' `parallelize`). With forked workers, each worker dumps its
993
+ records to a temporary directory on exit and the main process merges them back
994
+ before writing the schema. No configuration is needed.
995
+
989
996
  ## Links
990
997
 
991
998
  Existing RSpec plugins which have OpenAPI integration:
@@ -32,12 +32,12 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
32
32
  # Skip if the property using $ref is not found in the parent schema. The property may be removed.
33
33
  next if nested_schema.nil?
34
34
 
35
- schema_name = extract_schema_name(base.dig(*paths))&.to_sym
35
+ schema_name = extract_schema_name(base.dig(*paths)).to_sym
36
36
  fresh_schemas[schema_name] ||= {}
37
- RSpec::OpenAPI::SchemaMerger.merge!(fresh_schemas[schema_name], nested_schema)
37
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!(fresh_schemas[schema_name], nested_schema)
38
38
  end
39
39
 
40
- RSpec::OpenAPI::SchemaMerger.merge!(base, { components: { schemas: fresh_schemas } })
40
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!(base, { components: { schemas: fresh_schemas } })
41
41
  RSpec::OpenAPI::SchemaCleaner.cleanup_components_schemas!(base, { components: { schemas: fresh_schemas } })
42
42
  end
43
43
 
@@ -49,7 +49,7 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
49
49
  schema_name = extract_schema_name(ref_link)
50
50
  schema_body = dig_schema(fresh, paths.grep_v(Integer))
51
51
 
52
- RSpec::OpenAPI::SchemaMerger.merge!(acc, { schema_name => schema_body })
52
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!(acc, { schema_name => schema_body })
53
53
  end
54
54
  end
55
55
 
@@ -101,8 +101,9 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
101
101
  [paths] if schema_ref?(dig_schema(base, paths)&.dig(:$ref))
102
102
  end
103
103
 
104
+ # Only ever given the value at a path ending in $ref, which is the link itself.
104
105
  def extract_schema_name(ref_link)
105
- ref_link&.delete_prefix(SCHEMA_REF_PREFIX)
106
+ ref_link.delete_prefix(SCHEMA_REF_PREFIX)
106
107
  end
107
108
 
108
109
  def schema_ref?(ref_link)
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Records every request/response exchange issued in an example so that
4
+ # the exchange matching `request_pattern` can be looked up afterwards.
3
5
  module RSpec::OpenAPI::ExchangeRecorder
4
6
  THREAD_KEY = :rspec_openapi_exchanges
5
7
  VERBS = [:get, :post, :put, :patch, :delete, :head, :options].freeze
6
8
 
9
+ # Wraps HTTP verb methods to capture the exchange right after each request.
7
10
  module VerbTracking
8
11
  VERBS.each do |verb|
9
12
  define_method(verb) do |*args, &block|
@@ -12,10 +12,10 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
12
12
 
13
13
  route, path = find_rails_route(fixed_request)
14
14
 
15
+ # find_rails_route yields nil, [route, nil] or [route, path]; a path always
16
+ # comes with the route it was taken from.
15
17
  return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless path
16
18
 
17
- raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil?
18
-
19
19
  attrs = SharedExtractor.attributes(example)
20
20
  # :controller and :action always exist. :format is added when routes is configured as such.
21
21
  # TODO: Use .except(:controller, :action, :format) when we drop support for Ruby 2.x
@@ -1,43 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class << RSpec::OpenAPI::HashHelper = Object.new
4
- def paths_to_all_fields(obj)
5
- case obj
6
- when Hash
7
- obj.each.flat_map do |k, v|
8
- k = k.to_sym
9
- [[k]] + paths_to_all_fields(v).map { |x| [k, *x] }
10
- end
11
- when Array
12
- obj.flat_map.with_index do |value, i|
13
- [[i]] + paths_to_all_fields(value).map { |x| [i, *x] }
14
- end
15
- else
16
- []
17
- end
18
- end
19
-
4
+ # Paths in obj matching a dot separated selector, where `*` is any one key or
5
+ # array index. Only paths of exactly the selector's length can match, so this
6
+ # descends the branches that still match rather than listing every path in the
7
+ # document and filtering. A document built from a large suite holds tens of
8
+ # thousands of nodes and these selectors run dozens of times per build.
20
9
  def matched_paths(obj, selector)
21
10
  selector_parts = selector.split('.').map(&:to_sym)
22
- paths_to_all_fields(obj).select do |key_parts|
23
- key_parts.size == selector_parts.size && key_parts.zip(selector_parts).all? do |kp, sp|
24
- kp == sp || (sp == :* && !kp.nil?)
25
- end
26
- end
11
+ matches = []
12
+ collect_matches(obj, selector_parts, 0, [], matches)
13
+ matches
27
14
  end
28
15
 
29
16
  def matched_paths_deeply_nested(obj, begin_selector, end_selector)
30
- path_depth_sizes = paths_to_all_fields(obj).map(&:size).uniq
31
- path_depth_sizes.map do |depth|
32
- begin_selector_count = begin_selector.is_a?(Symbol) ? 0 : begin_selector.count('.')
33
- end_selector_count = end_selector.is_a?(Symbol) ? 0 : end_selector.count('.')
34
- diff = depth - begin_selector_count - end_selector_count
35
- if diff >= 0
36
- selector = "#{begin_selector}.#{'*.' * diff}#{end_selector}"
37
- matched_paths(obj, selector)
17
+ begin_depth = begin_selector.to_s.count('.')
18
+ end_depth = end_selector.to_s.count('.')
19
+
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?
25
+
26
+ matched_paths(obj, "#{begin_selector}.#{'*.' * gap}#{end_selector}")
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def collect_matches(obj, selector_parts, depth, prefix, matches)
33
+ part = selector_parts[depth]
34
+ last = depth == selector_parts.size - 1
35
+
36
+ each_child(obj) do |key, value|
37
+ next unless part == :* || part == key
38
+
39
+ path = [*prefix, key]
40
+ if last
41
+ matches << path
38
42
  else
39
- []
43
+ collect_matches(value, selector_parts, depth + 1, path, matches)
40
44
  end
41
- end.flatten(1)
45
+ end
46
+ end
47
+
48
+ def each_child(obj)
49
+ 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 }
52
+ end
53
+ end
54
+
55
+ def max_depth(obj)
56
+ 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
60
+ end
42
61
  end
43
62
  end
@@ -18,18 +18,16 @@ class << RSpec::OpenAPI::KeyTransformer = Object.new
18
18
  end
19
19
  end
20
20
 
21
+ # `examples` is a map of named examples under a media type, whose names are
22
+ # normalized here. Under a schema it is a JSON Schema keyword holding a plain
23
+ # array instead, which has no names to normalize.
21
24
  def symbolize_examples(value)
22
- case value
23
- when Hash
24
- value.to_h do |k, v|
25
- k = k.downcase.tr(' ', '_') unless k.is_a?(Symbol)
25
+ return symbolize(value) unless value.is_a?(Hash)
26
26
 
27
- [k.to_sym, symbolize(v)]
28
- end
29
- when Array
30
- value.map { |v| symbolize(v) }
31
- else
32
- value
27
+ value.to_h do |k, v|
28
+ k = k.downcase.tr(' ', '_') unless k.is_a?(Symbol)
29
+
30
+ [k.to_sym, symbolize(v)]
33
31
  end
34
32
  end
35
33
 
@@ -5,6 +5,10 @@ require 'minitest'
5
5
  module RSpec::OpenAPI::Minitest
6
6
  Example = Struct.new(:context, :description, :metadata, :file_path)
7
7
 
8
+ # Guards path_records against concurrent appends from Minitest's
9
+ # thread-based parallel executor (parallelize_me!).
10
+ RECORD_MUTEX = Mutex.new
11
+
8
12
  module RunPatch
9
13
  def run(*args)
10
14
  result = super
@@ -14,7 +18,12 @@ module RSpec::OpenAPI::Minitest
14
18
  example = Example.new(self, human_name, {}, file_path)
15
19
  path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
16
20
  record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor)
17
- RSpec::OpenAPI.path_records[path] << record if record
21
+ if record
22
+ RECORD_MUTEX.synchronize do
23
+ RSpec::OpenAPI::ParallelRecords.schedule_dump!
24
+ RSpec::OpenAPI.path_records[path] << record
25
+ end
26
+ end
18
27
  end
19
28
  result
20
29
  end
@@ -43,6 +52,7 @@ if ENV['OPENAPI']
43
52
  Minitest::Test.prepend RSpec::OpenAPI::Minitest::RunPatch
44
53
 
45
54
  Minitest.after_run do
55
+ RSpec::OpenAPI::ParallelRecords.merge!
46
56
  result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records)
47
57
  result_recorder.record_results!
48
58
  puts result_recorder.error_message if result_recorder.errors?
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+ require 'stringio'
6
+ require 'tmpdir'
7
+ require 'yaml'
8
+
9
+ # Collects records across processes when the test framework forks parallel
10
+ # workers, like Rails' `parallelize`. Records accumulate in each worker's own
11
+ # memory, so without this the main process would write the schema from an empty
12
+ # set. Each worker dumps its records to a shared directory on exit, and the
13
+ # main process merges every dump back in before recording results.
14
+ #
15
+ # The dumps go through YAML.safe_load with an allowlist rather than Marshal,
16
+ # so reading them cannot instantiate arbitrary objects even if the directory
17
+ # were tampered with.
18
+ module RSpec::OpenAPI::ParallelRecords
19
+ # The pid of the process that loaded this gem. Forked workers inherit the
20
+ # constant, which is how they know they are not the main process.
21
+ MAIN_PID = Process.pid
22
+
23
+ # Unpredictable per-run token. Workers inherit it across fork, while other
24
+ # local users cannot guess it to pre-create or plant files in the dump
25
+ # directory, which lives in the world-writable system tmpdir.
26
+ RUN_ID = SecureRandom.hex(16)
27
+
28
+ # Marks the stand-in a multipart upload leaves in a dump. The schema
29
+ # builder only ever asks an upload for its class and metadata, never for
30
+ # its content, so the file itself does not need to survive the trip.
31
+ UPLOADED_FILE_MARKER = '__rspec_openapi_uploaded_file'
32
+
33
+ class << self
34
+ def worker?
35
+ Process.pid != MAIN_PID
36
+ end
37
+
38
+ # Called when a worker records an example. Registered lazily from inside
39
+ # the worker because an at_exit inherited from the main process has already
40
+ # run there by the time workers fork: minitest executes the whole suite
41
+ # from an at_exit hook, so handlers the gem registered at load time are
42
+ # popped before any fork happens.
43
+ def schedule_dump!
44
+ return if @dump_scheduled || !worker?
45
+
46
+ @dump_scheduled = true
47
+ at_exit { dump! }
48
+ end
49
+
50
+ def dump!
51
+ return if RSpec::OpenAPI.path_records.empty?
52
+
53
+ FileUtils.mkdir_p(dump_dir, mode: 0o700)
54
+ encoded = RSpec::OpenAPI.path_records.transform_values do |records|
55
+ records.map { |record| transform_record(record) { |value| encode(value) } }
56
+ end
57
+ File.write(File.join(dump_dir, "#{Process.pid}.yaml"), YAML.dump(encoded))
58
+ end
59
+
60
+ def merge!
61
+ Dir.glob(File.join(dump_dir, '*.yaml')).sort.each do |file|
62
+ records_by_path = YAML.safe_load(File.read(file), permitted_classes: permitted_classes, aliases: true)
63
+ records_by_path.each do |path, records|
64
+ decoded = records.map { |record| transform_record(record) { |value| decode(value) } }
65
+ RSpec::OpenAPI.path_records[path].concat(decoded)
66
+ end
67
+ end
68
+ ensure
69
+ FileUtils.rm_rf(dump_dir)
70
+ end
71
+
72
+ private
73
+
74
+ def dump_dir
75
+ File.join(Dir.tmpdir, "rspec-openapi-records-#{RUN_ID}")
76
+ end
77
+
78
+ # Built lazily because ActiveSupport is not loaded in every setup. Rails
79
+ # request objects hand back HashWithIndifferentAccess for parameters.
80
+ def permitted_classes
81
+ classes = [Symbol, RSpec::OpenAPI::Record]
82
+ classes << ActiveSupport::HashWithIndifferentAccess if defined?(ActiveSupport::HashWithIndifferentAccess)
83
+ classes
84
+ end
85
+
86
+ def transform_record(record, &block)
87
+ RSpec::OpenAPI::Record.new(**record.to_h.transform_values(&block))
88
+ end
89
+
90
+ # Uploads hold an open Tempfile, which no serializer can represent, so
91
+ # dump their metadata instead.
92
+ def encode(value)
93
+ case value
94
+ when Array then value.map { |item| encode(item) }
95
+ when Hash then value.transform_values { |item| encode(item) }
96
+ when defined?(ActionDispatch::Http::UploadedFile) && ActionDispatch::Http::UploadedFile
97
+ { UPLOADED_FILE_MARKER => { 'filename' => value.original_filename, 'type' => value.content_type } }
98
+ else value
99
+ end
100
+ end
101
+
102
+ # Rebuilds a real UploadedFile so the schema builder's class checks match,
103
+ # backed by an empty StringIO in place of the worker's Tempfile.
104
+ def decode(value)
105
+ return value unless value.is_a?(Array) || value.is_a?(Hash)
106
+ return value.map { |item| decode(item) } if value.is_a?(Array)
107
+
108
+ meta = value[UPLOADED_FILE_MARKER]
109
+ if meta && value.size == 1 && defined?(ActionDispatch::Http::UploadedFile)
110
+ ActionDispatch::Http::UploadedFile.new(tempfile: StringIO.new, filename: meta['filename'], type: meta['type'])
111
+ else
112
+ value.transform_values { |item| decode(item) }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -51,12 +51,13 @@ class RSpec::OpenAPI::ResultRecorder
51
51
  RSpec::OpenAPI::OperationConverter.normalize!(spec)
52
52
  schema = RSpec::OpenAPI::DefaultSchema.build(title)
53
53
  schema[:info].merge!(RSpec::OpenAPI.info)
54
+ # Normalize the document once here rather than on every merge below.
54
55
  RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
55
56
  new_from_zero = {}
56
57
  records.each do |record|
57
58
  record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
58
- RSpec::OpenAPI::SchemaMerger.merge!(spec, record_schema)
59
- RSpec::OpenAPI::SchemaMerger.merge!(new_from_zero, record_schema)
59
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!(spec, record_schema)
60
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!(new_from_zero, record_schema)
60
61
  rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
61
62
  @error_records[e] = record # Avoid failing the build
62
63
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ class << RSpec::OpenAPI::SchemaBuilder
4
+ # Merges multiple observed schema variations of the same value (array items,
5
+ # stream items, repeated object properties) into a single OpenAPI schema.
6
+ # Divergent types are combined into oneOf, and keys that only appear in some
7
+ # variations are marked nullable.
8
+ VariationMerger = Object.new
9
+
10
+ class << VariationMerger
11
+ def merge_variations(variations)
12
+ # Drop empty `{}` schemas (e.g. items of an empty array) — they carry no
13
+ # type info and would otherwise spuriously mark every property of their
14
+ # populated siblings as nullable via the missing-key nullable rule.
15
+ non_empty = variations.reject(&:empty?)
16
+ return {} if non_empty.empty?
17
+ return non_empty.first if non_empty.size == 1
18
+
19
+ types = non_empty.map { |v| v[:type] }.compact.uniq
20
+ return one_of_schema(non_empty) if types.size > 1
21
+
22
+ case types.first
23
+ when 'object' then merge_object_variations(non_empty)
24
+ when 'array' then merge_array_item_variations(non_empty)
25
+ else non_empty.first
26
+ end
27
+ end
28
+
29
+ def merge_non_object_item_variations(schemas)
30
+ nullable_only = ->(schema) { schema.keys == [:nullable] }
31
+ typed = schemas.reject(&nullable_only)
32
+
33
+ merged = typed.size == 1 ? typed.first.dup : merge_multi(typed)
34
+ merged[:nullable] = true if schemas.any?(&nullable_only) && merged.is_a?(Hash)
35
+ merged
36
+ end
37
+
38
+ # Merge the per-key property schemas of multiple object variations.
39
+ # When `allow_recursive_merge` is true, objects are recursively merged via
40
+ # merge_variations and existing oneOf entries are flattened.
41
+ # When false (callsite: array-items merging), divergent property variations
42
+ # become oneOf without recursive descent.
43
+ def merge_property_variations(variations, allow_recursive_merge:)
44
+ property_keys(variations).each_with_object({}) do |key, merged_props|
45
+ all = variations.map { |v| v[:properties]&.[](key) }
46
+ prop_variations = all.reject { |p| p.nil? || p.keys == [:nullable] }
47
+ has_nullable = nullable_present?(all, recursive: allow_recursive_merge)
48
+
49
+ next if prop_variations.empty? && !has_nullable
50
+
51
+ merged_props[key] = merge_single_property(prop_variations, has_nullable,
52
+ variations_total: variations.size,
53
+ allow_recursive_merge: allow_recursive_merge,)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def merge_object_variations(variations)
60
+ {
61
+ type: 'object',
62
+ properties: merge_property_variations(variations, allow_recursive_merge: true),
63
+ required: variations.map { |v| v[:required] || [] }.reduce(:&) || [],
64
+ }
65
+ end
66
+
67
+ # Merge multiple array schemas by merging their `items` schemas.
68
+ def merge_array_item_variations(variations)
69
+ { type: 'array', items: merge_variations(variations.map { |v| v[:items] }.compact) }
70
+ end
71
+
72
+ def property_keys(variations)
73
+ variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
74
+ end
75
+
76
+ # `recursive` mirrors merge_variations' original rule that also treats
77
+ # `{ ..., nullable: true }` as a nullable signal. Array-items merging only
78
+ # looks at outright nil or `{ nullable: true }` markers.
79
+ def nullable_present?(all_props, recursive:)
80
+ all_props.any? do |p|
81
+ p.nil? || (p.is_a?(Hash) && (p.keys == [:nullable] || (recursive && p[:nullable] == true)))
82
+ end
83
+ end
84
+
85
+ def merge_single_property(prop_variations, has_nullable, variations_total:, allow_recursive_merge:)
86
+ return { nullable: true } if prop_variations.empty?
87
+
88
+ merged =
89
+ if prop_variations.size == 1
90
+ prop_variations.first.dup
91
+ else
92
+ merge_multi(prop_variations)
93
+ end
94
+
95
+ return merged unless merged.is_a?(Hash)
96
+
97
+ # In recursive mode, multi-variation merges also flag nullable when the key
98
+ # only appeared in some of the source variations.
99
+ needs_nullable =
100
+ if allow_recursive_merge && prop_variations.size > 1
101
+ has_nullable || prop_variations.size < variations_total
102
+ else
103
+ has_nullable
104
+ end
105
+ merged[:nullable] = true if needs_nullable
106
+ merged
107
+ end
108
+
109
+ # Combine multiple variations of the same property: flatten existing oneOf
110
+ # entries, recurse into objects and arrays, and combine divergent types into
111
+ # oneOf. Scalar variations of a single type collapse to the first schema.
112
+ def merge_multi(prop_variations)
113
+ return { oneOf: flatten_one_of(prop_variations) } if prop_variations.any? { |p| p.key?(:oneOf) }
114
+
115
+ prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
116
+ return one_of_schema(prop_variations) if prop_types.size > 1
117
+
118
+ case prop_types.first
119
+ when 'array' then merge_array_item_variations(prop_variations)
120
+ when 'object' then merge_variations(prop_variations)
121
+ else prop_variations.first.dup
122
+ end
123
+ end
124
+
125
+ def flatten_one_of(prop_variations)
126
+ prop_variations.each_with_object([]) do |prop, options|
127
+ clean = without_nullable(prop)
128
+ if clean.key?(:oneOf)
129
+ options.concat(clean[:oneOf])
130
+ elsif !clean.empty?
131
+ options << clean
132
+ end
133
+ end.uniq
134
+ end
135
+
136
+ def without_nullable(prop)
137
+ prop.reject { |k, _| k == :nullable }
138
+ end
139
+
140
+ def one_of_schema(variations)
141
+ { oneOf: variations.map { |p| without_nullable(p) }.uniq }
142
+ end
143
+ end
144
+
145
+ private_constant :VariationMerger
146
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  RSpec::OpenAPI::SchemaBuilder = Object.new
4
4
  require_relative 'schema_builder/build_context'
5
+ require_relative 'schema_builder/variation_merger'
5
6
 
6
7
  class << RSpec::OpenAPI::SchemaBuilder
7
8
  # @param [RSpec::OpenAPI::Record] record
@@ -74,7 +75,7 @@ class << RSpec::OpenAPI::SchemaBuilder
74
75
  return nil if items.empty?
75
76
 
76
77
  variations = items.map { |item| build_property(item, ctx) }
77
- build_merged_schema_from_variations(variations)
78
+ VariationMerger.merge_variations(variations)
78
79
  end
79
80
 
80
81
  # Returns the per-content-type body (schema + optional example/examples).
@@ -131,20 +132,18 @@ class << RSpec::OpenAPI::SchemaBuilder
131
132
  build_parameter(key, value, location: 'header', record: record)
132
133
  end
133
134
 
134
- parameters&.empty? ? nil : parameters
135
+ parameters.empty? ? nil : parameters
135
136
  end
136
137
 
137
- # `compound_name` and `required` follow from `location`:
138
- # path/header params are always required and use bracketed names like
139
- # `key[subkey]`; query params are pre-flattened and may be optional.
138
+ # Path and header params are always required; query params are pre-flattened
139
+ # by flatten_query_params and may be optional.
140
140
  def build_parameter(key, value, location:, record:)
141
141
  is_query = location == 'query'
142
- compound_name = !is_query
143
142
  required = is_query ? record.required_request_params.include?(key) : true
144
143
  cast = try_cast(value)
145
144
  ctx = BuildContext.new(record: record, context: :request, key: key, path: key.to_s)
146
145
  {
147
- name: compound_name ? build_parameter_name(key, value) : key,
146
+ name: key.to_s,
148
147
  in: location,
149
148
  required: required,
150
149
  schema: build_property(cast, ctx),
@@ -159,16 +158,6 @@ class << RSpec::OpenAPI::SchemaBuilder
159
158
  end
160
159
  end
161
160
 
162
- def build_parameter_name(key, value)
163
- key = key.to_s
164
- if value.is_a?(Hash) && (value_keys = value.keys).size == 1
165
- value_key = value_keys.first
166
- build_parameter_name("#{key}[#{value_key}]", value[value_key])
167
- else
168
- key
169
- end
170
- end
171
-
172
161
  def flatten_query_params(params, parent_key = nil)
173
162
  params.each_with_object({}) do |(key, value), result|
174
163
  full_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
@@ -279,8 +268,6 @@ class << RSpec::OpenAPI::SchemaBuilder
279
268
  # distinguishable from "no override"; for :hybrid_additional_properties
280
269
  # plain lookup is enough because only Hash values are meaningful.
281
270
  def infer_override(path, record, context, kind)
282
- return nil unless record
283
-
284
271
  overrides = record.send("#{context}_#{kind}")
285
272
  return nil unless overrides
286
273
 
@@ -333,141 +320,11 @@ class << RSpec::OpenAPI::SchemaBuilder
333
320
  def build_array_items_schema(array, ctx)
334
321
  schemas = array.map { |item| build_property(item, ctx) }
335
322
  return schemas.first if schemas.size == 1
336
- return merge_non_object_item_variations(schemas) unless array.all?(Hash)
323
+ return VariationMerger.merge_non_object_item_variations(schemas) unless array.all?(Hash)
337
324
 
338
325
  merged = schemas.first.dup
339
- merged[:properties] = merge_property_variations(schemas, allow_recursive_merge: false)
326
+ merged[:properties] = VariationMerger.merge_property_variations(schemas, allow_recursive_merge: false)
340
327
  merged[:required] = schemas.map { |s| s[:required] || [] }.reduce(:&) || []
341
328
  merged
342
329
  end
343
-
344
- def merge_non_object_item_variations(schemas)
345
- nullable_only = ->(schema) { schema.keys == [:nullable] }
346
- typed = schemas.reject(&nullable_only)
347
-
348
- merged = typed.size == 1 ? typed.first.dup : merge_multi(typed)
349
- merged[:nullable] = true if schemas.any?(&nullable_only) && merged.is_a?(Hash)
350
- merged
351
- end
352
-
353
- def build_merged_schema_from_variations(variations)
354
- # Drop empty `{}` schemas (e.g. items of an empty array) — they carry no
355
- # type info and would otherwise spuriously mark every property of their
356
- # populated siblings as nullable via the missing-key nullable rule.
357
- non_empty = variations.reject(&:empty?)
358
- return {} if non_empty.empty?
359
- return non_empty.first if non_empty.size == 1
360
-
361
- types = non_empty.map { |v| v[:type] }.compact.uniq
362
- return one_of_schema(non_empty) if types.size > 1
363
-
364
- case types.first
365
- when 'object'
366
- {
367
- type: 'object',
368
- properties: merge_property_variations(non_empty, allow_recursive_merge: true),
369
- required: non_empty.map { |v| v[:required] || [] }.reduce(:&) || [],
370
- }
371
- when 'array'
372
- items_variations = non_empty.map { |v| v[:items] }.compact
373
- { type: 'array', items: build_merged_schema_from_variations(items_variations) }
374
- else
375
- non_empty.first
376
- end
377
- end
378
-
379
- # Merge the per-key property schemas of multiple object variations.
380
- # When `allow_recursive_merge` is true, objects are recursively merged via
381
- # build_merged_schema_from_variations and existing oneOf entries are flattened.
382
- # When false (callsite: array-items merging), divergent property variations
383
- # become oneOf without recursive descent.
384
- def merge_property_variations(variations, allow_recursive_merge:)
385
- property_keys(variations).each_with_object({}) do |key, merged_props|
386
- all = variations.map { |v| v[:properties]&.[](key) }
387
- prop_variations = all.reject { |p| p.nil? || p.keys == [:nullable] }
388
- has_nullable = nullable_present?(all, recursive: allow_recursive_merge)
389
-
390
- next if prop_variations.empty? && !has_nullable
391
-
392
- merged_props[key] = merge_single_property(prop_variations, has_nullable,
393
- variations_total: variations.size,
394
- allow_recursive_merge: allow_recursive_merge,)
395
- end
396
- end
397
-
398
- def property_keys(variations)
399
- variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
400
- end
401
-
402
- # `recursive` mirrors build_merged_schema_from_variations' original rule that
403
- # also treats `{ ..., nullable: true }` as a nullable signal. Array-items
404
- # merging only looks at outright nil or `{ nullable: true }` markers.
405
- def nullable_present?(all_props, recursive:)
406
- all_props.any? do |p|
407
- p.nil? || (p.is_a?(Hash) && (p.keys == [:nullable] || (recursive && p[:nullable] == true)))
408
- end
409
- end
410
-
411
- def merge_single_property(prop_variations, has_nullable, variations_total:, allow_recursive_merge:)
412
- return { nullable: true } if prop_variations.empty?
413
-
414
- merged =
415
- if prop_variations.size == 1
416
- prop_variations.first.dup
417
- else
418
- merge_multi(prop_variations)
419
- end
420
-
421
- return merged unless merged.is_a?(Hash)
422
-
423
- # In recursive mode, multi-variation merges also flag nullable when the key
424
- # only appeared in some of the source variations.
425
- needs_nullable =
426
- if allow_recursive_merge && prop_variations.size > 1
427
- has_nullable || prop_variations.size < variations_total
428
- else
429
- has_nullable
430
- end
431
- merged[:nullable] = true if needs_nullable
432
- merged
433
- end
434
-
435
- # Combine multiple variations of the same property: flatten existing oneOf
436
- # entries, recurse into objects and arrays, and combine divergent types into
437
- # oneOf. Scalar variations of a single type collapse to the first schema.
438
- def merge_multi(prop_variations)
439
- return { oneOf: flatten_one_of(prop_variations) } if prop_variations.any? { |p| p.key?(:oneOf) }
440
-
441
- prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
442
- return one_of_schema(prop_variations) if prop_types.size > 1
443
-
444
- case prop_types.first
445
- when 'array'
446
- items_variations = prop_variations.map { |p| p[:items] }.compact
447
- { type: 'array', items: build_merged_schema_from_variations(items_variations) }
448
- when 'object'
449
- build_merged_schema_from_variations(prop_variations)
450
- else
451
- prop_variations.first.dup
452
- end
453
- end
454
-
455
- def flatten_one_of(prop_variations)
456
- prop_variations.each_with_object([]) do |prop, options|
457
- clean = without_nullable(prop)
458
- if clean.key?(:oneOf)
459
- options.concat(clean[:oneOf])
460
- elsif !clean.empty?
461
- options << clean
462
- end
463
- end.uniq
464
- end
465
-
466
- def without_nullable(prop)
467
- prop.reject { |k, _| k == :nullable }
468
- end
469
-
470
- def one_of_schema(variations)
471
- { oneOf: variations.map { |p| without_nullable(p) }.uniq }
472
- end
473
330
  end
@@ -79,10 +79,9 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
79
79
 
80
80
  private
81
81
 
82
- # Recursively remove temporary fields like :_example_key and :_example_name from the schema
82
+ # Recursively remove temporary fields like :_example_key and :_example_name from the schema.
83
+ # Every caller has already established that it holds a Hash.
83
84
  def cleanup_temporary_fields!(hash)
84
- return unless hash.is_a?(Hash)
85
-
86
85
  hash.delete(:_example_key)
87
86
  hash.delete(:_example_summary)
88
87
  hash.delete(:_example_name)
@@ -113,9 +112,9 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
113
112
  path_definition.delete(:parameters) if path_definition[:parameters].empty?
114
113
  end
115
114
 
116
- def cleanup_array!(base, spec, selector, fields_for_identity = [])
115
+ def cleanup_array!(base, spec, selector, fields_for_identity)
117
116
  marshal = lambda do |obj|
118
- Marshal.dump(slice(obj, fields_for_identity))
117
+ Marshal.dump(obj.slice(*fields_for_identity))
119
118
  end
120
119
 
121
120
  RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
@@ -127,33 +126,20 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
127
126
  target_array.select! { |e| spec_identities.include?(marshal.call(e)) }
128
127
  target_array.sort_by! { |param| fields_for_identity.map { |f| param[f] }.join('-') }
129
128
  # Keep the last duplicate to produce the result stably
130
- deduplicated = target_array.reverse.uniq { |param| slice(param, fields_for_identity) }.reverse
129
+ deduplicated = target_array.reverse.uniq { |param| param.slice(*fields_for_identity) }.reverse
131
130
  target_array.replace(deduplicated)
132
131
  end
133
132
  base
134
133
  end
135
134
 
135
+ # Every selector above names at least two segments, so a matched path always
136
+ # has a parent to delete the entry from.
136
137
  def cleanup_hash!(base, spec, selector)
137
138
  RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
138
139
  exist_in_base = !base.dig(*paths).nil?
139
140
  not_in_spec = spec.dig(*paths).nil?
140
- if exist_in_base && not_in_spec
141
- if paths.size == 1
142
- base.delete(paths.last)
143
- else
144
- parent_node = base.dig(*paths[0..-2])
145
- parent_node.delete(paths.last)
146
- end
147
- end
141
+ base.dig(*paths[0..-2]).delete(paths.last) if exist_in_base && not_in_spec
148
142
  end
149
143
  base
150
144
  end
151
-
152
- def slice(obj, fields_for_identity)
153
- if fields_for_identity.any?
154
- obj.slice(*fields_for_identity)
155
- else
156
- obj
157
- end
158
- end
159
145
  end
@@ -1,12 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class << RSpec::OpenAPI::SchemaMerger = Object.new
4
+ # Merge spec into base, normalizing the keys of both to symbols first.
5
+ #
4
6
  # @param [Hash] base
5
7
  # @param [Hash] spec
6
8
  def merge!(base, spec)
7
- spec = RSpec::OpenAPI::KeyTransformer.symbolize(spec)
8
9
  base.replace(RSpec::OpenAPI::KeyTransformer.symbolize(base))
9
- merge_schema!(base, spec)
10
+ merge_normalized!(base, spec)
11
+ end
12
+
13
+ # Same, for a base whose keys are already symbols.
14
+ #
15
+ # Normalizing base rebuilds every hash and array in it. Callers that merge
16
+ # record after record into one document hold a base of thousands of nodes and
17
+ # merge into it twice per request, so paying for that rebuild each time makes
18
+ # the whole build quadratic in the number of requests. A document read back
19
+ # from a file is normalized by SchemaFile, and anything this file builds is
20
+ # normalized by construction, so those callers can skip it.
21
+ #
22
+ # @param [Hash] base
23
+ # @param [Hash] spec
24
+ def merge_normalized!(base, spec)
25
+ merge_schema!(base, RSpec::OpenAPI::KeyTransformer.symbolize(spec))
10
26
  end
11
27
 
12
28
  SIMILARITY_THRESHOLD = 0.5
@@ -180,7 +196,11 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
180
196
  end
181
197
 
182
198
  def convert_example_to_examples!(hash)
183
- name = RSpec::OpenAPI::ExampleKey.normalize(hash.delete(:_example_key)) || 'default'
199
+ # This writes straight into the document rather than going through
200
+ # KeyTransformer, so symbolize the name here the way symbolize_examples
201
+ # would. Otherwise the next merge sees a string key beside the symbol one
202
+ # the builder produces and treats them as two different examples.
203
+ name = RSpec::OpenAPI::ExampleKey.normalize(hash.delete(:_example_key))&.to_sym || :default
184
204
  summary = hash.delete(:_example_summary)
185
205
  value = hash.delete(:example)
186
206
  example = {}
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module OpenAPI
5
- VERSION = '0.32.0'
5
+ VERSION = '0.33.0'
6
6
  end
7
7
  end
data/lib/rspec/openapi.rb CHANGED
@@ -8,6 +8,7 @@ require 'rspec/openapi/operation_converter'
8
8
  require 'rspec/openapi/stream_parser'
9
9
  require 'rspec/openapi/record_builder'
10
10
  require 'rspec/openapi/exchange_recorder'
11
+ require 'rspec/openapi/parallel_records'
11
12
  require 'rspec/openapi/result_recorder'
12
13
  require 'rspec/openapi/schema_builder'
13
14
  require 'rspec/openapi/schema_file'
@@ -131,10 +132,13 @@ module RSpec::OpenAPI
131
132
  # Allow Rails request specs to issue extra verbs (e.g. QUERY); ActionDispatch
132
133
  # otherwise rejects unknown verbs. No-op outside Rails.
133
134
  def register_http_methods(methods)
134
- # simplecov:disable branch non-Rails guard for roda/hanami; the suite always loads Rails
135
+ # :nocov:
136
+ # Guards users without Rails. Every job here installs it, so this arm is
137
+ # unreachable from the suite. SimpleCov's token is `nocov`, so the marker
138
+ # this used to carry never actually excluded anything.
135
139
  return unless defined?(ActionDispatch::Request::HTTP_METHODS)
136
140
 
137
- # simplecov:enable
141
+ # :nocov:
138
142
  Array(methods).each do |method|
139
143
  verb = method.to_s.upcase
140
144
  next if ActionDispatch::Request::HTTP_METHODS.include?(verb)
@@ -149,6 +153,9 @@ end
149
153
  if ENV['OPENAPI']
150
154
  RSpec::OpenAPI::Config.load_environment_settings
151
155
 
156
+ # :nocov:
157
+ # These two rescues report a framework the user does not have. Every job here
158
+ # installs both Hanami and Rails, so no run can reach them.
152
159
  begin
153
160
  require 'hanami'
154
161
  rescue LoadError
@@ -164,6 +171,7 @@ if ENV['OPENAPI']
164
171
  else
165
172
  require 'rspec/openapi/extractors/rails'
166
173
  end
174
+ # :nocov:
167
175
  end
168
176
 
169
177
  require 'rspec/openapi/minitest_hooks' if Object.const_defined?('Minitest')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-openapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.0
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takashi Kokubun
@@ -77,12 +77,14 @@ files:
77
77
  - lib/rspec/openapi/minitest_hooks.rb
78
78
  - lib/rspec/openapi/nullable_converter.rb
79
79
  - lib/rspec/openapi/operation_converter.rb
80
+ - lib/rspec/openapi/parallel_records.rb
80
81
  - lib/rspec/openapi/record.rb
81
82
  - lib/rspec/openapi/record_builder.rb
82
83
  - lib/rspec/openapi/result_recorder.rb
83
84
  - lib/rspec/openapi/rspec_hooks.rb
84
85
  - lib/rspec/openapi/schema_builder.rb
85
86
  - lib/rspec/openapi/schema_builder/build_context.rb
87
+ - lib/rspec/openapi/schema_builder/variation_merger.rb
86
88
  - lib/rspec/openapi/schema_cleaner.rb
87
89
  - lib/rspec/openapi/schema_file.rb
88
90
  - lib/rspec/openapi/schema_merger.rb
@@ -97,7 +99,7 @@ licenses:
97
99
  metadata:
98
100
  homepage_uri: https://github.com/exoego/rspec-openapi
99
101
  source_code_uri: https://github.com/exoego/rspec-openapi
100
- changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.32.0
102
+ changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.33.0
101
103
  rubygems_mfa_required: 'true'
102
104
  rdoc_options: []
103
105
  require_paths: