Skip to content

Commit 3f3e90f

Browse files
committed
OpenAPI: Prune null from enum/type in componentized schemas
Improve handling of nullable enums and componentized types in OpenAPI schema generation. - Remove "null" from type/enum arrays and use nullable/oneOf for nullability. - Add tests to verify correct schema references and nullability for parameters and request bodies.
1 parent 5e25f15 commit 3f3e90f

File tree

3 files changed

+84
-7
lines changed

3 files changed

+84
-7
lines changed

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -507,19 +507,29 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope
507507
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
508508
internal static void PruneNullTypeForComponentizedTypes(this JsonNode schema)
509509
{
510-
if (schema.WillBeComponentized() &&
511-
schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray)
510+
if (schema.WillBeComponentized())
512511
{
513-
for (var i = typeArray.Count - 1; i >= 0; i--)
512+
if (schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray)
514513
{
515-
if (typeArray[i]?.GetValue<string>() == "null")
514+
for (var i = typeArray.Count - 1; i >= 0; i--)
516515
{
517-
typeArray.RemoveAt(i);
516+
if (typeArray[i]?.GetValue<string>() == "null")
517+
{
518+
typeArray.RemoveAt(i);
519+
}
520+
}
521+
if (typeArray.Count == 1)
522+
{
523+
schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue<string>();
518524
}
519525
}
520-
if (typeArray.Count == 1)
526+
else if (schema[OpenApiSchemaKeywords.EnumKeyword] is JsonArray enumArray)
521527
{
522-
schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue<string>();
528+
var hasRemovedNull = enumArray.Remove(null);
529+
if (hasRemovedNull)
530+
{
531+
schema[OpenApiConstants.NullableProperty] = true;
532+
}
523533
}
524534
}
525535
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,38 @@ await VerifyOpenApiDocument(builder, document =>
977977
});
978978
}
979979

980+
[Fact]
981+
public async Task GetOpenApiParameters_DoesNotGenerateNullComponetizedSchemaAsNullableParametersAreOptional()
982+
{
983+
// Arrange
984+
var builder1 = CreateBuilder();
985+
builder1.MapGet("/query", (Status status, Status? optionalStatus) => "TEST");
986+
987+
var builder2 = CreateBuilder();
988+
builder2.MapGet("/query", (Status? optionalStatus, Status status) => "TEST");
989+
990+
// Assert
991+
await VerifyOpenApiDocument(builder1, VerifyNonNullComponetizedSchema);
992+
await VerifyOpenApiDocument(builder2, VerifyNonNullComponetizedSchema);
993+
994+
static void VerifyNonNullComponetizedSchema(OpenApiDocument doc)
995+
{
996+
var schema = doc.Components.Schemas["Status"];
997+
Assert.DoesNotContain(schema.Enum, node => node is null);
998+
Assert.Equal(3, schema.Enum.Count);
999+
1000+
var operation = doc.Paths["/query"].Operations[HttpMethod.Get];
1001+
1002+
Assert.Equal(2, operation.Parameters.Count);
1003+
var nullableParam = operation.Parameters.First(p => p.Name == "optionalStatus");
1004+
Assert.Equal("Status", ((OpenApiSchemaReference)nullableParam.Schema).Reference.Id);
1005+
1006+
var notNullableParam = operation.Parameters.First(p => p.Name == "status");
1007+
var notNullableSchemaReference = Assert.IsType<OpenApiSchemaReference>(notNullableParam.Schema);
1008+
Assert.Equal("Status", notNullableSchemaReference.Reference.Id);
1009+
}
1010+
}
1011+
9801012
[ApiController]
9811013
[Route("[controller]/[action]")]
9821014
private class TestFromQueryController : ControllerBase

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,4 +999,39 @@ internal Status FormPostWithOptionalEnumParam(
999999
[FromForm(Name = "status")] Status status = Status.Approved
10001000
) => status;
10011001
}
1002+
1003+
[Fact]
1004+
private async Task HandlesNullableEnumWithOneOf()
1005+
{
1006+
var builder = CreateBuilder();
1007+
1008+
builder.MapPost("/nullableEnum", (NullableEnumDto body) => { });
1009+
1010+
await VerifyOpenApiDocument(builder, (document) =>
1011+
{
1012+
var enumDtoSchema = document.Components.Schemas["NullableEnumDto"];
1013+
1014+
var statusPropertySchema = enumDtoSchema.Properties["status"];
1015+
Assert.Collection(statusPropertySchema.OneOf,
1016+
item =>
1017+
{
1018+
Assert.NotNull(item);
1019+
Assert.Equal(JsonSchemaType.Null, item.Type);
1020+
},
1021+
item =>
1022+
{
1023+
Assert.NotNull(item);
1024+
Assert.Equal("Status", ((OpenApiSchemaReference)item).Reference.Id);
1025+
});
1026+
1027+
var statusEnumSchema = document.Components.Schemas["Status"];
1028+
Assert.Equal(3, statusEnumSchema.Enum.Count);
1029+
Assert.DoesNotContain(null, statusEnumSchema.Enum);
1030+
});
1031+
}
1032+
1033+
internal class NullableEnumDto
1034+
{
1035+
public Status? Status { get; set; }
1036+
}
10021037
}

0 commit comments

Comments
 (0)