rspec-openapi 0.33.1 → 0.35.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: eb54f98b3234ebee5ea46c0c703f094265d37c17f8fc1c8dcfec5c1f70c88667
4
- data.tar.gz: 30972c7a4dd8387e1db47d954b3b5e380ee9a0d6282981ba73d14007e5fcdce8
3
+ metadata.gz: d7298e79ff60a23925281c724390eed37544bef37a47f7ba85e6f5ef2d28bf3f
4
+ data.tar.gz: b1798ffc05aa81f206b4f50e5fffeb6317ac902e113c1c3449bf1f0ed4be437d
5
5
  SHA512:
6
- metadata.gz: 19552292efc3339da373ca62b6e7c20ce23546d5ba81bd7b245ce6796dca729a12fd4138c905b11d90b9892e67f0a3abc18f15eaf7ea8f153f3a4c4b5f76de3c
7
- data.tar.gz: ff754813db11c90118d4169428547b84c7c5a7163384124e94c15eefacff9dd62e701cc6c0c37716cb889dccba061086858ccc32161ed41f4a275d2a0eb8fcff
6
+ metadata.gz: 8d5b3eb347258591c559c26f4450179c7a329a67bc1a6b01e7f92b1666dd8ab251c604f93ac28c92387277649d5b17b94d37a38be31875f86872028e431e6fad
7
+ data.tar.gz: 4ee74f1f40a64081044245d88dfe01ac43a40ef397467477e99cf54ea3368b2af080c44712f3873727989cf94e7c27229e2aaed900db0df58f1cfc2d0b818005
@@ -10,14 +10,26 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
10
10
  def update!(base, fresh)
11
11
  # Top-level schema: Used as the body of request or response
12
12
  top_level_refs = paths_to_top_level_refs(base)
13
- return if top_level_refs.empty?
14
-
15
13
  fresh_schemas = build_fresh_schemas(top_level_refs, base, fresh)
16
14
 
17
- # Nested schema: References in Top-level schemas. May contain some top-level schema.
18
- generated_schema_names = fresh_schemas.keys
19
- nested_refs = find_non_top_level_nested_refs(base, generated_schema_names)
20
- nested_refs.each do |paths|
15
+ # Nested schema: References in top-level schemas. May contain some top-level schema.
16
+ # A parent to dig fresh data out of only exists once some top-level schema was found.
17
+ apply_component_nested_refs!(fresh_schemas, base) unless top_level_refs.empty?
18
+
19
+ # Nested schema: References inline in a request/response body, not inside a component.
20
+ # Unlike the above, this doesn't depend on any top-level schema being found first.
21
+ apply_inline_nested_refs!(fresh_schemas, base, fresh)
22
+
23
+ return if fresh_schemas.empty?
24
+
25
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!(base, { components: { schemas: fresh_schemas } })
26
+ RSpec::OpenAPI::SchemaCleaner.cleanup_components_schemas!(base, { components: { schemas: fresh_schemas } })
27
+ end
28
+
29
+ private
30
+
31
+ def apply_component_nested_refs!(fresh_schemas, base)
32
+ find_non_top_level_nested_refs(base, fresh_schemas.keys).each do |paths|
21
33
  # Slice between the parent name and the element before "$ref"
22
34
  # ["components", "schema", "Table", "properties", "database", "$ref"]
23
35
  # 0 1 2 ^....................^
@@ -32,27 +44,60 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
32
44
  # Skip if the property using $ref is not found in the parent schema. The property may be removed.
33
45
  next if nested_schema.nil?
34
46
 
35
- schema_name = extract_schema_name(base.dig(*paths)).to_sym
36
- fresh_schemas[schema_name] ||= {}
37
- RSpec::OpenAPI::SchemaMerger.merge_normalized!(fresh_schemas[schema_name], nested_schema)
47
+ merge_fresh_schema!(fresh_schemas, base, paths, nested_schema)
38
48
  end
49
+ end
39
50
 
40
- RSpec::OpenAPI::SchemaMerger.merge_normalized!(base, { components: { schemas: fresh_schemas } })
41
- RSpec::OpenAPI::SchemaCleaner.cleanup_components_schemas!(base, { components: { schemas: fresh_schemas } })
51
+ # No promoted parent schema to dig from here, so pull the fresh data from the same
52
+ # relative path in the raw document instead.
53
+ def apply_inline_nested_refs!(fresh_schemas, base, fresh)
54
+ find_inline_nested_refs(base, fresh_schemas.keys).each do |paths|
55
+ nested_schema = dig_like(fresh, base, paths[0..-2])
56
+ next if nested_schema.nil?
57
+
58
+ merge_fresh_schema!(fresh_schemas, base, paths, nested_schema)
59
+ end
42
60
  end
43
61
 
44
- private
62
+ # Digs fresh along a path of base. This run may have recorded one shape of a
63
+ # oneOf only, or its variants in another order, so at a variant index the step
64
+ # picks the fresh variant of the type base has there.
65
+ def dig_like(fresh, base, paths)
66
+ node = fresh
67
+ paths.each_with_index do |step, index|
68
+ node = if index.positive? && paths[index - 1] == :oneOf
69
+ RSpec::OpenAPI::HashHelper.variant_of_type(node, base.dig(*paths.take(index + 1), :type))
70
+ elsif step != :oneOf
71
+ node[step]
72
+ else
73
+ node
74
+ end
75
+ return nil unless node.is_a?(Hash)
76
+ end
77
+ node
78
+ end
79
+
80
+ def merge_fresh_schema!(fresh_schemas, base, paths, nested_schema)
81
+ schema_name = extract_schema_name(base.dig(*paths))
82
+ merge_schema!(fresh_schemas, schema_name, nested_schema)
83
+ end
45
84
 
