Skip to content

Commit 0c90cc9

Browse files
committed
Use allOf to model nullable parameter types
1 parent 6cc4592 commit 0c90cc9

File tree

5 files changed

+219
-30
lines changed

5 files changed

+219
-30
lines changed

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,6 @@ internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValu
195195
/// underlying schema generator does not support this, we need to manually apply the
196196
/// supported formats to the schemas associated with the generated type.
197197
///
198-
/// Whereas JsonSchema represents nullable types via `type: ["string", "null"]`, OpenAPI
199-
/// v3 exposes a nullable property on the schema. This method will set the nullable property
200-
/// based on whether the underlying schema generator returned an array type containing "null" to
201-
/// represent a nullable type or if the type was denoted as nullable from our lookup cache.
202-
///
203198
/// Note that this method targets <see cref="JsonNode"/> and not <see cref="OpenApiSchema"/> because
204199
/// it is is designed to be invoked via the `OnGenerated` callback in the underlying schema generator as
205200
/// opposed to after the generated schemas have been mapped to OpenAPI schemas.
@@ -349,8 +344,6 @@ internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescri
349344
{
350345
schema.ApplyValidationAttributes(validationAttributes);
351346
}
352-
353-
schema.ApplyNullabilityContextInfo(parameterInfo);
354347
}
355348
// Route constraints are only defined on parameters that are sourced from the path. Since
356349
// they are encoded in the route template, and not in the type information based to the underlying
@@ -450,28 +443,6 @@ private static bool IsNonAbstractTypeWithoutDerivedTypeReference(JsonSchemaExpor
450443
&& !polymorphismOptions.DerivedTypes.Any(type => type.DerivedType == context.TypeInfo.Type);
451444
}
452445

453-
/// <summary>
454-
/// Support applying nullability status for reference types provided as a parameter.
455-
/// </summary>
456-
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
457-
/// <param name="parameterInfo">The <see cref="ParameterInfo" /> associated with the schema.</param>
458-
internal static void ApplyNullabilityContextInfo(this JsonNode schema, ParameterInfo parameterInfo)
459-
{
460-
if (parameterInfo.ParameterType.IsValueType)
461-
{
462-
return;
463-
}
464-
465-
var nullabilityInfoContext = new NullabilityInfoContext();
466-
var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo);
467-
if (nullabilityInfo.WriteState == NullabilityState.Nullable
468-
&& MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes
469-
&& !schemaTypes.HasFlag(JsonSchemaType.Null))
470-
{
471-
schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString();
472-
}
473-
}
474-
475446
/// <summary>
476447
/// Support applying nullability status for reference types provided as a property or field.
477448
/// </summary>

src/OpenApi/src/Extensions/TypeExtensions.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Reflection;
66
using Microsoft.AspNetCore.Mvc.ApiExplorer;
77
using Microsoft.AspNetCore.Mvc.Controllers;
8+
using Microsoft.AspNetCore.Mvc.Infrastructure;
89

910
namespace Microsoft.AspNetCore.OpenApi;
1011

@@ -70,4 +71,28 @@ public static bool ShouldApplyNullableResponseSchema(this ApiResponseType apiRes
7071

7172
return nullabilityInfo.WriteState == NullabilityState.Nullable;
7273
}
74+
75+
public static bool ShouldApplyNullableRequestSchema(this ApiParameterDescription apiParameterDescription)
76+
{
77+
var parameterType = apiParameterDescription.Type;
78+
if (parameterType is null)
79+
{
80+
return false;
81+
}
82+
83+
if (apiParameterDescription.ParameterDescriptor is not IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo })
84+
{
85+
return false;
86+
}
87+
88+
if (parameterType.IsValueType)
89+
{
90+
return apiParameterDescription.ModelMetadata?.IsNullableValueType ?? false;
91+
}
92+
93+
var nullabilityInfoContext = new NullabilityInfoContext();
94+
var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo);
95+
96+
return nullabilityInfo.WriteState == NullabilityState.Nullable;
97+
}
7398
}

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,11 @@ private async Task<OpenApiRequestBody> GetJsonRequestBody(
751751
foreach (var requestFormat in supportedRequestFormats)
752752
{
753753
var contentType = requestFormat.MediaType;
754-
requestBody.Content[contentType] = new OpenApiMediaType { Schema = await _componentService.GetOrCreateSchemaAsync(document, bodyParameter.Type, scopedServiceProvider, schemaTransformers, bodyParameter, cancellationToken: cancellationToken) };
754+
var schema = await _componentService.GetOrCreateSchemaAsync(document, bodyParameter.Type, scopedServiceProvider, schemaTransformers, bodyParameter, cancellationToken: cancellationToken);
755+
schema = bodyParameter.ShouldApplyNullableRequestSchema()
756+
? schema.CreateAllOfNullableWrapper()
757+
: schema;
758+
requestBody.Content[contentType] = new OpenApiMediaType { Schema = schema };
755759
}
756760

757761
return requestBody;

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,18 +451,25 @@ await VerifyOpenApiDocument(builder, document =>
451451
});
452452
}
453453

