Skip to content

Commit bc4ef75

Browse files
authored
fix: allow objects to be nullable (#272)
1 parent 2c12e5c commit bc4ef75

File tree

10 files changed

+613
-82
lines changed

10 files changed

+613
-82
lines changed

lib/rspec/openapi/schema_builder.rb

Lines changed: 28 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,19 @@ def build_array_items_schema(array, record: nil)
256256
all_keys = all_schemas.flat_map { |s| s[:properties]&.keys || [] }.uniq
257257

258258
all_keys.each do |key|
259-
property_variations = all_schemas.map { |s| s[:properties]&.[](key) }.compact
259+
all_property_schemas = all_schemas.map { |s| s[:properties]&.[](key) }
260260

261-
next if property_variations.empty?
261+
nullable_only_schemas = all_property_schemas.select { |p| p && p.keys == [:nullable] }
262+
property_variations = all_property_schemas.select { |p| p && p.keys != [:nullable] }
262263

263-
if property_variations.size == 1
264-
merged_schema[:properties][key] = make_property_nullable(property_variations.first)
264+
has_nullable = all_property_schemas.any?(&:nil?) || nullable_only_schemas.any?
265+
266+
next if property_variations.empty? && !has_nullable
267+
268+
if property_variations.empty? && has_nullable
269+
merged_schema[:properties][key] = { nullable: true }
270+
elsif property_variations.size == 1
271+
merged_schema[:properties][key] = property_variations.first.dup
265272
else
266273
unique_types = property_variations.map { |p| p[:type] }.compact.uniq
267274

@@ -275,9 +282,9 @@ def build_array_items_schema(array, record: nil)
275282
else
276283
merged_schema[:properties][key] = property_variations.first.dup
277284
end
278-
279-
merged_schema[:properties][key][:nullable] = true if property_variations.size < all_schemas.size
280285
end
286+
287+
merged_schema[:properties][key][:nullable] = true if has_nullable && merged_schema[:properties][key].is_a?(Hash)
281288
end
282289

283290
all_required_sets = all_schemas.map { |s| s[:required] || [] }
@@ -297,21 +304,32 @@ def build_merged_schema_from_variations(variations)
297304
all_keys = variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
298305

299306
all_keys.each do |key|
300-
prop_variations = variations.map { |v| v[:properties]&.[](key) }.compact
307+
all_prop_variations = variations.map { |v| v[:properties]&.[](key) }
308+
309+
nullable_only = all_prop_variations.select { |p| p && p.keys == [:nullable] }
310+
prop_variations = all_prop_variations.select { |p| p && p.keys != [:nullable] }.compact
311+
312+
has_nullable = all_prop_variations.any?(&:nil?) || nullable_only.any?
301313

302314
if prop_variations.size == 1
303-
merged[:properties][key] = make_property_nullable(prop_variations.first)
315+
merged[:properties][key] = prop_variations.first.dup
316+
merged[:properties][key][:nullable] = true if has_nullable
304317
elsif prop_variations.size > 1
305318
prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
306319

307320
if prop_types.size == 1
308-
merged[:properties][key] = prop_variations.first.dup
321+
# Only recursively merge if it's an object type
322+
merged[:properties][key] = if prop_types.first == 'object'
323+
build_merged_schema_from_variations(prop_variations)
324+
else
325+
prop_variations.first.dup
326+
end
309327
else
310328
unique_props = prop_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
311329
merged[:properties][key] = { oneOf: unique_props }
312330
end
313331

314-
merged[:properties][key][:nullable] = true if prop_variations.size < variations.size
332+
merged[:properties][key][:nullable] = true if has_nullable || prop_variations.size < variations.size
315333
end
316334
end
317335

@@ -323,56 +341,4 @@ def build_merged_schema_from_variations(variations)
323341
variations.first
324342
end
325343
end
326-
327-
def merge_object_schemas(schema1, schema2)
328-
return schema1 unless schema2.is_a?(Hash) && schema1.is_a?(Hash)
329-
return schema1 unless schema1[:type] == 'object' && schema2[:type] == 'object'
330-
331-
merged = schema1.dup
332-
333-
if schema1[:properties] && schema2[:properties]
334-
merged[:properties] = schema1[:properties].dup
335-
336-
schema2[:properties].each do |key, prop2|
337-
if merged[:properties][key]
338-
prop1 = merged[:properties][key]
339-
merged[:properties][key] = merge_property_schemas(prop1, prop2)
340-
else
341-
merged[:properties][key] = make_property_nullable(prop2)
342-
end
343-
end
344-
345-
schema1[:properties].each do |key, prop1|
346-
merged[:properties][key] = make_property_nullable(prop1) unless schema2[:properties][key]
347-
end
348-
349-
required1 = Set.new(schema1[:required] || [])
350-
required2 = Set.new(schema2[:required] || [])
351-
merged[:required] = (required1 & required2).to_a
352-
end
353-
354-
merged
355-
end
356-
357-
def merge_property_schemas(prop1, prop2)
358-
return prop1 unless prop2.is_a?(Hash) && prop1.is_a?(Hash)
359-
360-
merged = prop1.dup
361-
362-
# If either property is nullable, the merged property should be nullable
363-
merged[:nullable] = true if prop2[:nullable] && !prop1[:nullable]
364-
365-
# If both are objects, recursively merge their properties
366-
merged = merge_object_schemas(prop1, prop2) if prop1[:type] == 'object' && prop2[:type] == 'object'
367-
368-
merged
369-
end
370-
371-
def make_property_nullable(property)
372-
return property unless property.is_a?(Hash)
373-
374-
nullable_prop = property.dup
375-
nullable_prop[:nullable] = true unless nullable_prop[:nullable]
376-
nullable_prop
377-
end
378344
end

spec/apps/hanami/app/actions/array_hashes/nested.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ def handle(request, response)
2020
"id" => "ca",
2121
"label" => "Canada"
2222
}
23-
]
23+
],
24+
"validations" => nil,
25+
"always_nil" => nil
2426
},
2527
{
2628
"id" => "region_id",
@@ -33,7 +35,11 @@ def handle(request, response)
3335
"id" => 2,
3436
"label" => "California"
3537
}
36-
]
38+
],
39+
"validations" => {
40+
"presence" => true
41+
},
42+
"always_nil" => nil
3743
}
3844
]
3945
}.to_json