46
85
  def build_fresh_schemas(references, base, fresh)
47
- references.inject({}) do |acc, paths|
86
+ references.each_with_object({}) do |paths, fresh_schemas|
48
87
  ref_link = dig_schema(base, paths)[:$ref]
49
88
  schema_name = extract_schema_name(ref_link)
50
89
  schema_body = dig_schema(fresh, paths.grep_v(Integer))
51
90
 
52
- RSpec::OpenAPI::SchemaMerger.merge_normalized!(acc, { schema_name => schema_body })
91
+ merge_schema!(fresh_schemas, schema_name, schema_body)
53
92
  end
54
93
  end
55
94
 
95
+ # Under a `schemas` key the merger knows it merges schemas, so two shapes of
96
+ # one component become a oneOf like they would inline.
97
+ def merge_schema!(fresh_schemas, schema_name, schema)
98
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!({ schemas: fresh_schemas }, { schemas: { schema_name => schema } })
99
+ end
100
+
56
101
  def dig_schema(obj, paths)
57
102
  # Response code can be an integer
58
103
  paths = paths.map { |path| path.is_a?(Integer) ? path : path.to_sym }
@@ -75,12 +120,26 @@ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
75
120
  end
76
121
 
77
122
  def find_non_top_level_nested_refs(base, generated_names)
78
- nested_refs = [
79
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties.*.$ref'),
80
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties.*.items.$ref'),
81
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'oneOf.*.$ref'),
123
+ nested_refs = RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(
124
+ base, 'components.schemas', 'properties.*.$ref', 'properties.*.items.$ref', 'oneOf.*.$ref',
125
+ )
126
+ reject_already_generated_refs(nested_refs, base, generated_names)
127
+ end
128
+
129
+ # Like find_non_top_level_nested_refs, but for a $ref inside a schema that's inline
130
+ # rather than itself a component, so it's not reachable by walking components.schemas.
131
+ def find_inline_nested_refs(base, generated_names)
132
+ roots = [
133
+ 'paths.*.*.requestBody.content.application/json.schema',
134
+ 'paths.*.*.responses.*.content.application/json.schema',
82
135
  ]
83
- # Reject already-generated schemas to reduce unnecessary loop
136
+ nested_refs = roots.flat_map do |root|
137
+ RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, root, 'properties.*.$ref', 'properties.*.items.$ref')
138
+ end
139
+ reject_already_generated_refs(nested_refs, base, generated_names)
140
+ end
141
+
142
+ def reject_already_generated_refs(nested_refs, base, generated_names)
84
143
  nested_refs.reject do |paths|
85
144
  ref_link = base.dig(*paths)
86
145
  schema_name = extract_schema_name(ref_link)
@@ -7,56 +7,121 @@ class << RSpec::OpenAPI::HashHelper = Object.new
7
7
  # document and filtering. A document built from a large suite holds tens of
8
8
  # thousands of nodes and these selectors run dozens of times per build.
9
9
  def matched_paths(obj, selector)
10
- selector_parts = selector.split('.').map(&:to_sym)
11
10
  matches = []
12
- collect_matches(obj, selector_parts, 0, [], matches)
11
+ collect_matches(obj, split_selector(selector), 0, [], matches)
13
12
  matches
14
13
  end
15
14
 
16
- def matched_paths_deeply_nested(obj, begin_selector, end_selector)
17
- begin_depth = begin_selector.to_s.count('.')
18
- end_depth = end_selector.to_s.count('.')
15
+ # Paths that start with a match of begin_selector and end with a match of an
16
+ # end selector, with any number of keys in between: the matches of the first
17
+ # end selector sorted by length, then those of the next, each length in
18
+ # document order.
19
+ #
20
+ # The subtree under a begin match is walked once for all the end selectors,
21
+ # checking the tail of every path against each. Walking it once per possible
22
+ # length instead makes this cost the depth of the document times its size.
23
+ def matched_paths_deeply_nested(obj, begin_selector, *end_selectors)
24
+ end_parts = end_selectors.map { |selector| split_selector(selector) }
25
+ matches = end_parts.map { [] }
26
+ matched_paths(obj, begin_selector).each do |prefix|
27
+ collect_suffix_matches(obj.dig(*prefix), end_parts, prefix.dup, prefix.size, matches)
28
+ end
29
+ matches.flat_map { |found| found.group_by(&:size).sort_by(&:first).flat_map(&:last) }
30
+ end
19
31
 
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?
32
+ # Keys whose values hold user data, not schemas. Their contents must never be
33
+ # rewritten, or a recorded example/default that happens to contain a field
34
+ # named `type` or `nullable` would be silently mangled.
35
+ DATA_KEYS = [:example, :examples, :default, :enum, :const].freeze
25
36
 
26
- matched_paths(obj, "#{begin_selector}.#{'*.' * gap}#{end_selector}")
37
+ # Yields every hash in obj outside the data subtrees in DATA_KEYS, each after
38
+ # its descendants. The entries of a `properties` map are schemas whatever
39
+ # their names, so the map itself is skipped and each entry is yielded.
40
+ def each_schema(obj, &block)
41
+ case obj
42
+ when Hash
43
+ obj.each do |key, value|
44
+ if key == :properties && value.is_a?(Hash)
45
+ value.each_value { |property| each_schema(property, &block) }
46
+ elsif !DATA_KEYS.include?(key)
47
+ each_schema(value, &block)
48
+ end
49
+ end
50
+ yield obj
51
+ when Array
52
+ obj.each { |value| each_schema(value, &block) }
27
53
  end
