Skip to content

Commit 123bd06

Browse files
authored
Set IsRequired on ApiDescriptions for endpoints (#35233)
* Set isRequired on ApiDescriptions for endpoints - Use the same logic we have in RequestDelegateFactory.Create to determine if a method parameter is required or not. We then set the IsRequired property on the ApiParameterDesciption.
1 parent d596183 commit 123bd06

3 files changed

+83
-23
lines changed

src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs

+17-11
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ internal class EndpointMetadataApiDescriptionProvider : IApiDescriptionProvider
2828
private readonly IHostEnvironment _environment;
2929
private readonly IServiceProviderIsService? _serviceProviderIsService;
3030
private readonly TryParseMethodCache TryParseMethodCache = new();
31+
private readonly NullabilityInfoContext NullabilityContext = new();
3132

3233
// Executes before MVC's DefaultApiDescriptionProvider and GrpcHttpApiDescriptionProvider for no particular reason.
3334
public int Order => -1100;
@@ -132,45 +133,50 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string
132133

133134
private ApiParameterDescription? CreateApiParameterDescription(ParameterInfo parameter, RoutePattern pattern)
134135
{
135-
var (source, name) = GetBindingSourceAndName(parameter, pattern);
136+
var (source, name, allowEmpty) = GetBindingSourceAndName(parameter, pattern);
136137

137138
// Services are ignored because they are not request parameters.
138139
if (source == BindingSource.Services)
139140
{
140141
return null;
141142
}
142143

144+
// Determine the "requiredness" based on nullability, default value or if allowEmpty is set
145+
var nullability = NullabilityContext.Create(parameter);
146+
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable || allowEmpty;
147+
143148
return new ApiParameterDescription
144149
{
145150
Name = name,
146151
ModelMetadata = CreateModelMetadata(parameter.ParameterType),
147152
Source = source,
148153
DefaultValue = parameter.DefaultValue,
149154
Type = parameter.ParameterType,
155+
IsRequired = !isOptional
150156
};
151157
}
152158

153159
// TODO: Share more of this logic with RequestDelegateFactory.CreateArgument(...) using RequestDelegateFactoryUtilities
154160
// which is shared source.
155-
private (BindingSource, string) GetBindingSourceAndName(ParameterInfo parameter, RoutePattern pattern)
161+
private (BindingSource, string, bool) GetBindingSourceAndName(ParameterInfo parameter, RoutePattern pattern)
156162
{
157163
var attributes = parameter.GetCustomAttributes();
158164

159165
if (attributes.OfType<IFromRouteMetadata>().FirstOrDefault() is { } routeAttribute)
160166
{
161-
return (BindingSource.Path, routeAttribute.Name ?? parameter.Name ?? string.Empty);
167+
return (BindingSource.Path, routeAttribute.Name ?? parameter.Name ?? string.Empty, false);
162168
}
163169
else if (attributes.OfType<IFromQueryMetadata>().FirstOrDefault() is { } queryAttribute)
164170
{
165-
return (BindingSource.Query, queryAttribute.Name ?? parameter.Name ?? string.Empty);
171+
return (BindingSource.Query, queryAttribute.Name ?? parameter.Name ?? string.Empty, false);
166172
}
167173
else if (attributes.OfType<IFromHeaderMetadata>().FirstOrDefault() is { } headerAttribute)
168174
{
169-
return (BindingSource.Header, headerAttribute.Name ?? parameter.Name ?? string.Empty);
175+
return (BindingSource.Header, headerAttribute.Name ?? parameter.Name ?? string.Empty, false);
170176
}
171-
else if (parameter.CustomAttributes.Any(a => typeof(IFromBodyMetadata).IsAssignableFrom(a.AttributeType)))
177+
else if (attributes.OfType<IFromBodyMetadata>().FirstOrDefault() is { } fromBodyAttribute)
172178
{
173-
return (BindingSource.Body, parameter.Name ?? string.Empty);
179+
return (BindingSource.Body, parameter.Name ?? string.Empty, fromBodyAttribute.AllowEmpty);
174180
}
175181
else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType)) ||
176182
parameter.ParameterType == typeof(HttpContext) ||
@@ -180,23 +186,23 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string
180186
parameter.ParameterType == typeof(CancellationToken) ||
181187
_serviceProviderIsService?.IsService(parameter.ParameterType) == true)
182188
{
183-
return (BindingSource.Services, parameter.Name ?? string.Empty);
189+
return (BindingSource.Services, parameter.Name ?? string.Empty, false);
184190
}
185191
else if (parameter.ParameterType == typeof(string) || TryParseMethodCache.HasTryParseMethod(parameter))
186192
{
187193
// Path vs query cannot be determined by RequestDelegateFactory at startup currently because of the layering, but can be done here.
188194
if (parameter.Name is { } name && pattern.GetParameter(name) is not null)
189195
{
190-
return (BindingSource.Path, name);
196+
return (BindingSource.Path, name, false);
191197
}
192198
else
193199
{
194-
return (BindingSource.Query, parameter.Name ?? string.Empty);
200+
return (BindingSource.Query, parameter.Name ?? string.Empty, false);
195201
}
196202
}
197203
else
198204
{
199-
return (BindingSource.Body, parameter.Name ?? string.Empty);
205+
return (BindingSource.Body, parameter.Name ?? string.Empty, false);
200206
}
201207
}
202208

