Skip to content

Commit c746a3a

Browse files
committed
Support optionality via nullability and default values
1 parent 6a09fd8 commit c746a3a

File tree

3 files changed

+594
-43
lines changed

3 files changed

+594
-43
lines changed

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

Lines changed: 128 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ namespace Microsoft.AspNetCore.Http
2222
/// </summary>
2323
public static partial class RequestDelegateFactory
2424
{
25+
private static readonly NullabilityInfoContext nullabilityContext = new NullabilityInfoContext();
26+
2527
private static readonly MethodInfo ExecuteTaskOfTMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteTask), BindingFlags.NonPublic | BindingFlags.Static)!;
2628
private static readonly MethodInfo ExecuteTaskOfStringMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteTaskOfString), BindingFlags.NonPublic | BindingFlags.Static)!;
2729
private static readonly MethodInfo ExecuteValueTaskOfTMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteValueTaskOfT), BindingFlags.NonPublic | BindingFlags.Static)!;
@@ -31,12 +33,16 @@ public static partial class RequestDelegateFactory
3133
private static readonly MethodInfo ExecuteValueResultTaskOfTMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteValueTaskResult), BindingFlags.NonPublic | BindingFlags.Static)!;
3234
private static readonly MethodInfo ExecuteObjectReturnMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteObjectReturn), BindingFlags.NonPublic | BindingFlags.Static)!;
3335
private static readonly MethodInfo GetRequiredServiceMethod = typeof(ServiceProviderServiceExtensions).GetMethod(nameof(ServiceProviderServiceExtensions.GetRequiredService), BindingFlags.Public | BindingFlags.Static, new Type[] { typeof(IServiceProvider) })!;
36+
private static readonly MethodInfo GetServiceMethod = typeof(ServiceProviderServiceExtensions).GetMethod(nameof(ServiceProviderServiceExtensions.GetService), BindingFlags.Public | BindingFlags.Static, new Type[] { typeof(IServiceProvider) })!;
3437
private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!;
3538
private static readonly MethodInfo StringResultWriteResponseAsyncMethod = GetMethodInfo<Func<HttpResponse, string, Task>>((response, text) => HttpResponseWritingExtensions.WriteAsync(response, text, default));
3639
private static readonly MethodInfo JsonResultWriteResponseAsyncMethod = GetMethodInfo<Func<HttpResponse, object, Task>>((response, value) => HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, default));
3740
private static readonly MethodInfo LogParameterBindingFailureMethod = GetMethodInfo<Action<HttpContext, string, string, string>>((httpContext, parameterType, parameterName, sourceValue) =>
3841
Log.ParameterBindingFailed(httpContext, parameterType, parameterName, sourceValue));
3942

