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 +4 -4
- data/lib/rspec/openapi/components_updater.rb +78 -19
- data/lib/rspec/openapi/hash_helper.rb +87 -22
- data/lib/rspec/openapi/key_transformer.rb +14 -9
- data/lib/rspec/openapi/nullable_converter.rb +25 -19
- data/lib/rspec/openapi/result_recorder.rb +3 -2
- data/lib/rspec/openapi/schema_builder/build_context.rb +6 -6
- data/lib/rspec/openapi/schema_builder/variation_merger.rb +30 -11
- data/lib/rspec/openapi/schema_builder.rb +22 -13
- data/lib/rspec/openapi/schema_cleaner.rb +54 -25
- data/lib/rspec/openapi/schema_merger.rb +121 -64
- data/lib/rspec/openapi/schema_sorter.rb +3 -2
- data/lib/rspec/openapi/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7298e79ff60a23925281c724390eed37544bef37a47f7ba85e6f5ef2d28bf3f
|
|
4
|
+
data.tar.gz: b1798ffc05aa81f206b4f50e5fffeb6317ac902e113c1c3449bf1f0ed4be437d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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,
|
|
11
|
+
collect_matches(obj, split_selector(selector), 0, [], matches)
|
|
13
12
|
matches
|
|
14
13
|
end
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
#
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
properties
|
|
63
|
-
required
|
|
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,
|
|
138
|
+
options = prop_variations.each_with_object([]) do |prop, acc|
|
|
127
139
|
clean = without_nullable(prop)
|
|
128
140
|
if clean.key?(:oneOf)
|
|
129
|
-
|
|
141
|
+
acc.concat(clean[:oneOf])
|
|
130
142
|
elsif !clean.empty?
|
|
131
|
-
|
|
143
|
+
acc << clean
|
|
132
144
|
end
|
|
133
|
-
end
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
property[:required] =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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(&
|
|
126
|
-
target_array.select! { |e| spec_identities.include?(
|
|
127
|
-
target_array.sort_by! { |param|
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
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.
|
|
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.
|
|
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.
|
|
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: []
|