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.
@@ -1,21 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'hash_helper'
4
+
3
5
  # Converts between the internal `nullable: true` form and the 3.1+ JSON Schema
4
6
  # null form (`type: [..., 'null']`). The builder and merger only use `nullable`;
5
7
  # normalize! runs on read, to_json_schema! on write.
6
8
  class << RSpec::OpenAPI::NullableConverter = Object.new
7
- # Keys whose values hold user data, not schemas. Their contents must never be
8
- # rewritten, or a recorded example/default that happens to contain a field
9
- # named `type` or `nullable` would be silently mangled.
10
- DATA_KEYS = [:example, :examples, :default, :enum].freeze
9
+ NULL_VARIANT = { type: 'null' }.freeze
10
+ NULLABLE_VARIANT = { nullable: true }.freeze
11
11
 
12
+ # Variants come before their oneOf, so a oneOf sees its variants converted.
12
13
  def to_json_schema!(node)
13
- each_schema(node) { |schema| nullable_to_type_null!(schema) }
14
+ RSpec::OpenAPI::HashHelper.each_schema(node) { |schema| nullable_to_type_null!(schema) }
14
15
  node
15
16
  end
16
17
 
17
18
  def normalize!(node)
18
- each_schema(node) { |schema| type_null_to_nullable!(schema) }
19
+ RSpec::OpenAPI::HashHelper.each_schema(node) { |schema| type_null_to_nullable!(schema) }
19
20
  node
20
21
  end
21
22
 
@@ -23,6 +24,7 @@ class << RSpec::OpenAPI::NullableConverter = Object.new
23
24
 
24
25
  def nullable_to_type_null!(schema)
25
26
  return unless schema.delete(:nullable)
27
+ return insert_null_variant!(schema[:oneOf]) if schema[:oneOf] && !schema.key?(:type)
26
28
 
27
29
  schema[:type] =
28
30
  case (type = schema[:type])
@@ -32,7 +34,23 @@ class << RSpec::OpenAPI::NullableConverter = Object.new
32
34
  end
33
35
  end
34
36
 
37
+ def insert_null_variant!(variants)
38
+ return if variants.any? { |variant| accepts_null?(variant) }
39
+
40
+ index = variants.index { |variant| variant.is_a?(Hash) && variant[:type].to_s > 'null' } || variants.size
41
+ variants.insert(index, NULL_VARIANT.dup)
42
+ end
43
+
44
+ def accepts_null?(variant)
45
+ variant.is_a?(Hash) && Array(variant[:type]).include?('null')
46
+ end
47
+
48
+ # A null variant beside a typed oneOf is written back as a variant, so it
49
+ # stays one. Beside a typeless oneOf it folds into the wrapper, where the
50
+ # builder and merger keep nullable.
35
51
  def type_null_to_nullable!(schema)
52
+ schema[:nullable] = true if !schema.key?(:type) && schema[:oneOf]&.delete(NULLABLE_VARIANT)
53
+
36
54
  case (type = schema[:type])
37
55
  when 'null'
38
56
  schema.delete(:type)
@@ -49,17 +67,4 @@ class << RSpec::OpenAPI::NullableConverter = Object.new
49
67
  schema[:nullable] = true
50
68
  end
51
69
  end
52
-
53
- # Yield every schema Hash in the tree, skipping the data subtrees in DATA_KEYS. Each
54
- # transform mutates only its own node's keys, so the following each still
55
- # iterates a stable Hash.
56
- def each_schema(node, &block)
57
- case node
58
- when Hash
59
- yield node
60
- node.each { |key, value| each_schema(value, &block) unless DATA_KEYS.include?(key) }
61
- when Array
62
- node.each { |value| each_schema(value, &block) }
63
- end
64
- end
65
70
  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,19 +47,24 @@ 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)
51
57
  RSpec::OpenAPI::OperationConverter.normalize!(spec)
52
58
  schema = RSpec::OpenAPI::DefaultSchema.build(title)
