Skip to content

Commit 6cc4592

Browse files
committed
Use allOf to model nullable return types
1 parent 89bd338 commit 6cc4592

File tree

6 files changed

+305
-1
lines changed

6 files changed

+305
-1
lines changed

src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ internal static string GetSchemaReferenceId(this Type type, JsonSerializerOption
108108
return $"{typeName}Of{propertyNames}";
109109
}
110110

111+
// Special handling for nullable value types
112+
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
113+
{
114+
return type.GetGenericArguments()[0].GetSchemaReferenceId(options);
115+
}
116+
111117
// Special handling for generic types that are collections
112118
// Generic types become a concatenation of the generic type name and the type arguments
113119
if (type.IsGenericType)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.OpenApi;
5+
6+
internal static class OpenApiSchemaExtensions
7+
{
8+
private static readonly OpenApiSchema _nullSchema = new() { Type = JsonSchemaType.Null };
9+
10+
public static IOpenApiSchema CreateAllOfNullableWrapper(this IOpenApiSchema originalSchema)
11+
{
12+
return new OpenApiSchema
13+
{
14+
AllOf =
15+
[
16+
_nullSchema,
17+
originalSchema
18+
]
19+
};
20+
}
21+
}

src/OpenApi/src/Extensions/TypeExtensions.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Linq;
5+
using System.Reflection;
6+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
7+
using Microsoft.AspNetCore.Mvc.Controllers;
8+
49
namespace Microsoft.AspNetCore.OpenApi;
510

611
internal static class TypeExtensions
@@ -30,4 +35,39 @@ public static bool IsJsonPatchDocument(this Type type)
3035

