Skip to content

Commit f3f154f

Browse files
authored
Treat reference type parameters in oblivious nullability context as optional (#35563)
* Treat parameters in oblivious nullability context as optional * Only apply fix for reference types * Update optionality check in API descriptor * Update check in BindAsync and Mvc.ApiExplorer test
1 parent e90a1e7 commit f3f154f

File tree

4 files changed

+71
-10
lines changed

4 files changed

+71
-10
lines changed

src/Http/Http.Extensions/src/RequestDelegateFactory.cs

+19-8
Original file line numberDiff line numberDiff line change
@@ -627,8 +627,7 @@ private static Expression GetValueFromProperty(Expression sourceExpression, stri
627627

628628
private static Expression BindParameterFromService(ParameterInfo parameter)
629629
{
630-
var nullability = NullabilityContext.Create(parameter);
631-
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable;
630+
var isOptional = IsOptionalParameter(parameter);
632631

633632
return isOptional
634633
? Expression.Call(GetServiceMethod.MakeGenericMethod(parameter.ParameterType), RequestServicesExpr)
@@ -637,8 +636,7 @@ private static Expression BindParameterFromService(ParameterInfo parameter)
637636

638637
private static Expression BindParameterFromValue(ParameterInfo parameter, Expression valueExpression, FactoryContext factoryContext)
639638
{
640-
var nullability = NullabilityContext.Create(parameter);
641-
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable;
639+
var isOptional = IsOptionalParameter(parameter);
642640

643641
var argument = Expression.Variable(parameter.ParameterType, $"{parameter.Name}_local");
644642

@@ -671,7 +669,8 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
671669
}
672670

673671
// Allow nullable parameters that don't have a default value
674-
if (nullability.ReadState == NullabilityState.Nullable && !parameter.HasDefaultValue)
672+
var nullability = NullabilityContext.Create(parameter);
673+
if (nullability.ReadState != NullabilityState.NotNull && !parameter.HasDefaultValue)
675674
{
676675
return valueExpression;
677676
}
@@ -817,7 +816,7 @@ private static Expression BindParameterFromBindAsync(ParameterInfo parameter, Fa
817816
{
818817
// We reference the boundValues array by parameter index here
819818
var nullability = NullabilityContext.Create(parameter);
820-
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable;
819+
var isOptional = IsOptionalParameter(parameter);
821820

822821
// Get the BindAsync method
823822
var body = TryParseMethodCache.FindBindAsyncMethod(parameter.ParameterType)!;
@@ -862,8 +861,7 @@ private static Expression BindParameterFromBody(ParameterInfo parameter, bool al
862861
}
863862
}
864863

865-
var nullability = NullabilityContext.Create(parameter);
866-
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable;
864+
var isOptional = IsOptionalParameter(parameter);
867865

868866
factoryContext.JsonRequestBodyType = parameter.ParameterType;
869867
factoryContext.AllowEmptyRequestBody = allowEmpty || isOptional;
@@ -903,6 +901,19 @@ private static Expression BindParameterFromBody(ParameterInfo parameter, bool al
903901
return Expression.Convert(BodyValueExpr, parameter.ParameterType);
904902
}
905903

904+
private static bool IsOptionalParameter(ParameterInfo parameter)
905+
{
906+
// - Parameters representing value or reference types with a default value
907+
// under any nullability context are treated as optional.
908+
// - Value type parameters without a default value in an oblivious
909+
// nullability context are required.
910+
// - Reference type parameters without a default value in an oblivious
911+
// nullability context are optional.
912+
var nullability = NullabilityContext.Create(parameter);
913+
return parameter.HasDefaultValue
914+
|| nullability.ReadState != NullabilityState.NotNull;
915+
}
916+
906917
private static MethodInfo GetMethodInfo<T>(Expression<T> expr)
907918
{
908919
var mc = (MethodCallExpression)expr.Body;

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

+29
Original file line numberDiff line numberDiff line change
@@ -2137,6 +2137,35 @@ public async Task CanSetParseableStringParamAsOptionalWithNullabilityDisability(
21372137
Assert.Equal(expectedResponse, decodedResponseBody);
21382138
}
21392139

2140+
[Theory]
2141+
[InlineData(true, "Age: 42")]
2142+
[InlineData(false, "Age: ")]
2143+
public async Task TreatsUnknownNullabilityAsOptionalForReferenceType(bool provideValue, string expectedResponse)
2144+
{
2145+
string optionalQueryParam(string age) => $"Age: {age}";
2146+
2147+
var httpContext = new DefaultHttpContext();
2148+
var responseBodyStream = new MemoryStream();
2149+
httpContext.Response.Body = responseBodyStream;
2150+
2151+
if (provideValue)
2152+
{
2153+
httpContext.Request.Query = new QueryCollection(new Dictionary<string, StringValues>
2154+
{
2155+
["age"] = "42"
2156+
});
2157+
}
2158+
2159+
var requestDelegate = RequestDelegateFactory.Create(optionalQueryParam);
2160+
2161+
await requestDelegate(httpContext);
2162+
2163+
Assert.Equal(200, httpContext.Response.StatusCode);
2164+
Assert.False(httpContext.RequestAborted.IsCancellationRequested);
2165+
var decodedResponseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray());
2166+
Assert.Equal(expectedResponse, decodedResponseBody);
2167+
}
2168+
21402169
#nullable enable
21412170

21422171
private class Todo : ITodo

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string
143143

144144
// Determine the "requiredness" based on nullability, default value or if allowEmpty is set
145145
var nullability = NullabilityContext.Create(parameter);
146-
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable || allowEmpty;
146+
var isOptional = parameter.HasDefaultValue || nullability.ReadState != NullabilityState.NotNull || allowEmpty;
147147

148148
return new ApiParameterDescription
149149
{

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

+22-1
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ public void AddsMultipleParameters()
348348
Assert.Equal(typeof(InferredJsonClass), fromBodyParam.Type);
349349
Assert.Equal(typeof(InferredJsonClass), fromBodyParam.ModelMetadata.ModelType);
350350
Assert.Equal(BindingSource.Body, fromBodyParam.Source);
351-
Assert.True(fromBodyParam.IsRequired);
351+
Assert.False(fromBodyParam.IsRequired); // Reference type in oblivious nullability context
352352
}
353353

354354
[Fact]
@@ -421,6 +421,27 @@ public void AddsMetadataFromRouteEndpoint()
421421
Assert.True(apiExplorerSettings.IgnoreApi);
422422
}
423423

424+
[Fact]
425+
public void TestParameterIsRequiredForObliviousNullabilityContext()
426+
{
427+
// In an oblivious nullability context, reference type parameters without
428+
// annotations are optional. Value type parameters are always required.
429+
var apiDescription = GetApiDescription((string foo, int bar) => { });
430+
Assert.Equal(2, apiDescription.ParameterDescriptions.Count);
431+
432+
var fooParam = apiDescription.ParameterDescriptions[0];
433+
Assert.Equal(typeof(string), fooParam.Type);
434+
Assert.Equal(typeof(string), fooParam.ModelMetadata.ModelType);
435+
Assert.Equal(BindingSource.Query, fooParam.Source);
436+
Assert.False(fooParam.IsRequired);
437+
438+
var barParam = apiDescription.ParameterDescriptions[1];
439+
Assert.Equal(typeof(int), barParam.Type);
440+
Assert.Equal(typeof(int), barParam.ModelMetadata.ModelType);
441+
Assert.Equal(BindingSource.Query, barParam.Source);
442+
Assert.True(barParam.IsRequired);
443+
}
444+
424445
[Fact]
425446
public void RespectsProducesProblemExtensionMethod()
426447
{

0 commit comments

Comments
 (0)