53
59
  schema[:info].merge!(RSpec::OpenAPI.info)
54
- # Normalize the document once here rather than on every merge below.
55
- RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
56
- new_from_zero = {}
60
+ # SchemaFile#read already symbolized the document, so no merge here needs
61
+ # to normalize it again.
62
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!(spec, schema)
57
63
  records.each do |record|
58
64
  record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
59
- RSpec::OpenAPI::SchemaMerger.merge_normalized!(spec, record_schema)
60
- 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)
61
68
  rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
62
69
  @error_records[e] = record # Avoid failing the build
63
70
  end
@@ -67,14 +74,21 @@ class RSpec::OpenAPI::ResultRecorder
67
74
  end
68
75
 
69
76
  def execute_post_process_hook(path, records, spec)
70
- 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))
71
83
  end
72
84
 
73
85
  def cleanup_schema!(new_from_zero, spec)
74
86
  RSpec::OpenAPI::SchemaCleaner.cleanup_conflicting_security_parameters!(spec)
75
- RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
76
- RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
77
- 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)
78
92
  RSpec::OpenAPI::OperationConverter.to_additional_operations!(spec) if RSpec::OpenAPI.supports_additional_operations?
79
93
  RSpec::OpenAPI::SchemaSorter.deep_sort!(spec)
80
94
  RSpec::OpenAPI::NullableConverter.to_json_schema!(spec) if RSpec::OpenAPI.json_schema_based?
@@ -10,16 +10,24 @@ RSpec.configuration.after(:each) do |example|
10
10
  if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false
11
11
  path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
12
12
  record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor)
13
- RSpec::OpenAPI.path_records[path] << record if record
13
+ if record
14
+ RSpec::OpenAPI.path_records[path] << record
15
+ RSpec::OpenAPI::PartialRun.record_file(example.metadata[:absolute_file_path])
16
+ end
14
17
  end
15
18
  end
16
19
 
17
20
  RSpec.configuration.after(:suite) do
18
- result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records)
21
+ partial = RSpec::OpenAPI::PartialRun.rspec?
22
+ result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records, partial: partial)
19
23
  result_recorder.record_results!
24
+ reporter = RSpec.configuration.reporter
25
+ if (notice = RSpec::OpenAPI::PartialRun.notice(partial))
26
+ reporter.message notice
27
+ end
20
28
  if result_recorder.errors?
21
29
  error_message = result_recorder.error_message
22
30
  colorizer = RSpec::Core::Formatters::ConsoleCodes
23
- RSpec.configuration.reporter.message colorizer.wrap(error_message, :failure)
31
+ reporter.message colorizer.wrap(error_message, :failure)
24
32
  end
25
33
  end
@@ -4,16 +4,30 @@ class << RSpec::OpenAPI::SchemaBuilder
4
4
  # Lookup context threaded through schema building. Bundles the metadata
5
5
  # needed to resolve formats, enums, and additionalProperties overrides for
6
6
  # a value at a given path under a record's request/response side.
7
- BuildContext = Struct.new(:record, :context, :path, :key, keyword_init: true) do
7
+ #
8
+ # Positional members: one is built per property of every body, and
9
+ # keyword_init would add a keyword hash to each. The path is only ever looked
10
+ # up in the enum and additionalProperties maps of the record, so it is built
11
+ # for a side of a record that holds one.
12
+ BuildContext = Struct.new(:record, :context, :path, :key, :lookups) do
13
+ def self.root(record, context, path = nil, key = nil)
14
+ lookups = record[ENUM_MEMBERS.fetch(context)] ||
15
+ OVERRIDE_MEMBERS.any? { |_kind, members| record[members.fetch(context)] }
16
+ new(record, context, path, key, lookups)
17
+ end
18
+
8
19
  def descend(child_key)
