Skip to content

Commit c16c781

Browse files
authored
Add support for processing DefaultValueAttribute in OpenApi schemas (#56219)
1 parent 482730a commit c16c781

File tree

6 files changed

+177
-3
lines changed

6 files changed

+177
-3
lines changed

src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
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.ComponentModel;
45
using System.ComponentModel.DataAnnotations;
56
using System.Globalization;
67
using System.Linq;
8+
using System.Reflection;
9+
using System.Text.Json;
710
using System.Text.Json.Nodes;
11+
using System.Text.Json.Serialization.Metadata;
812
using JsonSchemaMapper;
913
using Microsoft.AspNetCore.Mvc.ApiExplorer;
14+
using Microsoft.AspNetCore.Mvc.Infrastructure;
1015
using Microsoft.AspNetCore.Routing;
1116
using Microsoft.AspNetCore.Routing.Constraints;
1217
using Microsoft.OpenApi.Models;
@@ -118,6 +123,29 @@ internal static void ApplyValidationAttributes(this JsonObject schema, IEnumerab
118123
}
119124
}
120125

126+
/// <summary>
127+
/// Populate the default value into the current schema.
128+
/// </summary>
129+
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
130+
/// <param name="defaultValue">An object representing the <see cref="object"/> associated with the default value.</param>
131+
/// <param name="jsonTypeInfo">The <see cref="JsonTypeInfo"/> associated with the target type.</param>
132+
internal static void ApplyDefaultValue(this JsonObject schema, object? defaultValue, JsonTypeInfo? jsonTypeInfo)
133+
{
134+
if (jsonTypeInfo is null)
135+
{
136+
return;
137+
}
138+
139+
if (defaultValue is null)
140+
{
141+
schema[OpenApiSchemaKeywords.DefaultKeyword] = null;
142+
}
143+
else
144+
{
145+
schema[OpenApiSchemaKeywords.DefaultKeyword] = JsonSerializer.SerializeToNode(defaultValue, jsonTypeInfo);
146+
}
147+
}
148+
121149
/// <summary>
122150
/// Applies the primitive types and formats to the schema based on the type.
123151
/// </summary>
@@ -228,7 +256,8 @@ internal static void ApplyRouteConstraints(this JsonObject schema, IEnumerable<I
228256
/// </summary>
229257
/// <param name="schema">The <see cref="JsonObject"/> produced by the underlying schema generator.</param>
230258
/// <param name="parameterDescription">The <see cref="ApiParameterDescription"/> associated with the <see paramref="schema"/>.</param>
231-
internal static void ApplyParameterInfo(this JsonObject schema, ApiParameterDescription parameterDescription)
259+
/// <param name="jsonTypeInfo">The <see cref="JsonTypeInfo"/> associated with the <see paramref="schema"/>.</param>
260+
internal static void ApplyParameterInfo(this JsonObject schema, ApiParameterDescription parameterDescription, JsonTypeInfo? jsonTypeInfo)
232261
{
233262
// This is special handling for parameters that are not bound from the body but represented in a complex type.
234263
// For example:
@@ -251,6 +280,17 @@ internal static void ApplyParameterInfo(this JsonObject schema, ApiParameterDesc
251280
{
252281
var attributes = validations.OfType<ValidationAttribute>();
253282
schema.ApplyValidationAttributes(attributes);
283+
if (parameterDescription.ParameterDescriptor is IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo })
284+
{
285+
if (parameterInfo.HasDefaultValue)
286+
{
287+
schema.ApplyDefaultValue(parameterInfo.DefaultValue, jsonTypeInfo);
288+
}
289+
else if (parameterInfo.GetCustomAttributes<DefaultValueAttribute>().LastOrDefault() is { } defaultValueAttribute)
290+
{
291+
schema.ApplyDefaultValue(defaultValueAttribute.Value, jsonTypeInfo);
292+
}
293+
}
254294
}
255295
// Route constraints are only defined on parameters that are sourced from the path. Since
256296
// they are encoded in the route template, and not in the type information based to the underlying

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +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.ComponentModel;
45
using System.ComponentModel.DataAnnotations;
56
using System.Diagnostics;
67
using System.IO.Pipelines;
8+
using System.Linq;
79
using System.Text.Json;
810
using System.Text.Json.Nodes;
911
using JsonSchemaMapper;
@@ -59,7 +61,10 @@ internal sealed class OpenApiSchemaService(
5961
{
6062
schema.ApplyValidationAttributes(validationAttributes);
6163
}
62-
64+
if (context.GetCustomAttributes(typeof(DefaultValueAttribute)).LastOrDefault() is DefaultValueAttribute defaultValueAttribute)
65+
{
66+
schema.ApplyDefaultValue(defaultValueAttribute.Value, context.TypeInfo);
67+
}
6368
}
6469
};
6570

@@ -71,7 +76,7 @@ internal async Task<OpenApiSchema> GetOrCreateSchemaAsync(Type type, ApiParamete
7176
var schemaAsJsonObject = _schemaStore.GetOrAdd(key, CreateSchema);
7277
if (parameterDescription is not null)
7378
{
74-
schemaAsJsonObject.ApplyParameterInfo(parameterDescription);
79+
schemaAsJsonObject.ApplyParameterInfo(parameterDescription, _jsonSerializerOptions.GetTypeInfo(type));
7580
}
7681
var deserializedSchema = JsonSerializer.Deserialize(schemaAsJsonObject, OpenApiJsonSchemaContext.Default.OpenApiJsonSchema);
7782
Debug.Assert(deserializedSchema != null, "The schema should have been deserialized successfully and materialize a non-null value.");

src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ParameterSchemas.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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.ComponentModel;
45
using System.ComponentModel.DataAnnotations;
56
using Microsoft.AspNetCore.Builder;
67
using Microsoft.AspNetCore.Http;
@@ -145,6 +146,7 @@ await VerifyOpenApiDocument(builder, document =>
145146
});
146147
}
147148