28
54
  end
29
55
 
56
+ # The variant of a schema that has the type, where a schema without a oneOf
57
+ # is its only variant.
58
+ def variant_of_type(schema, type)
59
+ (schema[:oneOf] || [schema]).find { |variant| variant.is_a?(Hash) && variant[:type] == type }
60
+ end
61
+
30
62
  private
31
63
 
32
- def collect_matches(obj, selector_parts, depth, prefix, matches)
64
+ def split_selector(selector)
65
+ selector.split('.').map(&:to_sym)
66
+ end
67
+
68
+ # `path` is the stack of keys from the root to obj, pushed and popped in
69
+ # place, so a path is only ever copied for a match.
70
+ def collect_matches(obj, selector_parts, depth, path, matches)
33
71
  part = selector_parts[depth]
34
72
  last = depth == selector_parts.size - 1
35
73
 
36
74
  each_child(obj) do |key, value|
37
75
  next unless part == :* || part == key
38
76
 
39
- path = [*prefix, key]
77
+ path.push(key)
40
78
  if last
41
- matches << path
79
+ matches << path.dup
42
80
  else
43
81
  collect_matches(value, selector_parts, depth + 1, path, matches)
44
82
  end
83
+ path.pop
45
84
  end
46
85
  end
47
86
 
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 }
87
+ # 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.
89
+ def collect_suffix_matches(obj, end_parts, path, prefix_size, matches)
90
+ each_child(obj) do |key, value|
91
+ path.push(key)
92
+ index = 0
93
+ while index < end_parts.size
94
+ parts = end_parts[index]
95
+ matches[index] << path.dup if path.size >= prefix_size + parts.size && suffix_matches?(path, parts)
96
+ index += 1
97
+ end
98
+ collect_suffix_matches(value, end_parts, path, prefix_size, matches)
99
+ path.pop
52
100
  end
53
101
  end
54
102
 
55
- def max_depth(obj)
103
+ def suffix_matches?(path, parts)
104
+ offset = path.size - parts.size
105
+ index = 0
106
+ while index < parts.size
107
+ part = parts[index]
108
+ return false unless part == :* || part == path[offset + index]
109
+
110
+ index += 1
111
+ end
112
+ true
113
+ end
114
+
115
+ def each_child(obj)
56
116
  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
117
+ when Hash
118
+ obj.each { |key, value| yield key.to_sym, value }
119
+ when Array
120
+ index = 0
121
+ obj.each do |value|
122
+ yield index, value
123
+ index += 1
124
+ end
60
125
  end
61
126
  end
62
127
  end
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class << RSpec::OpenAPI::KeyTransformer = Object.new
4
+ # The hashes are rebuilt with each rather than to_h, which yields every
5
+ # entry as a fresh pair array.
4
6
  def symbolize(value)
5
7
  case value
6
8
  when Hash
7
- value.to_h do |k, v|
8
- if k.to_sym == :examples
9
- [k.to_sym, symbolize_examples(v)]
10
- else
11
- [k.to_sym, symbolize(v)]
12
- end
9
+ result = {}
10
+ value.each do |k, v|
11
+ k = k.to_sym
12
+ result[k] = k == :examples ? symbolize_examples(v) : symbolize(v)
13
13
  end
14
+ result
14
15
  when Array
15
16
  value.map { |v| symbolize(v) }
16
17
  else
@@ -24,17 +25,21 @@ class << RSpec::OpenAPI::KeyTransformer = Object.new
24
25
  def symbolize_examples(value)
25
26
  return symbolize(value) unless value.is_a?(Hash)
26
27
 
27
- value.to_h do |k, v|
28
+ result = {}
29
+ value.each do |k, v|
28
30
  k = k.downcase.tr(' ', '_') unless k.is_a?(Symbol)
29
31
 
30
- [k.to_sym, symbolize(v)]
32
+ result[k.to_sym] = symbolize(v)
31
33
  end
34
+ result
32
35
  end
33
36
 
34
37
  def stringify(value)
35
38
  case value
36
39
  when Hash
37
- value.to_h { |k, v| [k.to_s, stringify(v)] }
40
+ result = {}
41
+ value.each { |k, v| result[k.to_s] = stringify(v) }
42
+ result
38
43
  when Array
39
44
  value.map { |v| stringify(v) }
40
45
  else
@@ -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,15 +24,33 @@ 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])
29
31
  when nil then 'null'
32
+ when Array then type | ['null']
30
33
  else [type, 'null']
31
34
  end
32
35
  end
33
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.
34
51
  def type_null_to_nullable!(schema)
52
+ schema[:nullable] = true if !schema.key?(:type) && schema[:oneOf]&.delete(NULLABLE_VARIANT)
53
+
35
54
  case (type = schema[:type])
36
55
  when 'null'
37
56
  schema.delete(:type)
@@ -48,17 +67,4 @@ class << RSpec::OpenAPI::NullableConverter = Object.new
48
67
  schema[:nullable] = true
49
68
  end
50
69
  end
51
-
52
- # Yield every schema Hash in the tree, skipping the data subtrees in DATA_KEYS. Each
53
- # transform mutates only its own node's keys, so the following each still
54
- # iterates a stable Hash.
55
- def each_schema(node, &block)
56
- case node
57
- when Hash
58
- yield node
59
- node.each { |key, value| each_schema(value, &block) unless DATA_KEYS.include?(key) }
60
- when Array
61
- node.each { |value| each_schema(value, &block) }
62
- end
63
- end
64
70
  end