43+
private static readonly MethodInfo LogRequiredParameterNotProvidedMethod = GetMethodInfo<Action<HttpContext, string, string>>((httpContext, parameterType, parameterName) =>
44+
Log.RequiredParameterNotProvided(httpContext, parameterType, parameterName));
45+
4046
private static readonly ParameterExpression TargetExpr = Expression.Parameter(typeof(object), "target");
4147
private static readonly ParameterExpression HttpContextExpr = Expression.Parameter(typeof(HttpContext), "httpContext");
4248
private static readonly ParameterExpression BodyValueExpr = Expression.Parameter(typeof(object), "bodyValue");
@@ -217,11 +223,11 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext
217223
}
218224
else if (parameterCustomAttributes.OfType<IFromBodyMetadata>().FirstOrDefault() is { } bodyAttribute)
219225
{
220-
return BindParameterFromBody(parameter.ParameterType, bodyAttribute.AllowEmpty, factoryContext);
226+
return BindParameterFromBody(parameter, bodyAttribute.AllowEmpty, factoryContext);
221227
}
222228
else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType)))
223229
{
224-
return Expression.Call(GetRequiredServiceMethod.MakeGenericMethod(parameter.ParameterType), RequestServicesExpr);
230+
return BindParameterFromService(parameter);
225231
}
226232
else if (parameter.ParameterType == typeof(HttpContext))
227233
{
@@ -256,16 +262,30 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext
256262
}
257263
else
258264
{
265+
266+
var nullability = nullabilityContext.Create(parameter);
267+
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable;
259268
if (factoryContext.ServiceProviderIsService is IServiceProviderIsService serviceProviderIsService)
260269
{
261-
// If the parameter resolves as a service then get it from services
262-
if (serviceProviderIsService.IsService(parameter.ParameterType))
270+
// If the parameter is required
271+
if (!isOptional)
263272
{
264-
return Expression.Call(GetRequiredServiceMethod.MakeGenericMethod(parameter.ParameterType), RequestServicesExpr);
273+
// And we are able to resolve a service for it
274+
return serviceProviderIsService.IsService(parameter.ParameterType)
275+
? Expression.Call(GetRequiredServiceMethod.MakeGenericMethod(parameter.ParameterType), RequestServicesExpr) // Then get it from the DI
276+
: BindParameterFromBody(parameter, allowEmpty: false, factoryContext); // Otherwise try to find it in the body
277+
}
278+
// If the parameter is optional
279+
else
280+
{
281+
// Then try to resolve it as an optional service and fallback to a body otherwise
282+
return Expression.Coalesce(
283+
Expression.Call(GetServiceMethod.MakeGenericMethod(parameter.ParameterType), RequestServicesExpr),
284+
BindParameterFromBody(parameter, allowEmpty: false, factoryContext));
265285
}
266286
}
267287

268-
return BindParameterFromBody(parameter.ParameterType, allowEmpty: false, factoryContext);
288+
return BindParameterFromBody(parameter, allowEmpty: false, factoryContext);
269289
}
270290
}
271291

@@ -479,13 +499,9 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall,
479499