src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs

+65-12
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,31 @@ static void AssertJsonRequestFormat(ApiDescription apiDescription)
7676
[Fact]
7777
public void AddsRequestFormatFromMetadata()
7878
{
79-
static void AssertustomRequestFormat(ApiDescription apiDescription)
79+
static void AssertCustomRequestFormat(ApiDescription apiDescription)
8080
{
8181
var requestFormat = Assert.Single(apiDescription.SupportedRequestFormats);
8282
Assert.Equal("application/custom", requestFormat.MediaType);
8383
Assert.Null(requestFormat.Formatter);
8484
}
8585

86-
AssertustomRequestFormat(GetApiDescription(
86+
AssertCustomRequestFormat(GetApiDescription(
8787
[Consumes("application/custom")]
88-
(InferredJsonClass fromBody) => { }));
88+
(InferredJsonClass fromBody) =>
89+
{ }));
8990

90-
AssertustomRequestFormat(GetApiDescription(
91+
AssertCustomRequestFormat(GetApiDescription(
9192
[Consumes("application/custom")]
92-
([FromBody] int fromBody) => { }));
93+
([FromBody] int fromBody) =>
94+
{ }));
9395
}
9496

9597
[Fact]
9698
public void AddsMultipleRequestFormatsFromMetadata()
9799
{
98100
var apiDescription = GetApiDescription(
99101
[Consumes("application/custom0", "application/custom1")]
100-
(InferredJsonClass fromBody) => { });
102+
(InferredJsonClass fromBody) =>
103+
{ });
101104

102105
Assert.Equal(2, apiDescription.SupportedRequestFormats.Count);
103106

@@ -167,8 +170,8 @@ public void AddsResponseFormatFromMetadata()
167170
{
168171
var apiDescription = GetApiDescription(
169172
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)]
170-
[Produces("application/custom")]
171-
() => new InferredJsonClass());
173+
[Produces("application/custom")]
174+
() => new InferredJsonClass());
172175

173176
var responseType = Assert.Single(apiDescription.SupportedResponseTypes);
174177

@@ -185,8 +188,8 @@ public void AddsMultipleResponseFormatsFromMetadataWithPoco()
185188
{
186189
var apiDescription = GetApiDescription(
187190
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)]
188-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
189-
() => new InferredJsonClass());
191+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
192+
() => new InferredJsonClass());
190193

191194
Assert.Equal(2, apiDescription.SupportedResponseTypes.Count);
192195

@@ -214,8 +217,8 @@ public void AddsMultipleResponseFormatsFromMetadataWithIResult()
214217
{
215218
var apiDescription = GetApiDescription(
216219
[ProducesResponseType(typeof(InferredJsonClass), StatusCodes.Status201Created)]
217-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
218-
() => Results.Ok(new InferredJsonClass()));
220+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
221+
() => Results.Ok(new InferredJsonClass()));
219222

220223
Assert.Equal(2, apiDescription.SupportedResponseTypes.Count);
221224