@@ -51,8 +51,9 @@ 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.
55
- RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
54
+ # SchemaFile#read already symbolized the document, so no merge here needs
55
+ # to normalize it again.
56
+ RSpec::OpenAPI::SchemaMerger.merge_normalized!(spec, schema)
56
57
  new_from_zero = {}
57
58
  records.each do |record|
58
59
  record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
@@ -4,16 +4,16 @@ 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.
10
+ BuildContext = Struct.new(:record, :context, :path, :key) do
8
11
  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
- )
12
+ self.class.new(record, context, path ? "#{path}.#{child_key}" : child_key.to_s, child_key)
13
13
  end
14
14
 
15
15
  def for_array_items
16
- self.class.new(record: record, context: context, path: path, key: nil)
16
+ self.class.new(record, context, path, nil)
17
17
  end
18
18
  end
19
19
  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
 
@@ -57,11 +57,15 @@ class << RSpec::OpenAPI::SchemaBuilder
57
57
  private
58
58
 
59
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
- }
60
+ merged = { type: 'object' }
61
+ if variations.any? { |v| v.key?(:properties) }
62
+ merged[:properties] = merge_property_variations(variations, allow_recursive_merge: true)
63
+ merged[:required] = variations.map { |v| v[:required] || [] }.reduce(:&) || []
64
+ end
65
+ # Every variation sits at one path, so they all carry the same override.
66
+ additional = variations.find { |v| v.key?(:additionalProperties) }
67
+ merged[:additionalProperties] = additional[:additionalProperties] if additional
68
+ merged
65
69
  end
66
70
 
67
71
  # Merge multiple array schemas by merging their `items` schemas.
@@ -112,6 +116,7 @@ class << RSpec::OpenAPI::SchemaBuilder
112
116
  def merge_multi(prop_variations)
113
117
  return { oneOf: flatten_one_of(prop_variations) } if prop_variations.any? { |p| p.key?(:oneOf) }
114
118
 
119
+ prop_variations = widen_integers(prop_variations)
115
120
  prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
116
121
  return one_of_schema(prop_variations) if prop_types.size > 1
117
122
 
@@ -122,15 +127,23 @@ class << RSpec::OpenAPI::SchemaBuilder
122
127
  end
123
128
  end
124
129
 
130
+ # Every integer is a number, so integers seen beside numbers are numbers.
131
+ def widen_integers(variations)
132
+ return variations unless variations.any? { |v| v[:type] == 'number' }
133
+
134
+ variations.reject { |v| v[:type] == 'integer' }
135
+ end
136
+
125
137
  def flatten_one_of(prop_variations)
126
- prop_variations.each_with_object([]) do |prop, options|
138
+ options = prop_variations.each_with_object([]) do |prop, acc|
127
139
  clean = without_nullable(prop)
128
140
  if clean.key?(:oneOf)
129
- options.concat(clean[:oneOf])
141
+ acc.concat(clean[:oneOf])
130
142
  elsif !clean.empty?
131
- options << clean
143
+ acc << clean
132
144
  end
133
- end.uniq
145
+ end
146
+ sort_by_type(options.uniq)
134
147
  end
135
148
 
136
149
  def without_nullable(prop)
@@ -138,7 +151,13 @@ class << RSpec::OpenAPI::SchemaBuilder
138
151
  end
139
152
 
140
153
  def one_of_schema(variations)
141
- { oneOf: variations.map { |p| without_nullable(p) }.uniq }
154
+ { oneOf: sort_by_type(variations.map { |p| without_nullable(p) }.uniq) }
155
+ end
156
+
157
+ # The same order the merger keeps, so the document does not depend on which
158
+ # example ran first.
159
+ def sort_by_type(options)
160
+ options.sort_by.with_index { |option, index| [option[:type].to_s, index] }
142
161
  end
143
162
  end
144
163
 
@@ -5,6 +5,17 @@ require_relative 'schema_builder/build_context'
5
5
  require_relative 'schema_builder/variation_merger'
6
6
 
7
7
  class << RSpec::OpenAPI::SchemaBuilder
8
+ # Record members holding the per-context lookup maps, by context (and kind).
9
+ ENUM_MEMBERS = { request: :request_enum, response: :response_enum }.freeze
10
+ OVERRIDE_MEMBERS = {
11
+ additional_properties: {
12
+ request: :request_additional_properties, response: :response_additional_properties,
13
+ },
14
+ hybrid_additional_properties: {
15
+ request: :request_hybrid_additional_properties, response: :response_hybrid_additional_properties,
16
+ },
17
+ }.freeze
18
+
8
19
  # @param [RSpec::OpenAPI::Record] record
9
20
  # @return [Hash]
10
21
  def build(record)
@@ -56,7 +67,7 @@ class << RSpec::OpenAPI::SchemaBuilder
56
67
  def build_content(record)
57
68
  disposition = normalize_content_disposition(record.response_content_disposition)
58
69
  content_type = normalize_content_type(record.response_content_type)
59
- ctx = BuildContext.new(record: record, context: :response)
70
+ ctx = BuildContext.new(record, :response)
60
71
 
61
72
  if RSpec::OpenAPI.supports_item_schema? && RSpec::OpenAPI.sequential_media_type?(content_type)
62
73
  item_schema = build_stream_item_schema(record.response_body, content_type, ctx)
@@ -141,7 +152,7 @@ class << RSpec::OpenAPI::SchemaBuilder
141
152
  is_query = location == 'query'
142
153
  required = is_query ? record.required_request_params.include?(key) : true
143
154
  cast = try_cast(value)