149+
#nullable enable
148150
public static object[][] RouteParametersWithDefaultValues =>
149151
[
150152
[(int id = 2) => { }, (IOpenApiAny defaultValue) => Assert.Equal(2, ((OpenApiInteger)defaultValue).Value)],
@@ -154,6 +156,18 @@ await VerifyOpenApiDocument(builder, document =>
154156
[(TaskStatus status = TaskStatus.Canceled) => { }, (IOpenApiAny defaultValue) => Assert.Equal(6, ((OpenApiInteger)defaultValue).Value)],
155157
// Default value for enums is serialized as string when a converter is registered.
156158
[(Status status = Status.Pending) => { }, (IOpenApiAny defaultValue) => Assert.Equal("Pending", ((OpenApiString)defaultValue).Value)],
159+
[([DefaultValue(2)] int id) => { }, (IOpenApiAny defaultValue) => Assert.Equal(2, ((OpenApiInteger)defaultValue).Value)],
160+
[([DefaultValue(3f)] float id) => { }, (IOpenApiAny defaultValue) => Assert.Equal(3, ((OpenApiInteger)defaultValue).Value)],
161+
[([DefaultValue("test")] string id) => { }, (IOpenApiAny defaultValue) => Assert.Equal("test", ((OpenApiString)defaultValue).Value)],
162+
[([DefaultValue(true)] bool id) => { }, (IOpenApiAny defaultValue) => Assert.True(((OpenApiBoolean)defaultValue).Value)],
163+
[([DefaultValue(TaskStatus.Canceled)] TaskStatus status) => { }, (IOpenApiAny defaultValue) => Assert.Equal(6, ((OpenApiInteger)defaultValue).Value)],
164+
[([DefaultValue(Status.Pending)] Status status) => { }, (IOpenApiAny defaultValue) => Assert.Equal("Pending", ((OpenApiString)defaultValue).Value)],
165+
[([DefaultValue(null)] int? id) => { }, (IOpenApiAny defaultValue) => Assert.True(defaultValue is OpenApiNull)],
166+
[([DefaultValue(2)] int? id) => { }, (IOpenApiAny defaultValue) => Assert.Equal(2, ((OpenApiInteger)defaultValue).Value)],
167+
[([DefaultValue(null)] string? id) => { }, (IOpenApiAny defaultValue) => Assert.True(defaultValue is OpenApiNull)],
168+
[([DefaultValue("foo")] string? id) => { }, (IOpenApiAny defaultValue) => Assert.Equal("foo", ((OpenApiString)defaultValue).Value)],
169+
[([DefaultValue(null)] TaskStatus? status) => { }, (IOpenApiAny defaultValue) => Assert.True(defaultValue is OpenApiNull)],
170+
[([DefaultValue(TaskStatus.Canceled)] TaskStatus? status) => { }, (IOpenApiAny defaultValue) => Assert.Equal(6, ((OpenApiInteger)defaultValue).Value)],
157171
];
158172

159173
[Theory]
@@ -175,6 +189,7 @@ await VerifyOpenApiDocument(builder, document =>
175189
assert(openApiDefault);
176190
});
177191
}
192+
#nullable restore
178193

179194
[Fact]
180195
public async Task GetOpenApiParameters_HandlesEnumParameterWithoutConverter()

src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.IO.Pipelines;
55
using Microsoft.AspNetCore.Builder;
66
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.OpenApi.Any;
78
using Microsoft.OpenApi.Models;
89

910
public partial class OpenApiComponentServiceTests : OpenApiDocumentServiceTestBase
@@ -54,6 +55,53 @@ await VerifyOpenApiDocument(builder, document =>
5455
});
5556
}
5657

