Skip to content

Commit 1099d06

Browse files
authored
Support checking for required members in minimal APIs (#45084)
* Support checking for required members in minimal APIs * Address feedback from peer review
1 parent 85e1614 commit 1099d06

File tree

6 files changed

+253
-3
lines changed

6 files changed

+253
-3
lines changed

src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7075,6 +7075,158 @@ public void Create_Populates_EndpointBuilderWithRequestDelegateAndMetadata()
70757075
Assert.Same(options.EndpointBuilder.Metadata, result.EndpointMetadata);
70767076
}
70777077

7078+
private class ParameterListRequiredStringFromDifferentSources
7079+
{
7080+
public HttpContext? HttpContext { get; set; }
7081+
7082+
[FromRoute]
7083+
public required string RequiredRouteParam { get; set; }
7084+
7085+
[FromQuery]
7086+
public required string RequiredQueryParam { get; set; }
7087+
7088+
[FromHeader]
7089+
public required string RequiredHeaderParam { get; set; }
7090+
}
7091+
7092+
[Fact]
7093+
public async Task RequestDelegateFactory_AsParameters_SupportsRequiredMember()
7094+
{
7095+
// Arrange
7096+
static void TestAction([AsParameters] ParameterListRequiredStringFromDifferentSources args) { }
7097+
7098+
var httpContext = CreateHttpContext();
7099+
7100+
var factoryResult = RequestDelegateFactory.Create(TestAction);
7101+
var requestDelegate = factoryResult.RequestDelegate;
7102+
7103+
// Act
7104+
await requestDelegate(httpContext);
7105+
7106+
// Assert that the required modifier on members that
7107+
// are not nullable treats them as required.
7108+
Assert.Equal(400, httpContext.Response.StatusCode);
7109+
7110+
var logs = TestSink.Writes.ToArray();
7111+
7112+
Assert.Equal(3, logs.Length);
7113+
7114+
Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId);
7115+
Assert.Equal(LogLevel.Debug, logs[0].LogLevel);
7116+
Assert.Equal(@"Required parameter ""string RequiredRouteParam"" was not provided from route.", logs[0].Message);
7117+
7118+
Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId);
7119+
Assert.Equal(LogLevel.Debug, logs[1].LogLevel);
7120+
Assert.Equal(@"Required parameter ""string RequiredQueryParam"" was not provided from query string.", logs[1].Message);
7121+
7122+
Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[2].EventId);
7123+
Assert.Equal(LogLevel.Debug, logs[2].LogLevel);
7124+
Assert.Equal(@"Required parameter ""string RequiredHeaderParam"" was not provided from header.", logs[2].Message);
7125+
}
7126+
7127+
private class ParameterListRequiredNullableStringFromDifferentSources
7128+
{
7129+
public HttpContext? HttpContext { get; set; }
7130+
7131+
[FromRoute]
7132+
public required StringValues? RequiredRouteParam { get; set; }
7133+
7134+
[FromQuery]
7135+
public required StringValues? RequiredQueryParam { get; set; }
7136+
7137+
[FromHeader]
7138+
public required StringValues? RequiredHeaderParam { get; set; }
7139+
}
7140+
7141+
[Fact]
7142+
public async Task RequestDelegateFactory_AsParameters_SupportsNullableRequiredMember()
7143+
{
7144+
// Arrange
7145+
static void TestAction([AsParameters] ParameterListRequiredNullableStringFromDifferentSources args)
7146+
{
7147+
args.HttpContext!.Items.Add("RequiredRouteParam", args.RequiredRouteParam);
7148+
args.HttpContext!.Items.Add("RequiredQueryParam", args.RequiredQueryParam);
7149+
args.HttpContext!.Items.Add("RequiredHeaderParam", args.RequiredHeaderParam);
7150+
}
7151+
7152+
var httpContext = CreateHttpContext();
7153+
7154+
var factoryResult = RequestDelegateFactory.Create(TestAction);
7155+
var requestDelegate = factoryResult.RequestDelegate;
7156+
7157+
// Act
7158+
await requestDelegate(httpContext);
7159+
7160+
// Assert that when properties are required but nullable
7161+
// we evaluate them as optional because required members
7162+
// must be initialized but they can be initialized to null
7163+
// when an NRT is required.
7164+
Assert.Equal(200, httpContext.Response.StatusCode);
7165+
7166+
Assert.Null(httpContext.Items["RequiredRouteParam"]);
7167+
Assert.Null(httpContext.Items["RequiredQueryParam"]);
7168+
Assert.Null(httpContext.Items["RequiredHeaderParam"]);
7169+
}
7170+
7171+
#nullable disable
7172+
private class ParameterListMixedRequiredStringsFromDifferentSources
7173+
{
7174+
public HttpContext HttpContext { get; set; }
7175+
7176+
[FromRoute]
7177+
public required string RequiredRouteParam { get; set; }
7178+
7179+
[FromRoute]
7180+
public string OptionalRouteParam { get; set; }
7181+
7182+
[FromQuery]
7183+
public required string RequiredQueryParam { get; set; }
7184+
7185+
[FromQuery]
7186+
public string OptionalQueryParam { get; set; }
7187+
7188+
[FromHeader]
7189+
public required string RequiredHeaderParam { get; set; }
7190+
7191+
[FromHeader]
7192+
public string OptionalHeaderParam { get; set; }
7193+
}
7194+
7195+
[Fact]
7196+
public async Task RequestDelegateFactory_AsParameters_SupportsRequiredMember_NullabilityDisabled()
7197+
{
7198+
// Arange
7199+
static void TestAction([AsParameters] ParameterListMixedRequiredStringsFromDifferentSources args) { }
7200+
7201+
var httpContext = CreateHttpContext();
7202+
7203+
var factoryResult = RequestDelegateFactory.Create(TestAction);
7204+
var requestDelegate = factoryResult.RequestDelegate;
7205+
7206+
// Act
7207+
await requestDelegate(httpContext);
7208+
7209+
// Assert that we only execute required parameter
7210+
// checks for members that have the required modifier
7211+
Assert.Equal(400, httpContext.Response.StatusCode);
7212+
7213+
var logs = TestSink.Writes.ToArray();
7214+
Assert.Equal(3, logs.Length);
7215+
7216+
Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId);
7217+
Assert.Equal(LogLevel.Debug, logs[0].LogLevel);
7218+
Assert.Equal(@"Required parameter ""string RequiredRouteParam"" was not provided from route.", logs[0].Message);
7219+
7220+
Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId);
7221+
Assert.Equal(LogLevel.Debug, logs[1].LogLevel);
7222+
Assert.Equal(@"Required parameter ""string RequiredQueryParam"" was not provided from query string.", logs[1].Message);
7223+
7224+
Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[2].EventId);
7225+
Assert.Equal(LogLevel.Debug, logs[2].LogLevel);
7226+
Assert.Equal(@"Required parameter ""string RequiredHeaderParam"" was not provided from header.", logs[2].Message);
7227+
}
7228+
#nullable enable
7229+
70787230
private DefaultHttpContext CreateHttpContext()
70797231
{
70807232
var responseFeature = new TestHttpResponseFeature();

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,9 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string
182182
// Determine the "requiredness" based on nullability, default value or if allowEmpty is set
183183
var nullabilityContext = new NullabilityInfoContext();
184184
var nullability = nullabilityContext.Create(parameter);
185-
var isOptional = parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull || allowEmpty;
185+
var isOptional = parameter is PropertyAsParameterInfo argument
186+
? argument.IsOptional || allowEmpty
187+
: parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull || allowEmpty;
186188
var parameterDescriptor = CreateParameterDescriptor(parameter, pattern);
187189
var routeInfo = CreateParameterRouteInfo(pattern, parameter, isOptional);
188190

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,46 @@ static void AssertParameters(ApiDescription apiDescription, string capturedName
527527
}
528528
#nullable enable
529529

530+
public class AsParametersWithRequiredMembers
531+
{
532+
public required string RequiredStringMember { get; set; }
533+
public required string? RequiredNullableStringMember { get; set; }
534+
public string NonNullableStringMember { get; set; } = string.Empty;
535+
public string? NullableStringMember { get; set; }
536+
}
537+
538+
[Fact]
539+
public void SupportsRequiredMembersInAsParametersAttribute()
540+
{
541+
var apiDescription = GetApiDescription(([AsParameters] AsParametersWithRequiredMembers foo) => { });
542+
Assert.Equal(4, apiDescription.ParameterDescriptions.Count);
543+
544+
Assert.Collection(apiDescription.ParameterDescriptions,
545+
param => Assert.True(param.IsRequired),
546+
param => Assert.False(param.IsRequired),
547+
param => Assert.True(param.IsRequired),
548+
param => Assert.False(param.IsRequired));
549+
}
550+
551+
#nullable disable
552+
public class AsParametersWithRequiredMembersObliviousContext
553+
{
554+
public required string RequiredStringMember { get; set; }
555+
public string OptionalStringMember { get; set; }
556+
}
557+
558+
[Fact]
559+
public void SupportsRequiredMembersInAsParametersObliviousContextAttribute()
560+
{
561+
var apiDescription = GetApiDescription(([AsParameters] AsParametersWithRequiredMembersObliviousContext foo) => { });
562+
Assert.Equal(2, apiDescription.ParameterDescriptions.Count);
563+
564+
Assert.Collection(apiDescription.ParameterDescriptions,
565+
param => Assert.True(param.IsRequired),
566+
param => Assert.False(param.IsRequired));
567+
}
568+
#nullable enable
569+
530570
[Fact]
531571
public void TestParameterIsRequired()
532572
{

src/OpenApi/src/OpenApiGenerator.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,9 @@ private List<OpenApiParameter> GetOpenApiParameters(MethodInfo methodInfo, Route
378378
}
379379
var nullabilityContext = new NullabilityInfoContext();
380380
var nullability = nullabilityContext.Create(parameter);
381-
var isOptional = parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull;
381+
var isOptional = parameter is PropertyAsParameterInfo argument
382+
? argument.IsOptional
383+
: parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull;
382384
var name = attributeName ?? (pattern.GetParameter(parameter.Name) is { } routeParameter ? routeParameter.Name : parameter.Name);
383385
var openApiParameter = new OpenApiParameter()
384386
{

src/OpenApi/test/OpenApiGeneratorTests.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,46 @@ static void ValidateParameter(OpenApiOperation operation, string expectedName)
928928
ValidateParameter(GetOpenApiOperation(([FromHeader(Name = "headerName")] string param) => ""), "headerName");
929929
}
930930

931+
#nullable enable
932+
public class AsParametersWithRequiredMembers
933+
{
934+
public required string RequiredStringMember { get; set; }
935+
public required string? RequiredNullableStringMember { get; set; }
936+
public string NonNullableStringMember { get; set; } = string.Empty;
937+
public string? NullableStringMember { get; set; }
938+
}
939+
940+
[Fact]
941+
public void SupportsRequiredMembersInAsParametersAttribute()
942+
{
943+
var operation = GetOpenApiOperation(([AsParameters] AsParametersWithRequiredMembers foo) => { });
944+
Assert.Equal(4, operation.Parameters.Count);
945+
946+
Assert.Collection(operation.Parameters,
947+
param => Assert.True(param.Required),
948+
param => Assert.False(param.Required),
949+
param => Assert.True(param.Required),
950+
param => Assert.False(param.Required));
951+
}
952+
#nullable disable
953+
954+
public class AsParametersWithRequiredMembersObliviousContext
955+
{
956+
public required string RequiredStringMember { get; set; }
957+
public string OptionalStringMember { get; set; }
958+
}
959+
960+
[Fact]
961+
public void SupportsRequiredMembersInAsParametersObliviousContextAttribute()
962+
{
963+
var operation = GetOpenApiOperation(([AsParameters] AsParametersWithRequiredMembersObliviousContext foo) => { });
964+
Assert.Equal(2, operation.Parameters.Count);
965+
966+
Assert.Collection(operation.Parameters,
967+
param => Assert.True(param.Required),
968+
param => Assert.False(param.Required));
969+
}
970+
931971
private static OpenApiOperation GetOpenApiOperation(
932972
Delegate action,
933973
string pattern = null,

src/Shared/PropertyAsParameterInfo.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Diagnostics.CodeAnalysis;
77
using System.Linq;
88
using System.Reflection;
9+
using System.Runtime.CompilerServices;
910
using System.Runtime.InteropServices;
1011
using Microsoft.Extensions.Internal;
1112

@@ -182,7 +183,20 @@ public override bool IsDefined(Type attributeType, bool inherit)
182183
_underlyingProperty.IsDefined(attributeType, inherit);
183184
}
184185

185-
public new bool IsOptional => HasDefaultValue || NullabilityInfo.ReadState != NullabilityState.NotNull;
186+
public new bool IsOptional => NullabilityInfo.ReadState switch
187+
{
188+
// Anything nullable is optional
189+
NullabilityState.Nullable => true,
190+
// In an oblivious context, the required modifier makes
191+
// members non-optional
192+
NullabilityState.Unknown => !_underlyingProperty.GetCustomAttributes().OfType<RequiredMemberAttribute>().Any(),
193+
// Non-nullable types are only optional if they have a default
194+
// value
195+
NullabilityState.NotNull => HasDefaultValue,
196+
// Assume that types are optional by default so we
197+
// don't greedily opt parameters into param checking
198+
_ => true
199+
};
186200

187201
public NullabilityInfo NullabilityInfo
188202
=> _nullabilityInfo ??= _constructionParameterInfo is not null ?

0 commit comments

Comments
 (0)