3136
return false;
3237
}
38+
39+
public static bool ShouldApplyNullableResponseSchema(this ApiResponseType apiResponseType, ApiDescription apiDescription)
40+
{
41+
// Get the MethodInfo from the ActionDescriptor
42+
var responseType = apiResponseType.Type;
43+
var methodInfo = apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
44+
? controllerActionDescriptor.MethodInfo
45+
: apiDescription.ActionDescriptor.EndpointMetadata.OfType<MethodInfo>().SingleOrDefault();
46+
47+
if (methodInfo is null)
48+
{
49+
return false;
50+
}
51+
52+
var returnType = methodInfo.ReturnType;
53+
if (returnType.IsGenericType &&
54+
(returnType.GetGenericTypeDefinition() == typeof(Task<>) || returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)))
55+
{
56+
returnType = returnType.GetGenericArguments()[0];
57+
}
58+
if (returnType != responseType)
59+
{
60+
return false;
61+
}
62+
63+
if (returnType.IsValueType)
64+
{
65+
return apiResponseType.ModelMetadata?.IsNullableValueType ?? false;
66+
}
67+
68+
var nullabilityInfoContext = new NullabilityInfoContext();
69+
var nullabilityInfo = nullabilityInfoContext.Create(methodInfo.ReturnParameter);
70+
71+
return nullabilityInfo.WriteState == NullabilityState.Nullable;
72+
}
3373
}

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,14 @@ private async Task<OpenApiResponse> GetResponseAsync(
423423
.Select(responseFormat => responseFormat.MediaType);
424424
foreach (var contentType in apiResponseFormatContentTypes)
425425
{
426-
var schema = apiResponseType.Type is { } type ? await _componentService.GetOrCreateSchemaAsync(document, type, scopedServiceProvider, schemaTransformers, null, cancellationToken) : new OpenApiSchema();
426+
IOpenApiSchema schema = new OpenApiSchema();
427+
if (apiResponseType.Type is { } responseType)
428+
{
429+
schema = await _componentService.GetOrCreateSchemaAsync(document, responseType, scopedServiceProvider, schemaTransformers, null, cancellationToken);
430+
schema = apiResponseType.ShouldApplyNullableResponseSchema(apiDescription)
431+
? schema.CreateAllOfNullableWrapper()
432+
: schema;
433+
}
427434
response.Content[contentType] = new OpenApiMediaType { Schema = schema };
428435
}
429436

src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ internal sealed class OpenApiSchemaService(
9797
schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId);
9898
schema.ApplySchemaReferenceId(context, createSchemaReferenceId);
9999
schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId);
100+
if (schema[OpenApiConstants.SchemaId] is { } schemaId && schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray)
101+
{
102+
// Remove null from union types when present
103+
for (var i = typeArray.Count - 1; i >= 0; i--)
104+
{
105+
if (typeArray[i]?.GetValue<string>() == "null")
106+
{
107+
typeArray.RemoveAt(i);
108+
}
109+
}
110+
if (typeArray.Count == 1)
111+
{
112+
schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue<string>();
113+
}
114+
}
100115
if (context.PropertyInfo is { } jsonPropertyInfo)
101116
{
102117
schema.ApplyNullabilityContextInfo(jsonPropertyInfo);

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,114 @@ public async Task GetOpenApiResponse_HandlesNullablePocoResponse()
174174
builder.MapGet("/api", GetTodo);
175175
#nullable restore
176176

177+
// Assert
178+
await VerifyOpenApiDocument(builder, document =>
179+
{
180+
var operation = document.Paths["/api"].Operations[HttpMethod.Get];
181+
var responses = Assert.Single(operation.Responses);
182+
var response = responses.Value;
183+
Assert.True(response.Content.TryGetValue("application/json", out var mediaType));
184+
var schema = mediaType.Schema;
185+
Assert.NotNull(schema.AllOf);
186+
Assert.Equal(2, schema.AllOf.Count);
187+
// Check that the allOf consists of a nullable schema and the GetTodo schema
188+
Assert.Collection(schema.AllOf,
189+
item =>
190+
{
191+
Assert.NotNull(item);
192+
Assert.Equal(JsonSchemaType.Null, item.Type);
193+
},
194+
item =>
195+
{
196+
Assert.NotNull(item);
197+
Assert.Equal(JsonSchemaType.Object, item.Type);
198+
Assert.Collection(item.Properties,
199+
property =>
200+
{
201+
Assert.Equal("id", property.Key);
202+
Assert.Equal(JsonSchemaType.Integer, property.Value.Type);
203+
Assert.Equal("int32", property.Value.Format);
204+
},
205+
property =>
206+
{
207+
Assert.Equal("title", property.Key);
208+
Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type);
209+
},
210+
property =>
211+
{
212+
Assert.Equal("completed", property.Key);
213+
Assert.Equal(JsonSchemaType.Boolean, property.Value.Type);
214+
},
215+
property =>
216+
{
217+
Assert.Equal("createdAt", property.Key);
218+
Assert.Equal(JsonSchemaType.String, property.Value.Type);
219+
Assert.Equal("date-time", property.Value.Format);
220+
});
221+
});
222+
223+
});
224+
}
225+
226+
[Fact]
227+
public async Task GetOpenApiResponse_HandlesNullablePocoTaskResponse()
228+
{
229+
// Arrange
230+
var builder = CreateBuilder();
231+
232+
// Act
233+
#nullable enable
234+
static Task<Todo?> GetTodoAsync() => Task.FromResult(Random.Shared.Next() < 0.5 ? new Todo(1, "Test Title", true, DateTime.Now) : null);
235+
builder.MapGet("/api", GetTodoAsync);
236+
#nullable restore
237+
238+
// Assert
239+
await VerifyOpenApiDocument(builder, document =>
240+
{
241+
var operation = document.Paths["/api"].Operations[HttpMethod.Get];
242+
var responses = Assert.Single(operation.Responses);
243+
var response = responses.Value;
244+
Assert.True(response.Content.TryGetValue("application/json", out var mediaType));
245+
var schema = mediaType.Schema;
246+
Assert.Equal(JsonSchemaType.Object, schema.Type);
247+
Assert.Collection(schema.Properties,
248+
property =>
249+
{
250+
Assert.Equal("id", property.Key);
251+
Assert.Equal(JsonSchemaType.Integer, property.Value.Type);
252+
Assert.Equal("int32", property.Value.Format);
253+
},
254+
property =>
255+
{
256+
Assert.Equal("title", property.Key);
257+
Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type);
258+
},
259+
property =>
260+
{
261+
Assert.Equal("completed", property.Key);
262+
Assert.Equal(JsonSchemaType.Boolean, property.Value.Type);
263+
},
264+
property =>
265+
{
266+
Assert.Equal("createdAt", property.Key);
267+
Assert.Equal(JsonSchemaType.String, property.Value.Type);
268+
Assert.Equal("date-time", property.Value.Format);
269+
});
270+
});
271+
}
272+
273+
[Fact]
274+
public async Task GetOpenApiResponse_HandlesNullablePocoValueTaskResponse()
275+
{
276+
// Arrange
277+
var builder = CreateBuilder();
278+
279+
// Act
280+
#nullable enable
281+
static ValueTask<Todo?> GetTodoValueTaskAsync() => ValueTask.FromResult(Random.Shared.Next() < 0.5 ? new Todo(1, "Test Title", true, DateTime.Now) : null);
282+
builder.MapGet("/api", GetTodoValueTaskAsync);
283+
#nullable restore
284+
177285
// Assert
178286
await VerifyOpenApiDocument(builder, document =>
179287
{
@@ -231,6 +339,95 @@ await VerifyOpenApiDocument(builder, document =>
231339
});
232340
}
233341

342+
[Fact]
343+
public async Task GetOpenApiResponse_HandlesNullableValueTypeResponse()
344+
{
345+
// Arrange
346+
var builder = CreateBuilder();
347+
348+
// Act
349+
#nullable enable
350+
static Point? GetNullablePoint() => Random.Shared.Next() < 0.5 ? new Point { X = 10, Y = 20 } : null;
351+
builder.MapGet("/api/nullable-point", GetNullablePoint);
352+
353+
static Coordinate? GetNullableCoordinate() => Random.Shared.Next() < 0.5 ? new Coordinate(1.5, 2.5) : null;
354+
builder.MapGet("/api/nullable-coordinate", GetNullableCoordinate);
355+
#nullable restore
356+
357+
// Assert
358+
await VerifyOpenApiDocument(builder, document =>
359+
{
360+
// Verify nullable Point response
361+
var pointOperation = document.Paths["/api/nullable-point"].Operations[HttpMethod.Get];
362+
var pointResponses = Assert.Single(pointOperation.Responses);
363+
var pointResponse = pointResponses.Value;
364+
Assert.True(pointResponse.Content.TryGetValue("application/json", out var pointMediaType));
365+
var pointSchema = pointMediaType.Schema;
366+
Assert.NotNull(pointSchema.AllOf);
367+
Assert.Equal(2, pointSchema.AllOf.Count);
368+
Assert.Collection(pointSchema.AllOf,
369+
item =>
370+
{
371+
Assert.NotNull(item);
372+
Assert.Equal(JsonSchemaType.Null, item.Type);
373+
},
374+
item =>
375+
{
376+
Assert.NotNull(item);
377+
Assert.Equal(JsonSchemaType.Object, item.Type);
378+
Assert.Collection(item.Properties,
379+
property =>
380+
{
381+
Assert.Equal("x", property.Key);
382+
Assert.Equal(JsonSchemaType.Integer, property.Value.Type);
383+
Assert.Equal("int32", property.Value.Format);
384+
},
385+
property =>
386+
{
387+
Assert.Equal("y", property.Key);
388+
Assert.Equal(JsonSchemaType.Integer, property.Value.Type);
389+
Assert.Equal("int32", property.Value.Format);
390+
});
391+
});
392+
393+
// Verify nullable Coordinate response
394+
var coordinateOperation = document.Paths["/api/nullable-coordinate"].Operations[HttpMethod.Get];
395+
var coordinateResponses = Assert.Single(coordinateOperation.Responses);
396+
var coordinateResponse = coordinateResponses.Value;
397+
Assert.True(coordinateResponse.Content.TryGetValue("application/json", out var coordinateMediaType));
398+
var coordinateSchema = coordinateMediaType.Schema;
399+
Assert.NotNull(coordinateSchema.AllOf);
400+
Assert.Equal(2, coordinateSchema.AllOf.Count);
401+
Assert.Collection(coordinateSchema.AllOf,
402+
item =>
403+
{
404+
Assert.NotNull(item);
405+
Assert.Equal(JsonSchemaType.Null, item.Type);
406+
},
407+
item =>
408+
{
409+
Assert.NotNull(item);
410+
Assert.Equal(JsonSchemaType.Object, item.Type);
411+
Assert.Collection(item.Properties,
412+
property =>
413+
{
414+
Assert.Equal("latitude", property.Key);
415+
Assert.Equal(JsonSchemaType.Number, property.Value.Type);
416+
Assert.Equal("double", property.Value.Format);
417+
},
418+
property =>
419+
{
420+
Assert.Equal("longitude", property.Key);
421+
Assert.Equal(JsonSchemaType.Number, property.Value.Type);
422+
Assert.Equal("double", property.Value.Format);
423+
});
424+
});
425+
426+
// Assert that Point and Coordinates are the only schemas defined at the top-level
427+
Assert.Equal(["Coordinate", "Point"], [.. document.Components.Schemas.Keys]);
428+
});
429+
}
430+
234431
[Fact]
235432
public async Task GetOpenApiResponse_HandlesInheritedTypeResponse()
236433
{
@@ -732,4 +929,22 @@ private class ClassWithObjectProperty
732929
[DefaultValue(32)]
733930
public object AnotherObject { get; set; }
734931
}
932+
933+
private struct Point
934+
{
935+
public int X { get; set; }
936+
public int Y { get; set; }
937+
}
938+
939+
private readonly struct Coordinate
940+
{
941+
public double Latitude { get; }
942+
public double Longitude { get; }
943+
944+
public Coordinate(double latitude, double longitude)
945+
{
946+
Latitude = latitude;
947+
Longitude = longitude;
948+
}
949+
}
735950
}

0 commit comments

Comments
 (0)