144
- ctx = BuildContext.new(record: record, context: :request, key: key, path: key.to_s)
155
+ ctx = BuildContext.new(record, :request, key.to_s, key)
145
156
  {
146
157
  name: key.to_s,
147
158
  in: location,
@@ -153,7 +164,7 @@ class << RSpec::OpenAPI::SchemaBuilder
153
164
 
154
165
  def build_response_headers(record)
155
166
  record.response_headers.to_h do |key, value|
156
- ctx = BuildContext.new(record: record, context: :response, key: key, path: key.to_s)
167
+ ctx = BuildContext.new(record, :response, key.to_s, key)
157
168
  [key, { schema: build_property(try_cast(value), ctx) }]
158
169
  end
159
170
  end
@@ -175,7 +186,7 @@ class << RSpec::OpenAPI::SchemaBuilder
175
186
  return nil if record.status >= 400 && record.request_example_mode != :multiple
176
187
 
177
188
  content_type = normalize_content_type(record.request_content_type)
178
- ctx = BuildContext.new(record: record, context: :request)
189
+ ctx = BuildContext.new(record, :request)
179
190
  schema = build_property(record.request_params, ctx)
180
191
  example = example_enabled?(record) ? build_example(record.request_params) : nil
181
192
 
@@ -207,10 +218,10 @@ class << RSpec::OpenAPI::SchemaBuilder
207
218
  return
208
219
  end
209
220
 
210
- property[:properties] = value.to_h do |k, v|
211
- [k, build_property(v, ctx.descend(k))]
212
- end
213
- property[:required] = property[:properties].keys
221
+ properties = {}
222
+ value.each { |k, v| properties[k] = build_property(v, ctx.descend(k)) }
223
+ property[:properties] = properties
224
+ property[:required] = properties.keys
214
225
  apply_additional_properties(property, override,
215
226
  infer_override(ctx.path, ctx.record, ctx.context, :hybrid_additional_properties),)
216
227
  end
@@ -259,7 +270,7 @@ class << RSpec::OpenAPI::SchemaBuilder
259
270
  return nil if !path || !record
260
271
 
261
272
  # Keys are already normalized to strings by SharedExtractor.normalize_enum
262
- record.send("#{context}_enum")&.[](path.to_s)
273
+ record[ENUM_MEMBERS.fetch(context)]&.[](path.to_s)
263
274
  end
264
275
 
265
276
  # Looks up an override for the current path under one of the per-context
@@ -268,7 +279,7 @@ class << RSpec::OpenAPI::SchemaBuilder
268
279
  # distinguishable from "no override"; for :hybrid_additional_properties
269
280
  # plain lookup is enough because only Hash values are meaningful.
270
281
  def infer_override(path, record, context, kind)
271
- overrides = record.send("#{context}_#{kind}")
282
+ overrides = record[OVERRIDE_MEMBERS.fetch(kind).fetch(context)]
272
283
  return nil unless overrides
273
284
 
274
285
  # path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
@@ -282,9 +293,7 @@ class << RSpec::OpenAPI::SchemaBuilder
282
293
  return true if value == 'true'
283
294
  return false if value == 'false'
284
295
 
285
- Integer(value)
286
- rescue TypeError, ArgumentError
287
- value
296
+ Integer(value, exception: false) || value
288
297
  end
289
298
 
290
299
  def build_example(value)
@@ -13,6 +13,7 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
13
13
  def cleanup_components_schemas!(base, spec)
14
14
  cleanup_hash!(base, spec, 'components.schemas.*')
15
15
  cleanup_hash!(base, spec, 'components.schemas.*.properties.*')
16
+ cleanup_one_of_properties!(base, spec, 'components.schemas.*')
16
17
  end
17
18
 
18
19
  # Cleanup specific elements that exists in the base but not in the spec
@@ -39,6 +40,10 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
39
40
  cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.example.*')
40
41
  cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.examples.*')
41
42
 
43
+ # cleanup properties of each variant once a body became a oneOf
44
+ cleanup_one_of_properties!(base, spec, 'paths.*.*.requestBody.content.application/json.schema')
45
+ cleanup_one_of_properties!(base, spec, 'paths.*.*.responses.*.content.application/json.schema')
46
+
42
47
  # cleanup temporary fields used for internal processing
43
48
  cleanup_temporary_fields!(base)
44
49
 
@@ -50,35 +55,36 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
50
55
 
51
56
  return if security_schemes.empty?
52
57
 
53
- paths_to_security_definitions = RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'paths', 'security')
54
-
55
- paths_to_security_definitions.each do |path|
56
- parent_path_definition = base.dig(*path.take(path.length - 1))
57
-
58
+ each_operation(base) do |operation|
58
59
  security_schemes.each do |security_scheme_name, security_scheme|
59
- remove_parameters_conflicting_with_security_scheme!(
60
- parent_path_definition, security_scheme, security_scheme_name,
61
- )
60
+ remove_parameters_conflicting_with_security_scheme!(operation, security_scheme, security_scheme_name)
62
61
  end
63
62
  end
64
63
  end
65
64
 
65
+ # A recorded example is data, so a field in it named `required` stays however
66
+ # it is shaped.
66
67
  def cleanup_empty_required_array!(base)
67
- paths_to_objects = [
68
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties'),
69
- *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'paths', 'properties'),
70
- ]
71
- paths_to_objects.each do |path|
72
- parent = base.dig(*path.take(path.length - 1))
73
- next unless parent
74
-
75
- # "required" array must not be present if empty
76
- parent.delete(:required) if parent[:required] && parent[:required].empty?
68
+ [base.dig(:components, :schemas), base[:paths]].each do |root|
69
+ RSpec::OpenAPI::HashHelper.each_schema(root) do |schema|
70
+ # "required" array must not be present if empty
71
+ schema.delete(:required) if schema.key?(:properties) && schema[:required] && schema[:required].empty?
72
+ end
77
73
  end
