Skip to content

Commit 333f1de

Browse files
authored
Add support for processing RequiredAttribute on OpenApi schemas (#56225)
* Add support for processing RequiredAttribute on OpenApi schemas * Bring back missing using * Address feedback * Fix bad merge
1 parent a6fe7f8 commit 333f1de

File tree

6 files changed

+144
-6
lines changed

6 files changed

+144
-6
lines changed

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Collections.Concurrent;
5+
using System.ComponentModel.DataAnnotations;
56
using System.Diagnostics;
67
using System.Diagnostics.CodeAnalysis;
78
using System.Globalization;
@@ -11,6 +12,7 @@
1112
using Microsoft.AspNetCore.Http.Metadata;
1213
using Microsoft.AspNetCore.Mvc;
1314
using Microsoft.AspNetCore.Mvc.ApiExplorer;
15+
using Microsoft.AspNetCore.Mvc.Infrastructure;
1416
using Microsoft.AspNetCore.Mvc.ModelBinding;
1517
using Microsoft.AspNetCore.Routing;
1618
using Microsoft.AspNetCore.WebUtilities;
@@ -262,9 +264,7 @@ private async Task<OpenApiResponse> GetResponseAsync(ApiDescription apiDescripti
262264
"Path" => ParameterLocation.Path,
263265
_ => throw new InvalidOperationException($"Unsupported parameter source: {parameter.Source.Id}")
264266
},
265-
// Per the OpenAPI specification, parameters that are sourced from the path
266-
// are always required, regardless of the requiredness status of the parameter.
267-
Required = parameter.Source == BindingSource.Path || parameter.IsRequired,
267+
Required = IsRequired(parameter),
268268
Schema = await _componentService.GetOrCreateSchemaAsync(parameter.Type, parameter, cancellationToken),
269269
};
270270
parameters ??= [];
@@ -273,6 +273,15 @@ private async Task<OpenApiResponse> GetResponseAsync(ApiDescription apiDescripti
273273
return parameters;
274274
}
275275