9
- self.class.new(
10
- record: record, context: context, key: child_key,
11
- path: path ? "#{path}.#{child_key}" : child_key.to_s,
12
- )
20
+ self.class.new(record, context, child_path(child_key), child_key, lookups)
21
+ end
22
+
23
+ def child_path(child_key)
24
+ return nil unless lookups
25
+
26
+ path ? "#{path}.#{child_key}" : child_key.to_s
13
27
  end
14
28
 
15
29
  def for_array_items
16
- self.class.new(record: record, context: context, path: path, key: nil)
30
+ self.class.new(record, context, path, nil, lookups)
17
31
  end
18
32
  end
19
33
  private_constant :BuildContext
@@ -12,7 +12,7 @@ class << RSpec::OpenAPI::SchemaBuilder
12
12
  # Drop empty `{}` schemas (e.g. items of an empty array) — they carry no
13
13
  # type info and would otherwise spuriously mark every property of their
14
14
  # populated siblings as nullable via the missing-key nullable rule.
15
- non_empty = variations.reject(&:empty?)
15
+ non_empty = widen_integers(variations.reject(&:empty?))
16
16
  return {} if non_empty.empty?
17
17
  return non_empty.first if non_empty.size == 1
18
18
 
@@ -27,11 +27,10 @@ class << RSpec::OpenAPI::SchemaBuilder
27
27
  end
28
28
 
29
29
  def merge_non_object_item_variations(schemas)
30
- nullable_only = ->(schema) { schema.keys == [:nullable] }
31
- typed = schemas.reject(&nullable_only)
30
+ typed = schemas.reject { |schema| nullable_only?(schema) }
32
31
 
33
32
  merged = typed.size == 1 ? typed.first.dup : merge_multi(typed)
34
- merged[:nullable] = true if schemas.any?(&nullable_only) && merged.is_a?(Hash)
33
+ merged[:nullable] = true if schemas.any? { |schema| nullable_only?(schema) } && merged.is_a?(Hash)
35
34
  merged
36
35
  end
37
36
 
@@ -43,25 +42,42 @@ class << RSpec::OpenAPI::SchemaBuilder
43
42
  def merge_property_variations(variations, allow_recursive_merge:)
44
43
  property_keys(variations).each_with_object({}) do |key, merged_props|
45
44
  all = variations.map { |v| v[:properties]&.[](key) }
46
- prop_variations = all.reject { |p| p.nil? || p.keys == [:nullable] }
45
+ read_only = lift_read_only!(all)
46
+ prop_variations = all.reject { |p| p.nil? || nullable_only?(p) }
47
47
  has_nullable = nullable_present?(all, recursive: allow_recursive_merge)
48
48
 
49
49
  next if prop_variations.empty? && !has_nullable
50
50
 
51
- merged_props[key] = merge_single_property(prop_variations, has_nullable,
52
- variations_total: variations.size,
53
- allow_recursive_merge: allow_recursive_merge,)
51
+ merged = merge_single_property(prop_variations, has_nullable,
52
+ variations_total: variations.size,
53
+ allow_recursive_merge: allow_recursive_merge,)
54
+ merged[:readOnly] = read_only unless read_only.nil?
55
+ merged_props[key] = merged
54
56
  end
55
57
  end
56
58
 
57
59
  private
58
60
 
61
+ # The variations of one property come from one side of one exchange, so
62
+ # they agree on readOnly. The merge rebuilds the schema, so the caller puts
63
+ # it back on the result, beside a oneOf like nullable. The schemas are the
64
+ # builder's own, so the flag comes off them in place.
65
+ def lift_read_only!(all)
66
+ read_only = all.find { |p| p.is_a?(Hash) && p.key?(:readOnly) }&.[](:readOnly)
67
+ all.each { |p| p&.delete(:readOnly) } unless read_only.nil?
68
+ read_only
69
+ end
70
+
59
71
  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