78
74
  end
79
75
 
80
76
  private
81
77
 
78
+ # Security sits on operations, the hashes under a path item: a non-standard
79
+ # method is still inline here and only moves under additionalOperations later.
80
+ def each_operation(base)
81
+ base[:paths].each_value do |path_item|
82
+ next unless path_item.is_a?(Hash)
83
+
84
+ path_item.each_value { |operation| yield operation if operation.is_a?(Hash) }
85
+ end
86
+ end
87
+
82
88
  # Recursively remove temporary fields like :_example_key and :_example_name from the schema.
83
89
  # Every caller has already established that it holds a Hash.
84
90
  def cleanup_temporary_fields!(hash)
@@ -113,25 +119,48 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
113
119
  end
114
120
 
115
121
  def cleanup_array!(base, spec, selector, fields_for_identity)
116
- marshal = lambda do |obj|
117
- Marshal.dump(obj.slice(*fields_for_identity))
118
- end
122
+ identity = ->(obj) { fields_for_identity.map { |field| obj[field] } }
119
123
 
120
124
  RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
121
125
  target_array = base.dig(*paths)
122
126
  spec_array = spec.dig(*paths)
123
127
  next unless target_array.is_a?(Array) && spec_array.is_a?(Array)
124
128
 
125
- spec_identities = Set.new(spec_array.map(&marshal))
126
- target_array.select! { |e| spec_identities.include?(marshal.call(e)) }
127
- target_array.sort_by! { |param| fields_for_identity.map { |f| param[f] }.join('-') }
129
+ spec_identities = Set.new(spec_array.map(&identity))
130
+ target_array.select! { |e| spec_identities.include?(identity.call(e)) }
131
+ target_array.sort_by! { |param| identity.call(param).join('-') }
128
132
  # Keep the last duplicate to produce the result stably
129
- deduplicated = target_array.reverse.uniq { |param| param.slice(*fields_for_identity) }.reverse
133
+ deduplicated = target_array.reverse.uniq(&identity).reverse
130
134
  target_array.replace(deduplicated)
131
135
  end
132
136
  base
133
137
  end
134
138
 
139
+ def cleanup_one_of_properties!(base, spec, selector)
140
+ RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
141
+ variants = base.dig(*paths, :oneOf)
142
+ fresh = spec.dig(*paths)
143
+ next unless variants.is_a?(Array) && fresh.is_a?(Hash)
144
+
145
+ variants.each { |variant| cleanup_variant_properties!(variant, variants, fresh) }
146
+ end
147
+ base
148
+ end
149
+
150
+ # Variants are matched by type, since the spec may hold one of them as a bare
151
+ # schema or in another order. Two variants of one type can not be told apart
152
+ # that way, so those stay as they are, like a variant of a type the spec does
153
+ # not hold.
154
+ def cleanup_variant_properties!(variant, variants, fresh)
155
+ return unless variant.is_a?(Hash) && variant[:properties].is_a?(Hash)
156
+ return unless variants.one? { |other| other.is_a?(Hash) && other[:type] == variant[:type] }
157
+
158
+ counterpart = RSpec::OpenAPI::HashHelper.variant_of_type(fresh, variant[:type])
159
+ return unless counterpart
160
+
161
+ variant[:properties].delete_if { |name, _| counterpart.dig(:properties, name).nil? }
162
+ end
163
+
135
164
  # Every selector above names at least two segments, so a matched path always
136
165
  # has a parent to delete the entry from.
137
166
  def cleanup_hash!(base, spec, selector)
@@ -25,7 +25,13 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
25
25
  merge_schema!(base, RSpec::OpenAPI::KeyTransformer.symbolize(spec))
26
26
  end
27
27
 
28
- SIMILARITY_THRESHOLD = 0.5
28
+ SCHEMA_KEYS = [:schema, :schemas, :itemSchema].freeze
29
+ DATA_KEYS = [:example, :examples, :default, :enum].freeze
30
+ NUMERIC_TYPES = ['integer', 'number'].freeze
31
+ ANNOTATION_KEYS = [
32
+ :title, :description, :deprecated, :readOnly, :writeOnly, :example, :examples, :default, :externalDocs, :xml,
33
+ :discriminator,
34
+ ].freeze
29
35
 
30
36
  private
31
37
 
@@ -34,19 +40,46 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
34
40
  # may be an array whose Hash elements have a key name.
35
41
  #
36
42
  # TODO: Should we probably force-merge `summary` regardless of manual modifications?
37
- def merge_schema!(base, spec)
38
- if (options = base[:oneOf])
39
- merge_closest_match!(options, spec)
40
-
41
- return base
42
- end
43
+ def merge_schema!(base, spec, in_schema: false)
44
+ return merge_one_of!(base, spec) if base[:oneOf]
43
45
 
44
46
  prune_stale_object_fields!(base, spec)
47
+ spec = widen_to_number!(base, spec) if in_schema
45
48
 
46
- spec.each { |key, value| merge_entry!(base, key, value) }
49
+ spec.each { |key, value| merge_schema_entry!(base, key, value, in_schema) }
47
50
  base
48
51
  end
49
52
 