@@ -324,18 +327,68 @@ public void AddsMultipleParameters()
324327
Assert.Equal(typeof(int), fooParam.Type);
325328
Assert.Equal(typeof(int), fooParam.ModelMetadata.ModelType);
326329
Assert.Equal(BindingSource.Path, fooParam.Source);
330+
Assert.True(fooParam.IsRequired);
327331

328332
var barParam = apiDescription.ParameterDescriptions[1];
329333
Assert.Equal(typeof(int), barParam.Type);
330334
Assert.Equal(typeof(int), barParam.ModelMetadata.ModelType);
331335
Assert.Equal(BindingSource.Query, barParam.Source);
336+
Assert.True(barParam.IsRequired);
332337

333338
var fromBodyParam = apiDescription.ParameterDescriptions[2];
334339
Assert.Equal(typeof(InferredJsonClass), fromBodyParam.Type);
335340
Assert.Equal(typeof(InferredJsonClass), fromBodyParam.ModelMetadata.ModelType);
336341
Assert.Equal(BindingSource.Body, fromBodyParam.Source);
342+
Assert.True(fromBodyParam.IsRequired);
337343
}
338344

345+
[Fact]
346+
public void TestParameterIsRequired()
347+
{
348+
var apiDescription = GetApiDescription(([FromRoute] int foo, int? bar) => { });
349+
Assert.Equal(2, apiDescription.ParameterDescriptions.Count);
350+
351+
var fooParam = apiDescription.ParameterDescriptions[0];
352+
Assert.Equal(typeof(int), fooParam.Type);
353+
Assert.Equal(typeof(int), fooParam.ModelMetadata.ModelType);
354+
Assert.Equal(BindingSource.Path, fooParam.Source);
355+
Assert.True(fooParam.IsRequired);
356+
357+
var barParam = apiDescription.ParameterDescriptions[1];
358+
Assert.Equal(typeof(int?), barParam.Type);
359+
Assert.Equal(typeof(int?), barParam.ModelMetadata.ModelType);
360+
Assert.Equal(BindingSource.Query, barParam.Source);
361+
Assert.False(barParam.IsRequired);
362+
}
363+
364+
#nullable enable
365+
366+
[Fact]
367+
public void TestIsRequiredFromBody()
368+
{
369+
var apiDescription0 = GetApiDescription(([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] InferredJsonClass fromBody) => { });
370+
var apiDescription1 = GetApiDescription((InferredJsonClass? fromBody) => { });
371+
Assert.Equal(1, apiDescription0.ParameterDescriptions.Count);
372+
Assert.Equal(1, apiDescription1.ParameterDescriptions.Count);
373+
374+
var fromBodyParam0 = apiDescription0.ParameterDescriptions[0];
375+
Assert.Equal(typeof(InferredJsonClass), fromBodyParam0.Type);
376+
Assert.Equal(typeof(InferredJsonClass), fromBodyParam0.ModelMetadata.ModelType);
377+
Assert.Equal(BindingSource.Body, fromBodyParam0.Source);
378+
Assert.False(fromBodyParam0.IsRequired);
379+
380+
var fromBodyParam1 = apiDescription1.ParameterDescriptions[0];
381+
Assert.Equal(typeof(InferredJsonClass), fromBodyParam1.Type);
382+
Assert.Equal(typeof(InferredJsonClass), fromBodyParam1.ModelMetadata.ModelType);
383+
Assert.Equal(BindingSource.Body, fromBodyParam1.Source);
384+
Assert.False(fromBodyParam1.IsRequired);
385+
}
386+
387+
// This is necessary for TestIsRequiredFromBody to pass until https://github.com/dotnet/roslyn/issues/55254 is resolved.
388+
private object RandomMethod() => throw new NotImplementedException();
389+
390+
#nullable disable
391+
339392
[Fact]
340393
public void AddsDisplayNameFromRouteEndpoint()
341394
{

src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<PropertyGroup>
44
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
55
<LangVersion>Preview</LangVersion>
6+
<Features>$(Features.Replace('nullablePublicOnly', '')</Features>
67
</PropertyGroup>
78

89
<ItemGroup>

0 commit comments

Comments
 (0)