spec/apps/hanami/app/actions/array_hashes/nested_objects.rb

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,57 @@ def handle(request, response)
1414
"metadata" => {
1515
"author" => "Alice",
1616
"version" => "1.0"
17-
}
17+
},
18+
"actions" => [
19+
{
20+
"label" => "Duplicate",
21+
"modal" => {
22+
"confirm_action" => {
23+
"label" => "Duplicate"
24+
}
25+
}
26+
},
27+
{
28+
"label" => "Edit",
29+
},
30+
{
31+
"label" => "Something Else Again",
32+
"modal" => {
33+
"confirm_action" => {
34+
"label" => nil
35+
}
36+
}
37+
}
38+
]
1839
},
1940
{
2041
"id" => 2,
2142
"metadata" => {
2243
"author" => "Bob",
2344
"version" => "2.0",
2445
"reviewed" => true
25-
}
46+
},
47+
"actions" => [
48+
{
49+
"label" => "Duplicate",
50+
"modal" => {
51+
"confirm_action" => {
52+
"label" => "Duplicate"
53+
}
54+
}
55+
},
56+
{
57+
"label" => "Edit",
58+
},
59+
{
60+
"label" => "Something Else Again",
61+
"modal" => {
62+
"confirm_action" => {
63+
"label" => nil
64+
}
65+
}
66+
}
67+
]
2668
},
2769
{
2870
"id" => 3,

spec/apps/hanami/doc/openapi.json

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,21 @@
178178
"label"
179179
]
180180
}
181+
},
182+
"validations": {
183+
"type": "object",
184+
"properties": {
185+
"presence": {
186+
"type": "boolean"
187+
}
188+
},
189+
"required": [
190+
"presence"
191+
],
192+
"nullable": true
193+
},
194+
"always_nil": {
195+
"nullable": true
181196
}
182197
},
183198
"required": [
@@ -204,7 +219,9 @@
204219
"id": "ca",
205220
"label": "Canada"
206221
}
207-
]
222+
],
223+
"validations": null,
224+
"always_nil": null
208225
},
209226
{
210227
"id": "region_id",
@@ -217,7 +234,11 @@
217234
"id": 2,
218235
"label": "California"
219236
}
220-
]
237+
],
238+
"validations": {
239+
"presence": true
240+
},
241+
"always_nil": null
221242
}
222243
]
223244
}
@@ -337,6 +358,41 @@
337358
"required": [
338359
"author"
339360
]
361+
},
362+
"actions": {
363+
"type": "array",
364+
"items": {
365+
"type": "object",
366+
"properties": {
367+
"label": {
368+
"type": "string"
369+
},
370+
"modal": {
371+
"type": "object",
372+
"properties": {
373+
"confirm_action": {
374+
"type": "object",
375+
"properties": {
376+
"label": {
377+
"type": "string",
378+
"nullable": true
379+
}
380+
},
381+
"required": [
382+
"label"
383+
]
384+
}
385+
},
386+
"required": [
387+
"confirm_action"
388+
]
389+
}
390+
},
391+
"required": [
392+
"label"
393+
]
394+
},
395+
"nullable": true
340396
}
341397
},
342398
"required": [
@@ -357,15 +413,57 @@
357413
"metadata": {
358414
"author": "Alice",
359415
"version": "1.0"
360-
}
416+
},
417+
"actions": [
418+
{
419+
"label": "Duplicate",
420+
"modal": {
421+
"confirm_action": {
422+
"label": "Duplicate"
423+
}
424+
}
425+
},
426+
{
427+
"label": "Edit"
428+
},
429+
{
430+
"label": "Something Else Again",
431+
"modal": {
432+
"confirm_action": {
433+
"label": null
434+
}
435+
}
436+
}
437+
]
361438
},
362439
{
363440
"id": 2,
364441
"metadata": {
365442
"author": "Bob",
366443
"version": "2.0",
367444
"reviewed": true
368-
}
445+
},
446+
"actions": [
447+
{
448+
"label": "Duplicate",
449+
"modal": {
450+
"confirm_action": {
451+
"label": "Duplicate"
452+
}
453+
}
454+
},
455+
{
456+
"label": "Edit"
457+
},
458+
{
459+
"label": "Something Else Again",
460+
"modal": {
461+
"confirm_action": {
462+
"label": null
463+
}
464+
}
465+
}
466+
]
369467
},
370468
{
371469
"id": 3,

0 commit comments

Comments
 (0)