53
+ # Every integer is a number, so a value seen as both is a number. The format
54
+ # of the integer side would be wrong on a number, so only the number's stays.
55
+ def widen_to_number!(base, spec)
56
+ return spec if base[:type] == spec[:type] || !same_type?(base[:type], spec[:type])
57
+ return spec.reject { |key, _| [:type, :format].include?(key) } if base[:type] == 'number'
58
+
59
+ base.delete(:format)
60
+ spec
61
+ end
62
+
63
+ def merge_schema_entry!(base, key, value, in_schema)
64
+ if in_schema && key == :properties && base[key].is_a?(Hash) && value.is_a?(Hash)
65
+ value.each { |name, property| merge_entry!(base[key], name, property, true) }
66
+ elsif DATA_KEYS.include?(key)
67
+ merge_data!(base, key, value)
68
+ else
69
+ merge_entry!(base, key, value, in_schema || SCHEMA_KEYS.include?(key))
70
+ end
71
+ end
72
+
73
+ # Recorded data stays data however it is shaped, so a field named like a
74
+ # schema key never gets merged as one.
75
+ def merge_data!(base, key, value)
76
+ if base[key].is_a?(Hash) && value.is_a?(Hash)
77
+ value.each { |name, item| merge_data!(base[key], name, item) }
78
+ else
79
+ base[key] = value
80
+ end
81
+ end
82
+
50
83
  # When the new spec converts an object to a dictionary (introduces
51
84
  # `additionalProperties` on a node that previously had `properties` /
52
85
  # `required`), drop the stale fields so the merged result reflects the
@@ -60,9 +93,9 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
60
93
  base.delete(:required)
61
94
  end
62
95
 
63
- def merge_entry!(base, key, value)
96
+ def merge_entry!(base, key, value, in_schema)
64
97
  if base[key].is_a?(Hash) && value.is_a?(Hash)
65
- merge_hash_entry!(base, key, value)
98
+ merge_hash_entry!(base, key, value, in_schema)
66
99
  elsif base[key].is_a?(Array) && value.is_a?(Array)
67
100
  # parameters need to be merged as if `name` and `in` were the Hash keys.
68
101
  merge_arrays(base, key, value)
@@ -71,18 +104,62 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
71
104
  end
72
105
  end
73
106
 
74
- def merge_hash_entry!(base, key, value)
107
+ def merge_hash_entry!(base, key, value, in_schema)
75
108
  # Handle example/examples conflict - convert to examples when mixed
76
109
  normalize_example_fields!(base[key], value)
77
110
 
78
- # If the new value has oneOf, replace the entire value instead of merging
79
- if value.key?(:oneOf)
111
+ if in_schema && one_of_needed?(base[key], value)
112
+ merge_one_of!(base[key], value)
113
+ elsif value.key?(:oneOf)
80
114
  base[key] = value
81
115
  elsif !base[key].key?(:$ref)
82
- merge_schema!(base[key], value)
116
+ merge_schema!(base[key], value, in_schema: in_schema)
83
117
  end
84
118
  end
85
119
 
120
+ def one_of_needed?(base, spec)
121
+ return false if base.key?(:$ref)
122
+
123
+ spec.key?(:oneOf) || conflicting_types?(base, spec)
124
+ end
125
+
126
+ def conflicting_types?(base, spec)
127
+ base[:type].is_a?(String) && spec[:type].is_a?(String) && !same_type?(base[:type], spec[:type])
128
+ end
129
+
130
+ def same_type?(first, second)
131
+ first == second || (NUMERIC_TYPES.include?(first) && NUMERIC_TYPES.include?(second))
132
+ end
133
+
134
+ def merge_one_of!(base, spec)
135
+ nullable = base.delete(:nullable) || spec[:nullable]
136
+ wrap_in_one_of!(base) unless base[:oneOf]
137
+
138
+ one_of_variants(spec).each { |variant| merge_variant!(base, variant) }
139
+ base[:nullable] = true if nullable
140
+ base
141
+ end
142
+
143
+ # Annotations describe the whole value, so they stay beside the oneOf. A schema
144
+ # without a type constrains every variant too, and a variant made of it would
145
+ # match every other type, so all of it stays beside the oneOf.
146
+ def wrap_in_one_of!(base)
147
+ # A hand-written `oneOf:` left empty reads back as nil.
148
+ base.delete(:oneOf)
149
+ variant = base[:type] ? base.reject { |key, _| annotation?(key) } : {}
150
+ rest = base.reject { |key, _| variant.key?(key) }
151
+ base.replace({ oneOf: [variant].reject(&:empty?) }.merge(rest))
152
+ end
153
+
154
+ def annotation?(key)
155
+ ANNOTATION_KEYS.include?(key) || key.start_with?('x-')
156
+ end
157
+
158
+ def one_of_variants(schema)
159
+ variants = schema[:oneOf] || [schema.reject { |key, _| key == :nullable }]
160
+ variants.reject { |variant| variant.is_a?(Hash) && variant.empty? }
161
+ end
162
+
86
163
  # do not ADD `properties` or `required` fields if `additionalProperties` field is present
87
164
  def skip_due_to_additional_properties?(base, key)
88
165
  base.key?(:additionalProperties) && [:properties, :required].include?(key)
@@ -132,16 +209,8 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
132
209
  end
133
210
 
134
211
  def merge_parameter_with_schema(base_param, new_param)
135
- base_schema = base_param[:schema]
136
- new_schema = new_param[:schema]
137
-
138
- # If schemas have different types, create a oneOf
139
- merged = if base_schema && new_schema && schemas_have_different_types?(base_schema, new_schema)
140
- merged_schema = merge_schemas_into_one_of(base_schema, new_schema)
141
- base_param.merge(new_param).merge(schema: merged_schema)
142
- else
143
- base_param.merge(new_param)
144
- end
212
+ merge_entry!(base_param, :schema, new_param[:schema], true)
213
+ merged = base_param.merge(new_param.reject { |key, _| key == :schema })
145
214
 