58+
[Fact]
59+
public async Task GetOpenApiRequestBody_GeneratesSchemaForPoco_WithValidationAttributes()
60+
{
61+
// Arrange
62+
var builder = CreateBuilder();
63+
64+
// Act
65+
builder.MapPost("/", (ProjectBoard todo) => { });
66+
67+
// Assert
68+
await VerifyOpenApiDocument(builder, document =>
69+
{
70+
var operation = document.Paths["/"].Operations[OperationType.Post];
71+
var requestBody = operation.RequestBody;
72+
73+
Assert.NotNull(requestBody);
74+
var content = Assert.Single(requestBody.Content);
75+
Assert.Equal("application/json", content.Key);
76+
Assert.NotNull(content.Value.Schema);
77+
Assert.Equal("object", content.Value.Schema.Type);
78+
Assert.Collection(content.Value.Schema.Properties,
79+
property =>
80+
{
81+
Assert.Equal("id", property.Key);
82+
Assert.Equal("integer", property.Value.Type);
83+
Assert.Equal(1, property.Value.Minimum);
84+
Assert.Equal(100, property.Value.Maximum);
85+
Assert.True(property.Value.Default is OpenApiNull);
86+
},
87+
property =>
88+
{
89+
Assert.Equal("name", property.Key);
90+
Assert.Equal("string", property.Value.Type);
91+
Assert.Equal(5, property.Value.MinLength);
92+
Assert.True(property.Value.Default is OpenApiNull);
93+
},
94+
property =>
95+
{
96+
Assert.Equal("isPrivate", property.Key);
97+
Assert.Equal("boolean", property.Value.Type);
98+
var defaultValue = Assert.IsAssignableFrom<OpenApiBoolean>(property.Value.Default);
99+
Assert.True(defaultValue.Value);
100+
});
101+
102+
});
103+
}
104+
57105
[Fact]
58106
public async Task GetOpenApiRequestBody_GeneratesSchemaForFileTypes()
59107
{

src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ResponseSchemas.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using Microsoft.AspNetCore.Builder;
55
using Microsoft.AspNetCore.Http;
6+
using Microsoft.OpenApi.Any;
67
using Microsoft.OpenApi.Models;
78

89
public partial class OpenApiComponentServiceTests : OpenApiDocumentServiceTestBase
@@ -90,6 +91,51 @@ await VerifyOpenApiDocument(builder, document =>
9091
});
9192
}
9293

94+
[Fact]
95+
public async Task GetOpenApiResponse_GeneratesSchemaForPoco_WithValidationAttributes()
96+
{
97+
// Arrange
98+
var builder = CreateBuilder();
99+
100+
// Act
101+
builder.MapGet("/", () => new ProjectBoard { Id = 2, Name = "Test", IsPrivate = false });
102+
103+
// Assert
104+
await VerifyOpenApiDocument(builder, document =>
105+
{
106+
var operation = document.Paths["/"].Operations[OperationType.Get];
107+
var response = operation.Responses["200"];
108+
109+
Assert.NotNull(response);
110+
var content = Assert.Single(response.Content);
111+
Assert.Equal("application/json", content.Key);
112+
Assert.NotNull(content.Value.Schema);
113+
Assert.Equal("object", content.Value.Schema.Type);
114+
Assert.Collection(content.Value.Schema.Properties,
115+
property =>
116+
{
117+
Assert.Equal("id", property.Key);
118+
Assert.Equal("integer", property.Value.Type);
119+
Assert.Equal(1, property.Value.Minimum);
120+
Assert.Equal(100, property.Value.Maximum);
121+
},
122+
property =>
123+
{
124+
Assert.Equal("name", property.Key);
125+
Assert.Equal("string", property.Value.Type);
126+
Assert.Equal(5, property.Value.MinLength);
127+
},
128+
property =>
129+
{
130+
Assert.Equal("isPrivate", property.Key);
131+
Assert.Equal("boolean", property.Value.Type);
132+
var defaultValue = Assert.IsAssignableFrom<OpenApiBoolean>(property.Value.Default);
133+
Assert.True(defaultValue.Value);
134+
});
135+
136+
});
137+
}
138+
93139
[Fact]
94140
public async Task GetOpenApiResponse_HandlesNullablePocoResponse()
95141
{

src/OpenApi/test/SharedTypes.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
// This file contains shared types that are used across tests, sample apps,
55
// and benchmark apps.
66

7+
using System.ComponentModel;
8+
using System.ComponentModel.DataAnnotations;
9+
using System.Diagnostics.CodeAnalysis;
710
using System.Text.Json.Serialization;
811
using Microsoft.AspNetCore.Http;
912

@@ -72,3 +75,20 @@ internal class PaginatedItems<T>(int pageIndex, int pageSize, long totalItems, i
7275
public int TotalPages { get; set; } = totalPages;
7376
public IEnumerable<T> Items { get; set; } = items;
7477
}
78+
79+
#nullable enable
80+
internal class ProjectBoard
81+
{
82+
[Range(1, 100)]
83+
[DefaultValue(null)]
84+
public int Id { get; set; }
85+
86+
[MinLength(5)]
87+
[DefaultValue(null)]
88+
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Used in tests.")]
89+
public string? Name { get; set; }
90+
91+
[DefaultValue(true)]
92+
public required bool IsPrivate { get; set; }
93+
}
94+
#nullable restore

0 commit comments

Comments
 (0)