Skip to content

Commit d7c2eaa

Browse files
authored
fix: better determination of oneOf for hash arrays in hash arrays (#269)
1 parent 204a75e commit d7c2eaa

File tree

22 files changed

+2738
-6
lines changed

22 files changed

+2738
-6
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
/spec/reports/
88
/tmp/
99
/Gemfile.lock
10+
.DS_Store
1011

1112
# rspec failure tracking
1213
.rspec_status

lib/rspec/openapi/schema_builder.rb

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,20 +246,84 @@ def normalize_content_disposition(content_disposition)
246246

247247
def build_array_items_schema(array, record: nil)
248248
return {} if array.empty?
249+
return build_property(array.first, record: record) if array.size == 1
250+
return build_property(array.first, record: record) unless array.all? { |item| item.is_a?(Hash) }
249251

250-
merged_schema = build_property(array.first, record: record)
252+
all_schemas = array.map { |item| build_property(item, record: record) }
253+
merged_schema = all_schemas.first.dup
254+
merged_schema[:properties] = {}
251255

252-
# Future improvement - cover other types than just hashes
253-
if array.size > 1 && array.all? { |item| item.is_a?(Hash) }
254-
array[1..].each do |item|
255-
item_schema = build_property(item, record: record)
256-
merged_schema = merge_object_schemas(merged_schema, item_schema)
256+
all_keys = all_schemas.flat_map { |s| s[:properties]&.keys || [] }.uniq
257+
258+
all_keys.each do |key|
259+
property_variations = all_schemas.map { |s| s[:properties]&.[](key) }.compact
260+
261+
next if property_variations.empty?
262+
263+
if property_variations.size == 1
264+
merged_schema[:properties][key] = make_property_nullable(property_variations.first)
265+
else
266+
unique_types = property_variations.map { |p| p[:type] }.compact.uniq
267+
268+
case unique_types.first
269+
when 'array'
270+
merged_schema[:properties][key] = { type: 'array' }
271+
items_variations = property_variations.map { |p| p[:items] }.compact
272+
merged_schema[:properties][key][:items] = build_merged_schema_from_variations(items_variations)
273+
when 'object'
274+
merged_schema[:properties][key] = build_merged_schema_from_variations(property_variations)
275+
else
276+
merged_schema[:properties][key] = property_variations.first.dup
277+
end
278+
279+
merged_schema[:properties][key][:nullable] = true if property_variations.size < all_schemas.size
257280
end
258281
end
259282

283+
all_required_sets = all_schemas.map { |s| s[:required] || [] }
284+
merged_schema[:required] = all_required_sets.reduce(:&) || []
285+
260286
merged_schema
261287
end
262288

289+
def build_merged_schema_from_variations(variations)
290+
return {} if variations.empty?
291+
return variations.first if variations.size == 1
292+
293+
types = variations.map { |v| v[:type] }.compact.uniq
294+
295+
if types.size == 1 && types.first == 'object'
296+
merged = { type: 'object', properties: {} }
297+
all_keys = variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
298+
299+
all_keys.each do |key|
300+
prop_variations = variations.map { |v| v[:properties]&.[](key) }.compact
301+
302+
if prop_variations.size == 1
303+
merged[:properties][key] = make_property_nullable(prop_variations.first)
304+
elsif prop_variations.size > 1
305+
prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
306+
307+
if prop_types.size == 1
308+
merged[:properties][key] = prop_variations.first.dup
309+
else
310+
unique_props = prop_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
311+
merged[:properties][key] = { oneOf: unique_props }
312+
end
313+
314+
merged[:properties][key][:nullable] = true if prop_variations.size < variations.size
315+
end
316+
end
317+
318+
all_required = variations.map { |v| v[:required] || [] }
319+
merged[:required] = all_required.reduce(:&) || []
320+
321+
merged
322+
else
323+
variations.first
324+
end
325+
end
326+
263327
def merge_object_schemas(schema1, schema2)
264328
return schema1 unless schema2.is_a?(Hash) && schema1.is_a?(Hash)
265329
return schema1 unless schema1[:type] == 'object' && schema2[:type] == 'object'

lib/rspec/openapi/schema_merger.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def merge_closest_match!(options, spec)
7777

7878
return if option&.key?(:$ref)
7979

80+
return if spec[:oneOf]
81+
8082
if score.to_f > SIMILARITY_THRESHOLD
8183
merge_schema!(option, spec)
8284
else
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module HanamiTest
4+
module Actions
5+
module ArrayHashes
6+
class EmptyArray < HanamiTest::Action
7+
def handle(request, response)
8+
response.format = :json
9+
10+
response.body = {
11+
"items" => []
12+
}.to_json
13+
end
14+
end
15+
end
16+
end
17+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
module HanamiTest
4+
module Actions
5+
module ArrayHashes
6+
class MixedTypesNested < HanamiTest::Action
7+
def handle(request, response)
8+
response.format = :json
9+
10+
response.body = {
11+
"items" => [
12+
{
13+
"id" => 1,
14+
"config" => {
15+
"port" => 8080,
16+
"host" => "localhost"
17+
}
18+
},
19+
{
20+
"id" => 2,
21+
"config" => {
22+
"port" => "3000",
23+
"host" => "example.com",
24+
"ssl" => true
25+
}
26+
}
27+
]
28+
}.to_json
29+
end
30+
end
31+
end
32+
end
33+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
module HanamiTest
4+
module Actions
5+
module ArrayHashes
6+
class Nested < HanamiTest::Action
7+
def handle(request, response)
8+
response.format = :json
9+
10+
response.body = {
11+
"fields" => [
12+
{
13+
"id" => "country_code",
14+
"options" => [
15+
{
16+
"id" => "us",
17+
"label" => "United States"
18+
},
19+
{
20+
"id" => "ca",
21+
"label" => "Canada"
22+
}
23+
]
24+
},
25+
{
26+
"id" => "region_id",
27+
"options" => [
28+
{
29+
"id" => 1,
30+
"label" => "New York"
31+
},
32+
{
33+
"id" => 2,
34+
"label" => "California"
35+
}
36+
]
37+
}
38+
]
39+
}.to_json
40+
end
41+
end
42+
end
43+
end
44+
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
module HanamiTest
4+
module Actions
5+
module ArrayHashes
6+
class NestedArrays < HanamiTest::Action
7+
def handle(request, response)
8+
response.format = :json
9+
10+
response.body = {
11+
"items" => [
12+
{
13+
"id" => 1,
14+
"tags" => ["ruby", "rails"]
15+
},
16+
{
17+
"id" => 2,
18+
"tags" => ["python", "django"]
19+
},
20+
{
21+
"id" => 3,
22+
"tags" => ["javascript"]
23+
}
24+
]
25+
}.to_json
26+
end
27+
end
28+
end
29+
end
30+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
module HanamiTest
4+
module Actions
5+
module ArrayHashes
6+
class NestedObjects < HanamiTest::Action
7+
def handle(request, response)
8+
response.format = :json
9+
10+
response.body = {
11+
"items" => [
12+
{
13+
"id" => 1,
14+
"metadata" => {
15+
"author" => "Alice",
16+
"version" => "1.0"
17+
}
18+
},
19+
{
20+
"id" => 2,
21+
"metadata" => {
22+
"author" => "Bob",
23+
"version" => "2.0",
24+
"reviewed" => true
25+
}
26+
},
27+
{
28+
"id" => 3,
29+
"metadata" => {
30+
"author" => "Charlie"
31+
}
32+
}
33+
]
34+
}.to_json
35+
end
36+
end
37+
end
38+
end
39+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module HanamiTest
4+
module Actions
5+
module ArrayHashes
6+
class NonHashItems < HanamiTest::Action
7+
def handle(request, response)
8+
response.format = :json
9+
10+
response.body = {
11+
"items" => ["string1", "string2", "string3"]
12+
}.to_json
13+
end
14+
end
15+
end
16+
end
17+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module HanamiTest
4+
module Actions
5+
module ArrayHashes
6+
class SingleItem < HanamiTest::Action
7+
def handle(request, response)
8+
response.format = :json
9+
10+
response.body = {
11+
"items" => [
12+
{
13+
"id" => 1,
14+
"name" => "Item 1"
15+
}
16+
]
17+
}.to_json
18+
end
19+
end
20+
end
21+
end
22+
end

0 commit comments

Comments
 (0)