276+
private static bool IsRequired(ApiParameterDescription parameter)
277+
{
278+
var hasRequiredAttribute = parameter.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescriptor &&
279+
parameterInfoDescriptor.ParameterInfo.GetCustomAttributes(inherit: true).Any(attr => attr is RequiredAttribute);
280+
// Per the OpenAPI specification, parameters that are sourced from the path
281+
// are always required, regardless of the requiredness status of the parameter.
282+
return parameter.Source == BindingSource.Path || parameter.IsRequired || hasRequiredAttribute;
283+
}
284+
276285
private async Task<OpenApiRequestBody?> GetRequestBodyAsync(ApiDescription description, CancellationToken cancellationToken)
277286
{
278287
// Only one parameter can be bound from the body in each request.
@@ -301,7 +310,7 @@ private async Task<OpenApiRequestBody> GetFormRequestBody(IList<ApiRequestFormat
301310

302311
var requestBody = new OpenApiRequestBody
303312
{
304-
Required = formParameters.Any(parameter => parameter.IsRequired),
313+
Required = formParameters.Any(IsRequired),
305314
Content = new Dictionary<string, OpenApiMediaType>()
306315
};
307316

@@ -435,7 +444,7 @@ private async Task<OpenApiRequestBody> GetJsonRequestBody(IList<ApiRequestFormat
435444

436445
var requestBody = new OpenApiRequestBody
437446
{
438-
Required = bodyParameter.IsRequired,
447+
Required = IsRequired(bodyParameter),
439448
Content = new Dictionary<string, OpenApiMediaType>()
440449
};
441450

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Linq;
99
using System.Text.Json;
1010
using System.Text.Json.Nodes;
11+
using System.Text.Json.Serialization.Metadata;
1112
using JsonSchemaMapper;
1213
using Microsoft.AspNetCore.Http;
1314
using Microsoft.AspNetCore.Http.Json;
@@ -32,7 +33,26 @@ internal sealed class OpenApiSchemaService(
3233
{
3334
private readonly OpenApiSchemaStore _schemaStore = serviceProvider.GetRequiredKeyedService<OpenApiSchemaStore>(documentName);
3435
private readonly OpenApiOptions _openApiOptions = optionsMonitor.Get(documentName);
35-
private readonly JsonSerializerOptions _jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
36+
private readonly JsonSerializerOptions _jsonSerializerOptions = new(jsonOptions.Value.SerializerOptions)
37+
{
38+
// In order to properly handle the `RequiredAttribute` on type properties, add a modifier to support
39+
// setting `JsonPropertyInfo.IsRequired` based on the presence of the `RequiredAttribute`.
40+
TypeInfoResolver = jsonOptions.Value.SerializerOptions.TypeInfoResolver?.WithAddedModifier(jsonTypeInfo =>
41+
{
42+
if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object)
43+
{
44+
return;
45+
}
46+
foreach (var propertyInfo in jsonTypeInfo.Properties)
47+
{
48+
var hasRequiredAttribute = propertyInfo.AttributeProvider?
49+
.GetCustomAttributes(inherit: false)
50+
.Any(attr => attr is RequiredAttribute);
51+
propertyInfo.IsRequired |= hasRequiredAttribute ?? false;
52+
}
53+
})
54+
};
55+
3656
private readonly JsonSchemaMapperConfiguration _configuration = new()
3757
{
3858
OnSchemaGenerated = (context, schema) =>

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,31 @@ await VerifyOpenApiDocument(builder, document =>
359359
});
360360
}
361361

362+
[Fact]
363+
public async Task GetOpenApiParameters_HandlesParametersWithRequiredAttribute()
364+
{
365+
// Arrange
366+
var builder = CreateBuilder();
367+
368+
// Act -- route parameters are always required so we test other
369+
// parameter sources here.
370+
builder.MapGet("/api-1", ([Required] string id) => { });
371+
builder.MapGet("/api-2", ([Required] int? age) => { });
372+
builder.MapGet("/api-3", ([Required] Guid guid) => { });
373+
builder.MapGet("/api-4", ([Required][FromHeader] DateTime date) => { });
374+
375+
// Assert
376+
await VerifyOpenApiDocument(builder, document =>
377+
{
378+
foreach (var path in document.Paths.Values)
379+
{
380+
var operation = path.Operations[OperationType.Get];
381+
var parameter = Assert.Single(operation.Parameters);
382+
Assert.True(parameter.Required);
383+
}
384+
});
385+
}
386+
362387
public static object[][] ArrayBasedQueryParameters =>
363388
[
364389
[(int[] id) => { }, "integer", false],

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

Lines changed: 53 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.DataAnnotations;
45
using System.IO.Pipelines;
56
using Microsoft.AspNetCore.Builder;
67
using Microsoft.AspNetCore.Mvc;
@@ -102,6 +103,58 @@ await VerifyOpenApiDocument(builder, document =>
102103
});
103104
}
104105

106+
[Fact]
107+
public async Task GetOpenApiRequestBody_RespectsRequiredAttributeOnBodyParameter()
108+
{
109+
// Arrange
110+
var builder = CreateBuilder();
111+
112+
// Act
113+
builder.MapPost("/required-poco", ([Required] Todo todo) => { });
114+
builder.MapPost("/non-required-poco", (Todo todo) => { });
115+
builder.MapPost("/required-form", ([Required][FromForm] Todo todo) => { });
116+
builder.MapPost("/non-required-form", ([FromForm] Todo todo) => { });
117+
builder.MapPost("/", (ProjectBoard todo) => { });
118+
119+
// Assert
120+
await VerifyOpenApiDocument(builder, document =>
121+
{
122+
Assert.True(GetRequestBodyForPath(document, "/required-poco").Required);
123+
Assert.False(GetRequestBodyForPath(document, "/non-required-poco").Required);
124+
Assert.True(GetRequestBodyForPath(document, "/required-form").Required);
125+
Assert.False(GetRequestBodyForPath(document, "/non-required-form").Required);
126+
});
127+
128+
static OpenApiRequestBody GetRequestBodyForPath(OpenApiDocument document, string path)
129+
{
130+
var operation = document.Paths[path].Operations[OperationType.Post];
131+
return operation.RequestBody;
132+
}
133+
}
134+
135+
[Fact]
136+
public async Task GetOpenApiRequestBody_RespectsRequiredAttributeOnBodyProperties()
137+
{
138+
// Arrange
139+
var builder = CreateBuilder();
140+
141+
// Act
142+
builder.MapPost("/required-properties", (RequiredTodo todo) => { });
143+
144+
// Assert
145+
await VerifyOpenApiDocument(builder, document =>
146+
{
147+
var operation = document.Paths["/required-properties"].Operations[OperationType.Post];
148+
var requestBody = operation.RequestBody;
149+
var content = Assert.Single(requestBody.Content);
150+
var schema = content.Value.Schema;
151+
Assert.Collection(schema.Required,
152+
property => Assert.Equal("title", property),
153+
property => Assert.Equal("completed", property));
154+
Assert.DoesNotContain("assignee", schema.Required);
155+
});
156+
}
157+
105158
[Fact]
106159
public async Task GetOpenApiRequestBody_GeneratesSchemaForFileTypes()
107160
{

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,28 @@ await VerifyOpenApiDocument(builder, document =>
182182
});
183183
}
184184

185+
[Fact]
186+
public async Task GetOpenApiResponse_RespectsRequiredAttributeOnBodyProperties()
187+
{
188+
// Arrange
189+
var builder = CreateBuilder();
190+
191+
// Act
192+
builder.MapPost("/required-properties", () => new RequiredTodo { Title = "Test Title", Completed = true });
193+
194+
// Assert
195+
await VerifyOpenApiDocument(builder, document =>
196+
{
197+
var operation = document.Paths["/required-properties"].Operations[OperationType.Post];
198+
var response = operation.Responses["200"];
199+
var content = Assert.Single(response.Content);
200+
var schema = content.Value.Schema;
201+
Assert.Collection(schema.Required,
202+
property => Assert.Equal("title", property),
203+
property => Assert.Equal("completed", property));
204+
});
205+
}
206+
185207
[Fact]
186208
public async Task GetOpenApiResponse_HandlesInheritedTypeResponse()
187209
{

src/OpenApi/test/SharedTypes.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ internal class PaginatedItems<T>(int pageIndex, int pageSize, long totalItems, i
7676
public IEnumerable<T> Items { get; set; } = items;
7777
}
7878

79+
internal class RequiredTodo
80+
{
81+
[Required]
82+
public string Title { get; set; } = string.Empty;
83+
[Required]
84+
public bool Completed { get; set; }
85+
public string Assignee { get; set; } = string.Empty;
86+
}
87+
7988
#nullable enable
8089
internal class ProjectBoard
8190
{

0 commit comments

Comments
 (0)