146
215
  # Once a parameter has been seen missing in any earlier test case, keep it optional
147
216
  # even if later test cases mark it required again.
@@ -150,37 +219,6 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
150
219
  merged
151
220
  end
152
221
 
153
- def schemas_have_different_types?(schema1, schema2)
154
- # If either already has oneOf, we need to merge into it
155
- return true if schema1[:oneOf] || schema2[:oneOf]
156
-
157
- type1 = schema1[:type]
158
- type2 = schema2[:type]
159
-
160
- type1 && type2 && type1 != type2
161
- end
162
-
163
- def merge_schemas_into_one_of(base_schema, new_schema)
164
- existing_types = extract_schema_types(base_schema)
165
- new_types = extract_schema_types(new_schema)
166
-
167
- all_types = existing_types + new_types
168
- all_types.uniq!
169
-
170
- # If only one type remains, return it directly
171
- return all_types.first if all_types.size == 1
172
-
173
- { oneOf: all_types }
174
- end
175
-
176
- def extract_schema_types(schema)
177
- if schema[:oneOf]
178
- schema[:oneOf].map { |s| s.reject { |k, _| k == :example } }
179
- else
180
- [schema.reject { |k, _| k == :example }]
181
- end
182
- end
183
-
184
222
  def index_parameters_by_identity(parameters)
185
223
  parameters.to_h { |p| [[p[:name], p[:in]], p] }
186
224
  end
@@ -209,20 +247,39 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
209
247
  hash[:examples] = { name => example }
210
248
  end
211
249
 
212
- def merge_closest_match!(options, spec)
213
- score, option = options.map { |option| [similarity(option, spec), option] }.max_by(&:first)
250
+ def merge_variant!(base, spec)
251
+ options = base[:oneOf]
252
+ return if spec[:oneOf] || options.include?(spec)
253
+ return if options.any? { |option| option.is_a?(Hash) && option.key?(:$ref) }
214
254
 
215
- return if option&.key?(:$ref)
216
-
217
- return if spec[:oneOf]
218
-
219
- if score.to_f > SIMILARITY_THRESHOLD
220
- merge_schema!(option, spec)
255
+ option = matching_option(base, spec)
256
+ if option
257
+ merge_schema!(option, spec, in_schema: true)
221
258
  else
222
259
  options.push(spec)
260
+ sort_by_type!(options)
223
261
  end
224
262
  end
225
263
 
264
+ # A value could match two variants of one type, so those merge into one, the
265
+ # closest when there are several. A discriminator tells the variants apart by
266
+ # a property, so only the variants that declare it are candidates there.
267
+ def matching_option(base, spec)
268
+ candidates = base[:oneOf].select { |option| option.is_a?(Hash) && same_type?(option[:type], spec[:type]) }
269
+ if (property = base.dig(:discriminator, :propertyName))
270
+ candidates.select! { |option| option.dig(:properties, property.to_sym) }
271
+ end
272
+ candidates.max_by { |option| similarity(option, spec) }
273
+ end
274
+
275
+ def sort_by_type!(options)
276
+ options.replace(options.sort_by.with_index { |option, index| [type_name(option), index] })
277
+ end
278
+
279
+ def type_name(schema)
280
+ schema.is_a?(Hash) ? schema[:type].to_s : ''
281
+ end
282
+
226
283
  def similarity(first, second)
227
284
  return 1 if first == second
228
285
 
@@ -231,7 +288,7 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
231
288
  when [Array, Array]
232
289
  (first & second).size / [first.size, second.size].max.to_f
233
290
  when [Hash, Hash]
234
- return 1 if first.merge(second).key?(:$ref)
291
+ return 1 if first.key?(:$ref) || second.key?(:$ref)
235
292
 
236
293
  intersection = first.keys & second.keys
237
294
  total_size = [first.size, second.size].max.to_f
@@ -37,8 +37,9 @@ class << RSpec::OpenAPI::SchemaSorter = Object.new
37
37
  end
38
38
  end
39
39
 
40
+ # Re-inserting a key moves it to the end, so re-inserting every key in sorted
41
+ # order sorts the hash in place.
40
42
  def deep_sort_hash!(hash)
41
- sorted = hash.entries.sort_by { |k, _| k.to_s }.to_h.transform_keys(&:to_sym)
42
- hash.replace(sorted)
43
+ hash.keys.sort_by(&:to_s).each { |key| hash[key.to_sym] = hash.delete(key) }
43
44
  end
44
45
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module OpenAPI
5
- VERSION = '0.33.1'
5
+ VERSION = '0.35.0'
6
6
  end
7
7
  end
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.33.1
4
+ version: 0.35.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takashi Kokubun
@@ -99,7 +99,7 @@ licenses:
99
99
  metadata:
100
100
  homepage_uri: https://github.com/exoego/rspec-openapi
101
101
  source_code_uri: https://github.com/exoego/rspec-openapi
102
- changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.33.1
102
+ changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.35.0
103
103
  rubygems_mfa_required: 'true'
104
104
  rdoc_options: []
105
105
  require_paths:
@@ -115,7 +115,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
115
115
  - !ruby/object:Gem::Version
116
116
  version: '0'
117
117
  requirements: []
118
- rubygems_version: 4.0.16
118
+ rubygems_version: 4.0.20
119
119
  specification_version: 4
120
120
  summary: Generate OpenAPI schema from RSpec request specs
121
121
  test_files: []