480500
return async (target, httpContext) =>
481501
{
482-
object? bodyValue;
502+
object? bodyValue = defaultBodyValue;
483503

484-
if (factoryContext.AllowEmptyRequestBody && httpContext.Request.ContentLength == 0)
485-
{
486-
bodyValue = defaultBodyValue;
487-
}
488-
else
504+
if (httpContext.Request.ContentLength != 0 && httpContext.Request.HasJsonContentType())
489505
{
490506
try
491507
{
@@ -516,21 +532,53 @@ private static Expression GetValueFromProperty(Expression sourceExpression, stri
516532
return Expression.Convert(indexExpression, typeof(string));
517533
}
518534

535+
private static Expression BindParameterFromService(ParameterInfo parameter)
536+
{
537+
var nullability = nullabilityContext.Create(parameter);
538+
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable;
539+
540+
return isOptional
541+
? Expression.Call(GetServiceMethod.MakeGenericMethod(parameter.ParameterType), RequestServicesExpr)
542+
: Expression.Call(GetRequiredServiceMethod.MakeGenericMethod(parameter.ParameterType), RequestServicesExpr);
543+
}
544+
519545
private static Expression BindParameterFromValue(ParameterInfo parameter, Expression valueExpression, FactoryContext factoryContext)
520546
{
547+
var nullability = nullabilityContext.Create(parameter);
548+
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable;
549+
521550
if (parameter.ParameterType == typeof(string))
522551
{
523-
if (!parameter.HasDefaultValue)
552+
factoryContext.UsingTempSourceString = true;
553+
554+
if (!isOptional)
524555
{
525-
return valueExpression;
556+
var checkRequiredStringParameterBlock = Expression.Block(
557+
Expression.Assign(TempSourceStringExpr, valueExpression),
558+
Expression.IfThen(Expression.Not(TempSourceStringNotNullExpr),
559+
Expression.Block(
560+
Expression.Assign(WasTryParseFailureExpr, Expression.Constant(true)),
561+
Expression.Call(LogRequiredParameterNotProvidedMethod,
562+
HttpContextExpr, Expression.Constant(parameter.ParameterType.Name), Expression.Constant(parameter.Name))
563+
)
564+
)
565+
);
566+
567+
factoryContext.TryParseParams.Add((TempSourceStringExpr, checkRequiredStringParameterBlock));
568+
return Expression.Block(TempSourceStringExpr);
569+
}
570+
571+
// Allow nullable parameters that don't have a default value
572+
if (nullability.ReadState == NullabilityState.Nullable && !parameter.HasDefaultValue)
573+
{
574+
return Expression.Block(Expression.Assign(TempSourceStringExpr, valueExpression));
526575
}
527576

528-
factoryContext.UsingTempSourceString = true;
529577
return Expression.Block(
530578
Expression.Assign(TempSourceStringExpr, valueExpression),
531579
Expression.Condition(TempSourceStringNotNullExpr,
532580
TempSourceStringExpr,
533-
Expression.Constant(parameter.DefaultValue)));
581+
Expression.Convert(Expression.Constant(parameter.DefaultValue), parameter.ParameterType)));
534582
}
535583

536584
factoryContext.UsingTempSourceString = true;
@@ -598,6 +646,17 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
598646

599647
var tryParseCall = tryParseMethodCall(parsedValue);
600648

649+
// If the parameter is required, fail to parse and log an error
650+
var checkRequiredParaseableParameterBlock = Expression.Block(
651+
Expression.IfThen(Expression.Not(TempSourceStringNotNullExpr),
652+
Expression.Block(
653+
Expression.Assign(WasTryParseFailureExpr, Expression.Constant(true)),
654+
Expression.Call(LogRequiredParameterNotProvidedMethod,
655+
HttpContextExpr, parameterTypeNameConstant, parameterNameConstant)
656+
)
657+
)
658+
);
659+
601660
// If the parameter is nullable, we need to assign the "parsedValue" local to the nullable parameter on success.
602661
Expression tryParseExpression = isNotNullable ?
603662
Expression.IfThen(Expression.Not(tryParseCall), failBlock) :
@@ -612,11 +671,18 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
612671
tryParseExpression,
613672
Expression.Assign(argument, Expression.Constant(parameter.DefaultValue)));
614673

615-
var fullTryParseBlock = Expression.Block(
616-
// tempSourceString = httpContext.RequestValue["id"];
617-
Expression.Assign(TempSourceStringExpr, valueExpression),
618-
// if (tempSourceString != null) { ... }
619-
ifNotNullTryParse);
674+
var fullTryParseBlock = !isOptional
675+
? Expression.Block(
676+
// tempSourceString = httpContext.RequestValue["id"];
677+
Expression.Assign(TempSourceStringExpr, valueExpression),
678+
checkRequiredParaseableParameterBlock,
679+
// if (tempSourceString != null) { ... }
680+
ifNotNullTryParse)
681+
: Expression.Block(
682+
// tempSourceString = httpContext.RequestValue["id"];
683+
Expression.Assign(TempSourceStringExpr, valueExpression),
684+
// if (tempSourceString != null) { ... }
685+
ifNotNullTryParse);
620686

621687
factoryContext.TryParseParams.Add((argument, fullTryParseBlock));
622688

@@ -633,17 +699,46 @@ private static Expression BindParameterFromRouteValueOrQueryString(ParameterInfo
633699
return BindParameterFromValue(parameter, Expression.Coalesce(routeValue, queryValue), factoryContext);
634700
}
635701

636-
private static Expression BindParameterFromBody(Type parameterType, bool allowEmpty, FactoryContext factoryContext)
702+
private static Expression BindParameterFromBody(ParameterInfo parameter, bool allowEmpty, FactoryContext factoryContext)
637703
{
638704
if (factoryContext.JsonRequestBodyType is not null)
639705
{
640706
throw new InvalidOperationException("Action cannot have more than one FromBody attribute.");
641707
}
642708

643-
factoryContext.JsonRequestBodyType = parameterType;
644-
factoryContext.AllowEmptyRequestBody = allowEmpty;
709+
var nullability = nullabilityContext.Create(parameter);
710+
var isOptional = parameter.HasDefaultValue || nullability.ReadState == NullabilityState.Nullable;
711+
712+
factoryContext.JsonRequestBodyType = parameter.ParameterType;
713+
factoryContext.AllowEmptyRequestBody = allowEmpty || isOptional;
714+
715+
var argument = Expression.Variable(parameter.ParameterType, $"{parameter.Name}_local");
645716

646-
return Expression.Convert(BodyValueExpr, parameterType);
717+
if (!isOptional && !allowEmpty)
718+
{
719+
var checkRequiredBodyBlock = Expression.Block(
720+
Expression.Assign(argument, Expression.Convert(BodyValueExpr, parameter.ParameterType)),
721+
Expression.IfThen(Expression.Equal(argument, Expression.Constant(null)),
722+
Expression.Block(
723+
Expression.Assign(WasTryParseFailureExpr, Expression.Constant(true)),
724+
Expression.Call(LogRequiredParameterNotProvidedMethod,
725+
HttpContextExpr, Expression.Constant(parameter.ParameterType.Name), Expression.Constant(parameter.Name))
726+
)
727+
)
728+
);
729+
factoryContext.TryParseParams.Add((argument, checkRequiredBodyBlock));
730+
return argument;
731+
}
732+
733+
if (parameter.HasDefaultValue)
734+
{
735+
// Convert(bodyValue ?? SomeDefault, Todo)
736+
return Expression.Convert(
737+
Expression.Coalesce(BodyValueExpr, Expression.Constant(parameter.DefaultValue)),
738+
parameter.ParameterType);
739+
}
740+
741+
return Expression.Convert(BodyValueExpr, parameter.ParameterType);
647742
}
648743

649744
private static MethodInfo GetMethodInfo<T>(Expression<T> expr)
@@ -847,11 +942,19 @@ public static void RequestBodyInvalidDataException(HttpContext httpContext, Inva
847942
public static void ParameterBindingFailed(HttpContext httpContext, string parameterTypeName, string parameterName, string sourceValue)
848943
=> ParameterBindingFailed(GetLogger(httpContext), parameterTypeName, parameterName, sourceValue);
849944

945+
public static void RequiredParameterNotProvided(HttpContext httpContext, string parameterTypeName, string parameterName)
946+
=> RequiredParameterNotProvided(GetLogger(httpContext), parameterTypeName, parameterName);
947+
850948
[LoggerMessage(3, LogLevel.Debug,
851949
@"Failed to bind parameter ""{ParameterType} {ParameterName}"" from ""{SourceValue}"".",
852950
EventName = "ParamaterBindingFailed")]
853951
private static partial void ParameterBindingFailed(ILogger logger, string parameterType, string parameterName, string sourceValue);
854952

953+
[LoggerMessage(4, LogLevel.Debug,
954+
@"Required parameter ""{ParameterType} {ParameterName}"" was not provided.",
955+
EventName = "RequiredParameterNotProvided")]
956+
private static partial void RequiredParameterNotProvided(ILogger logger, string parameterType, string parameterName);
957+
855958
private static ILogger GetLogger(HttpContext httpContext)
856959
{
857960
var loggerFactory = httpContext.RequestServices.GetRequiredService<ILoggerFactory>();

src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
<PropertyGroup>
44
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
5+
<!-- This is needed to support codepaths that use NullabilityInfoContext. -->
6+
<Features>$(Features.Replace('nullablePublicOnly', '')</Features>
57
</PropertyGroup>
68

79
<ItemGroup>

0 commit comments

Comments
 (0)