- }
72
+ merged = { type: 'object' }
73
+ if variations.any? { |v| v.key?(:properties) }
74
+ merged[:properties] = merge_property_variations(variations, allow_recursive_merge: true)
75
+ merged[:required] = variations.map { |v| v[:required] || [] }.reduce(:&) || []
76
+ end
77
+ # Every variation sits at one path, so they all carry the same override.
78
+ additional = variations.find { |v| v.key?(:additionalProperties) }
79
+ merged[:additionalProperties] = additional[:additionalProperties] if additional
80
+ merged
65
81
  end
66
82
 
67
83
  # Merge multiple array schemas by merging their `items` schemas.
@@ -70,7 +86,10 @@ class << RSpec::OpenAPI::SchemaBuilder
70
86
  end
71
87
 
72
88
  def property_keys(variations)
73
- variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
89
+ keys = []
90
+ variations.each { |v| v[:properties]&.each_key { |key| keys << key } }
91
+ keys.uniq!
92
+ keys
74
93
  end
75
94
 
76
95
  # `recursive` mirrors merge_variations' original rule that also treats
@@ -78,10 +97,14 @@ class << RSpec::OpenAPI::SchemaBuilder
78
97
  # looks at outright nil or `{ nullable: true }` markers.
79
98
  def nullable_present?(all_props, recursive:)
80
99
  all_props.any? do |p|
81
- p.nil? || (p.is_a?(Hash) && (p.keys == [:nullable] || (recursive && p[:nullable] == true)))
100
+ p.nil? || (p.is_a?(Hash) && (nullable_only?(p) || (recursive && p[:nullable] == true)))
82
101
  end
83
102
  end
84
103
 
104
+ def nullable_only?(schema)
105
+ schema.size == 1 && schema.key?(:nullable)
106
+ end
107
+
85
108
  def merge_single_property(prop_variations, has_nullable, variations_total:, allow_recursive_merge:)
86
109
  return { nullable: true } if prop_variations.empty?
87
110
 
@@ -112,6 +135,7 @@ class << RSpec::OpenAPI::SchemaBuilder
112
135
  def merge_multi(prop_variations)
113
136
  return { oneOf: flatten_one_of(prop_variations) } if prop_variations.any? { |p| p.key?(:oneOf) }
114
137
 
138
+ prop_variations = widen_integers(prop_variations)
115
139
  prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
116
140
  return one_of_schema(prop_variations) if prop_types.size > 1
117
141
 
@@ -122,15 +146,23 @@ class << RSpec::OpenAPI::SchemaBuilder
122
146
  end
123
147
  end
124
148
 
149
+ # Every integer is a number, so integers seen beside numbers are numbers.
150
+ def widen_integers(variations)
151
+ return variations unless variations.any? { |v| v[:type] == 'number' }
152
+
153
+ variations.reject { |v| v[:type] == 'integer' }
154
+ end
155
+
125
156
  def flatten_one_of(prop_variations)
126
- prop_variations.each_with_object([]) do |prop, options|
157
+ options = prop_variations.each_with_object([]) do |prop, acc|
127
158
  clean = without_nullable(prop)
128
159
  if clean.key?(:oneOf)
129
- options.concat(clean[:oneOf])
160
+ acc.concat(clean[:oneOf])
130
161
  elsif !clean.empty?
131
- options << clean
162
+ acc << clean
132
163
  end
133
- end.uniq
164
+ end
165
+ sort_by_type(options.uniq)
134
166
  end
135
167
 
136
168
  def without_nullable(prop)
@@ -138,7 +170,13 @@ class << RSpec::OpenAPI::SchemaBuilder
138
170
  end
139
171
 
140
172
  def one_of_schema(variations)
141
- { oneOf: variations.map { |p| without_nullable(p) }.uniq }
173
+ { oneOf: sort_by_type(variations.map { |p| without_nullable(p) }.uniq) }
174
+ end
175
+
176
+ # The same order the merger keeps, so the document does not depend on which
177
+ # example ran first.
178
+ def sort_by_type(options)
179
+ options.sort_by.with_index { |option, index| [option[:type].to_s, index] }
142
180
  end
143
181
  end
144
182