454+
#nullable enable
454455
public static object[][] ArrayBasedQueryParameters =>
455456
[
456457
[(int[] id) => { }, JsonSchemaType.Integer, false],
457458
[(int?[] id) => { }, JsonSchemaType.Integer, true],
458459
[(Guid[] id) => { }, JsonSchemaType.String, false],
459460
[(Guid?[] id) => { }, JsonSchemaType.String, true],
461+
[(string[] id) => { }, JsonSchemaType.String, false],
462+
// Due to runtime restrictions, we can't resolve nullability
463+
// info for reference types as element types so this will still
464+
// encode as non-nullable.
465+
[(string?[] id) => { }, JsonSchemaType.String, false],
460466
[(DateTime[] id) => { }, JsonSchemaType.String, false],
461467
[(DateTime?[] id) => { }, JsonSchemaType.String, true],
462468
[(DateTimeOffset[] id) => { }, JsonSchemaType.String, false],
463469
[(DateTimeOffset?[] id) => { }, JsonSchemaType.String, true],
464470
[(Uri[] id) => { }, JsonSchemaType.String, false],
465471
];
472+
#nullable restore
466473

467474
[Theory]
468475
[MemberData(nameof(ArrayBasedQueryParameters))]

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

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,188 @@ await VerifyOpenApiDocument(builder, document =>
464464
});
465465
}
466466

467+
[Fact]
468+
public async Task GetOpenApiRequestBody_HandlesNullableParameterWithAllOf()
469+
{
470+
// Arrange
471+
var builder = CreateBuilder();
472+
473+
// Act
474+
#nullable enable
475+
builder.MapPost("/api/nullable-todo", (Todo? todo) => { });
476+
builder.MapPost("/api/nullable-point", (Point? point) => { });
477+
#nullable restore
478+
479+
// Assert
480+
await VerifyOpenApiDocument(builder, document =>
481+
{
482+
// Verify nullable Todo parameter
483+
var todoOperation = document.Paths["/api/nullable-todo"].Operations[HttpMethod.Post];
484+
var todoRequestBody = todoOperation.RequestBody;
485+
var todoContent = Assert.Single(todoRequestBody.Content);
486+
Assert.Equal("application/json", todoContent.Key);
487+
var todoSchema = todoContent.Value.Schema;
488+
Assert.NotNull(todoSchema.AllOf);
489+
Assert.Equal(2, todoSchema.AllOf.Count);
490+
Assert.Collection(todoSchema.AllOf,
491+
item =>
492+
{
493+
Assert.NotNull(item);
494+
Assert.Equal(JsonSchemaType.Null, item.Type);
495+
},
496+
item =>
497+
{
498+
Assert.NotNull(item);
499+
Assert.Equal(JsonSchemaType.Object, item.Type);
500+
Assert.Collection(item.Properties,
501+
property =>
502+
{
503+
Assert.Equal("id", property.Key);
504+
Assert.Equal(JsonSchemaType.Integer, property.Value.Type);
505+
Assert.Equal("int32", property.Value.Format);
506+
},
507+
property =>
508+
{
509+
Assert.Equal("title", property.Key);
510+
Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type);
511+
},
512+
property =>
513+
{
514+
Assert.Equal("completed", property.Key);
515+
Assert.Equal(JsonSchemaType.Boolean, property.Value.Type);
516+
},
517+
property =>
518+
{
519+
Assert.Equal("createdAt", property.Key);
520+
Assert.Equal(JsonSchemaType.String, property.Value.Type);
521+
Assert.Equal("date-time", property.Value.Format);
522+
});
523+
});
524+
525+
// Verify nullable Point parameter
526+
var pointOperation = document.Paths["/api/nullable-point"].Operations[HttpMethod.Post];
527+
var pointRequestBody = pointOperation.RequestBody;
528+
var pointContent = Assert.Single(pointRequestBody.Content);
529+
Assert.Equal("application/json", pointContent.Key);
530+
var pointSchema = pointContent.Value.Schema;
531+
Assert.NotNull(pointSchema.AllOf);
532+
Assert.Equal(2, pointSchema.AllOf.Count);
533+
Assert.Collection(pointSchema.AllOf,
534+
item =>
535+
{
536+
Assert.NotNull(item);
537+
Assert.Equal(JsonSchemaType.Null, item.Type);
538+
},
539+
item =>
540+
{
541+
Assert.NotNull(item);
542+
Assert.Equal(JsonSchemaType.Object, item.Type);
543+
Assert.Collection(item.Properties,
544+
property =>
545+
{
546+
Assert.Equal("x", property.Key);
547+
Assert.Equal(JsonSchemaType.Integer, property.Value.Type);
548+
Assert.Equal("int32", property.Value.Format);
549+
},
550+
property =>
551+
{
552+
Assert.Equal("y", property.Key);
553+
Assert.Equal(JsonSchemaType.Integer, property.Value.Type);
554+
Assert.Equal("int32", property.Value.Format);
555+
});
556+
});
557+
558+
Assert.Equal(["Point", "Todo"], [.. document.Components.Schemas.Keys]);
559+
Assert.Collection(document.Components.Schemas.Values,
560+
item => Assert.Equal(JsonSchemaType.Object, item.Type),
561+
item => Assert.Equal(JsonSchemaType.Object, item.Type));
562+
});
563+
}
564+
565+
[Fact]
566+
public async Task GetOpenApiRequestBody_HandlesNullableCollectionParametersWithAllOf()
567+
{
568+
// Arrange
569+
var builder = CreateBuilder();
570+
571+
// Act
572+
#nullable enable
573+
builder.MapPost("/api/nullable-array", (Todo[]? todos) => { });
574+
builder.MapPost("/api/nullable-list", (List<Todo>? todoList) => { });
575+
builder.MapPost("/api/nullable-enumerable", (IEnumerable<Todo>? todoEnumerable) => { });
576+
#nullable restore
577+
578+
// Assert
579+
await VerifyOpenApiDocument(builder, document =>
580+
{
581+
// Verify nullable array parameter - verify actual behavior with AllOf
582+
var arrayOperation = document.Paths["/api/nullable-array"].Operations[HttpMethod.Post];
583+
var arrayRequestBody = arrayOperation.RequestBody;
584+
var arrayContent = Assert.Single(arrayRequestBody.Content);
585+
Assert.Equal("application/json", arrayContent.Key);
586+
var arraySchema = arrayContent.Value.Schema;
587+
Assert.NotNull(arraySchema.AllOf); // AllOf IS used for nullable collections
588+
Assert.Equal(2, arraySchema.AllOf.Count);
589+
Assert.Collection(arraySchema.AllOf,
590+
item =>
591+
{
592+
Assert.NotNull(item);
593+
Assert.Equal(JsonSchemaType.Null, item.Type);
594+
},
595+
item =>
596+
{
597+
Assert.NotNull(item);
598+
Assert.Equal(JsonSchemaType.Array, item.Type);
599+
Assert.NotNull(item.Items);
600+
Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id);
601+
});
602+
603+
// Verify nullable List parameter - verify actual behavior with AllOf
604+
var listOperation = document.Paths["/api/nullable-list"].Operations[HttpMethod.Post];
605+
var listRequestBody = listOperation.RequestBody;
606+
var listContent = Assert.Single(listRequestBody.Content);
607+
Assert.Equal("application/json", listContent.Key);
608+
var listSchema = listContent.Value.Schema;
609+
Assert.NotNull(listSchema.AllOf); // AllOf IS used for nullable collections
610+
Assert.Equal(2, listSchema.AllOf.Count);
611+
Assert.Collection(listSchema.AllOf,
612+
item =>
613+
{
614+
Assert.NotNull(item);
615+
Assert.Equal(JsonSchemaType.Null, item.Type);
616+
},
617+
item =>
618+
{
619+
Assert.NotNull(item);
620+
Assert.Equal(JsonSchemaType.Array, item.Type);
621+
Assert.NotNull(item.Items);
622+
Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id);
623+
});
624+
625+
// Verify nullable IEnumerable parameter - verify actual behavior with AllOf
626+
var enumerableOperation = document.Paths["/api/nullable-enumerable"].Operations[HttpMethod.Post];
627+
var enumerableRequestBody = enumerableOperation.RequestBody;
628+
var enumerableContent = Assert.Single(enumerableRequestBody.Content);
629+
Assert.Equal("application/json", enumerableContent.Key);
630+
var enumerableSchema = enumerableContent.Value.Schema;
631+
Assert.NotNull(enumerableSchema.AllOf); // AllOf IS used for nullable collections
632+
Assert.Equal(2, enumerableSchema.AllOf.Count);
633+
Assert.Collection(enumerableSchema.AllOf,
634+
item =>
635+
{
636+
Assert.NotNull(item);
637+
Assert.Equal(JsonSchemaType.Null, item.Type);
638+
},
639+
item =>
640+
{
641+
Assert.NotNull(item);
642+
Assert.Equal(JsonSchemaType.Array, item.Type);
643+
Assert.NotNull(item.Items);
644+
Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id);
645+
});
646+
});
647+
}
648+
467649
[Fact]
468650
public async Task SupportsNestedTypes()
469651
{

0 commit comments

Comments
 (0)