Skip to content

Commit 7e05088

Browse files
authored
Add support for endpoint filters in minimal APIs (#40491)
* Add support for endpoint filters in minimal APIs * Fix build and react to peer review * Fixing failing tests and address feedback * Don't execute handler after filter if StatusCode >= 400 * Fix docs and kick build
1 parent 87f870d commit 7e05088

15 files changed

+590
-54
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http;
5+
6+
/// <summary>
7+
/// Provides an interface for implementing a filter targetting a route handler.
8+
/// </summary>
9+
public interface IRouteHandlerFilter
10+
{
11+
/// <summary>
12+
/// Implements the core logic associated with the filter given a <see cref="RouteHandlerFilterContext"/>
13+
/// and the next filter to call in the pipeline.
14+
/// </summary>
15+
/// <param name="context">The <see cref="RouteHandlerFilterContext"/> associated with the current request/response.</param>
16+
/// <param name="next">The next filter in the pipeline.</param>
17+
/// <returns>An awaitable result of calling the handler and apply
18+
/// any modifications made by filters in the pipeline.</returns>
19+
ValueTask<object?> InvokeAsync(RouteHandlerFilterContext context, Func<RouteHandlerFilterContext, ValueTask<object?>> next);
20+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
#nullable enable
22
*REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string!
33
Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata<T>() -> T!
4+
Microsoft.AspNetCore.Http.RouteHandlerFilterContext.RouteHandlerFilterContext(Microsoft.AspNetCore.Http.HttpContext! httpContext, params object![]! parameters) -> void
5+
Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerFilterContext! context, System.Func<Microsoft.AspNetCore.Http.RouteHandlerFilterContext!, System.Threading.Tasks.ValueTask<object?>>! next) -> System.Threading.Tasks.ValueTask<object?>
46
Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata
57
Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string?
68
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(Microsoft.AspNetCore.Routing.RouteValueDictionary? dictionary) -> void
79
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string!, object?>>? values) -> void
810
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string!, string?>>? values) -> void
911
abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string?
1012
Microsoft.AspNetCore.Http.Metadata.ISkipStatusCodePagesMetadata
13+
Microsoft.AspNetCore.Http.RouteHandlerFilterContext
14+
Microsoft.AspNetCore.Http.RouteHandlerFilterContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext!
15+
Microsoft.AspNetCore.Http.RouteHandlerFilterContext.Parameters.get -> System.Collections.Generic.IList<object?>!
16+
Microsoft.AspNetCore.Http.IRouteHandlerFilter
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http;
5+
6+
/// <summary>
7+
/// Provides an abstraction for wrapping the <see cref="HttpContext"/> and parameters
8+
/// provided to a route handler.
9+
/// </summary>
10+
public class RouteHandlerFilterContext
11+
{
12+
/// <summary>
13+
/// Creates a new instance of the <see cref="RouteHandlerFilterContext"/> for a given request.
14+
/// </summary>
15+
/// <param name="httpContext">The <see cref="HttpContext"/> associated with the current request.</param>
16+
/// <param name="parameters">A list of parameters provided in the current request.</param>
17+
public RouteHandlerFilterContext(HttpContext httpContext, params object[] parameters)
18+
{
19+
HttpContext = httpContext;
20+
Parameters = parameters;
21+
}
22+
23+
/// <summary>
24+
/// The <see cref="HttpContext"/> associated with the current request being processed by the filter.
25+
/// </summary>
26+
public HttpContext HttpContext { get; }
27+
28+
/// <summary>
29+
/// A list of parameters provided in the current request to the filter.
30+
/// <remarks>
31+
/// This list is not read-only to premit modifying of existing parameters by filters.
32+
/// </remarks>
33+
/// </summary>
34+
public IList<object?> Parameters { get; }
35+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
#nullable enable
22
Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions
33
static Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions.ConfigureRouteHandlerJsonOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.AspNetCore.Http.Json.JsonOptions!>! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
4+
Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.RouteHandlerFilters.get -> System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Http.IRouteHandlerFilter!>?
5+
Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.RouteHandlerFilters.init -> void

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

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public static partial class RequestDelegateFactory
3939
private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!;
4040
private static readonly MethodInfo StringResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteWriteStringResponseAsync), BindingFlags.NonPublic | BindingFlags.Static)!;
4141
private static readonly MethodInfo StringIsNullOrEmptyMethod = typeof(string).GetMethod(nameof(string.IsNullOrEmpty), BindingFlags.Static | BindingFlags.Public)!;
42+
private static readonly MethodInfo WrapObjectAsValueTaskMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WrapObjectAsValueTask), BindingFlags.NonPublic | BindingFlags.Static)!;
4243

4344
// Call WriteAsJsonAsync<object?>() to serialize the runtime return type rather than the declared return type.
4445
// https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism
@@ -71,12 +72,21 @@ public static partial class RequestDelegateFactory
7172
private static readonly MemberExpression FormFilesExpr = Expression.Property(FormExpr, typeof(IFormCollection).GetProperty(nameof(IFormCollection.Files))!);
7273
private static readonly MemberExpression StatusCodeExpr = Expression.Property(HttpResponseExpr, typeof(HttpResponse).GetProperty(nameof(HttpResponse.StatusCode))!);
7374
private static readonly MemberExpression CompletedTaskExpr = Expression.Property(null, (PropertyInfo)GetMemberInfo<Func<Task>>(() => Task.CompletedTask));
75+
private static readonly NewExpression CompletedValueTaskExpr = Expression.New(typeof(ValueTask<object>).GetConstructor(new[] { typeof(Task) })!, CompletedTaskExpr);
7476

7577
private static readonly ParameterExpression TempSourceStringExpr = ParameterBindingMethodCache.TempSourceStringExpr;
7678
private static readonly BinaryExpression TempSourceStringNotNullExpr = Expression.NotEqual(TempSourceStringExpr, Expression.Constant(null));
7779
private static readonly BinaryExpression TempSourceStringNullExpr = Expression.Equal(TempSourceStringExpr, Expression.Constant(null));
7880
private static readonly UnaryExpression TempSourceStringIsNotNullOrEmptyExpr = Expression.Not(Expression.Call(StringIsNullOrEmptyMethod, TempSourceStringExpr));
7981

82+
private static readonly ConstructorInfo RouteHandlerFilterContextConstructor = typeof(RouteHandlerFilterContext).GetConstructor(new[] { typeof(HttpContext), typeof(object[]) })!;
83+
private static readonly ParameterExpression FilterContextExpr = Expression.Parameter(typeof(RouteHandlerFilterContext), "context");
84+
private static readonly MemberExpression FilterContextParametersExpr = Expression.Property(FilterContextExpr, typeof(RouteHandlerFilterContext).GetProperty(nameof(RouteHandlerFilterContext.Parameters))!);
85+
private static readonly MemberExpression FilterContextHttpContextExpr = Expression.Property(FilterContextExpr, typeof(RouteHandlerFilterContext).GetProperty(nameof(RouteHandlerFilterContext.HttpContext))!);
86+
private static readonly MemberExpression FilterContextHttpContextResponseExpr = Expression.Property(FilterContextHttpContextExpr, typeof(HttpContext).GetProperty(nameof(HttpContext.Response))!);
87+
private static readonly MemberExpression FilterContextHttpContextStatusCodeExpr = Expression.Property(FilterContextHttpContextResponseExpr, typeof(HttpResponse).GetProperty(nameof(HttpResponse.StatusCode))!);
88+
private static readonly ParameterExpression InvokedFilterContextExpr = Expression.Parameter(typeof(RouteHandlerFilterContext), "filterContext");
89+
8090
private static readonly string[] DefaultAcceptsContentType = new[] { "application/json" };
8191
private static readonly string[] FormFileContentType = new[] { "multipart/form-data" };
8292

@@ -102,6 +112,7 @@ public static RequestDelegateResult Create(Delegate handler, RequestDelegateFact
102112
};
103113

104114
var factoryContext = CreateFactoryContext(options);
115+
105116
var targetableRequestDelegate = CreateTargetableRequestDelegate(handler.Method, targetExpression, factoryContext);
106117

107118
return new RequestDelegateResult(httpContext => targetableRequestDelegate(handler.Target, httpContext), factoryContext.Metadata);
@@ -155,6 +166,7 @@ private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions
155166
RouteParameters = options?.RouteParameterNames?.ToList(),
156167
ThrowOnBadRequest = options?.ThrowOnBadRequest ?? false,
157168
DisableInferredFromBody = options?.DisableInferBodyFromParameters ?? false,
169+
Filters = options?.RouteHandlerFilters?.ToList()
158170
};
159171

160172
private static Func<object?, HttpContext, Task> CreateTargetableRequestDelegate(MethodInfo methodInfo, Expression? targetExpression, FactoryContext factoryContext)
@@ -176,10 +188,31 @@ private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions
176188
// }
177189

178190
var arguments = CreateArguments(methodInfo.GetParameters(), factoryContext);
191+
var returnType = methodInfo.ReturnType;
192+
factoryContext.MethodCall = CreateMethodCall(methodInfo, targetExpression, arguments);
193+
194+
// If there are filters registered on the route handler, then we update the method call and
195+
// return type associated with the request to allow for the filter invocation pipeline.
196+
if (factoryContext.Filters is { Count: > 0 })
197+
{
198+
var filterPipeline = CreateFilterPipeline(methodInfo, targetExpression, factoryContext);
199+
Expression<Func<RouteHandlerFilterContext, ValueTask<object?>>> invokePipeline = (context) => filterPipeline(context);
200+
returnType = typeof(ValueTask<object?>);
201+
// var filterContext = new RouteHandlerFilterContext(httpContext, new[] { (object)name_local, (object)int_local });
202+
// invokePipeline.Invoke(filterContext);
203+
factoryContext.MethodCall = Expression.Block(
204+
new[] { InvokedFilterContextExpr },
205+
Expression.Assign(
206+
InvokedFilterContextExpr,
207+
Expression.New(RouteHandlerFilterContextConstructor,
208+
new Expression[] { HttpContextExpr, Expression.NewArrayInit(typeof(object), factoryContext.BoxedArgs) })),
209+
Expression.Invoke(invokePipeline, InvokedFilterContextExpr)
210+
);
211+
}
179212

180213
var responseWritingMethodCall = factoryContext.ParamCheckExpressions.Count > 0 ?
181-
CreateParamCheckingResponseWritingMethodCall(methodInfo, targetExpression, arguments, factoryContext) :
182-
CreateResponseWritingMethodCall(methodInfo, targetExpression, arguments);
214+
CreateParamCheckingResponseWritingMethodCall(returnType, factoryContext) :
215+
AddResponseWritingToMethodCall(factoryContext.MethodCall, returnType);
183216

184217
if (factoryContext.UsingTempSourceString)
185218
{
@@ -189,6 +222,35 @@ private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions
189222
return HandleRequestBodyAndCompileRequestDelegate(responseWritingMethodCall, factoryContext);
190223
}
191224

225+
private static Func<RouteHandlerFilterContext, ValueTask<object?>> CreateFilterPipeline(MethodInfo methodInfo, Expression? target, FactoryContext factoryContext)
226+
{
227+
Debug.Assert(factoryContext.Filters is not null);
228+
// httpContext.Response.StatusCode >= 400
229+
// ? Task.CompletedTask
230+
// : handler((string)context.Parameters[0], (int)context.Parameters[1])
231+
var filteredInvocation = Expression.Lambda<Func<RouteHandlerFilterContext, ValueTask<object?>>>(
232+
Expression.Condition(
233+
Expression.GreaterThanOrEqual(FilterContextHttpContextStatusCodeExpr, Expression.Constant(400)),
234+
CompletedValueTaskExpr,
235+
Expression.Block(
236+
new[] { TargetExpr },
237+
Expression.Call(WrapObjectAsValueTaskMethod,
238+
target is null
239+
? Expression.Call(methodInfo, factoryContext.ContextArgAccess)
240+
: Expression.Call(target, methodInfo, factoryContext.ContextArgAccess))
241+
)),
242+
FilterContextExpr).Compile();
243+
244+
for (var i = factoryContext.Filters.Count - 1; i >= 0; i--)
245+
{
246+
var currentFilter = factoryContext.Filters![i];
247+
var nextFilter = filteredInvocation;
248+
filteredInvocation = (RouteHandlerFilterContext context) => currentFilter.InvokeAsync(context, nextFilter);
249+
250+
}
251+
return filteredInvocation;
252+
}
253+
192254
private static Expression[] CreateArguments(ParameterInfo[]? parameters, FactoryContext factoryContext)
193255
{
194256
if (parameters is null || parameters.Length == 0)
@@ -201,6 +263,16 @@ private static Expression[] CreateArguments(ParameterInfo[]? parameters, Factory
201263
for (var i = 0; i < parameters.Length; i++)
202264
{
203265
args[i] = CreateArgument(parameters[i], factoryContext);
266+
// Register expressions containing the boxed and unboxed variants
267+
// of the route handler's arguments for use in RouteHandlerFilterContext
268+
// construction and route handler invocation.
269+
// (string)context.Parameters[0];
270+
factoryContext.ContextArgAccess.Add(
271+
Expression.Convert(
272+
Expression.Property(FilterContextParametersExpr, "Item", Expression.Constant(i)),
273+
parameters[i].ParameterType));
274+
// (object)name_local
275+
factoryContext.BoxedArgs.Add(Expression.Convert(args[i], typeof(object)));
204276
}
205277

206278
if (factoryContext.HasInferredBody && factoryContext.DisableInferredFromBody)
@@ -381,16 +453,14 @@ target is null ?
381453
Expression.Call(methodInfo, arguments) :
382454
Expression.Call(target, methodInfo, arguments);
383455

384-
private static Expression CreateResponseWritingMethodCall(MethodInfo methodInfo, Expression? target, Expression[] arguments)
456+
private static ValueTask<object?> WrapObjectAsValueTask(object? obj)
385457
{
386-
var callMethod = CreateMethodCall(methodInfo, target, arguments);
387-
return AddResponseWritingToMethodCall(callMethod, methodInfo.ReturnType);
458+
return ValueTask.FromResult<object?>(obj);
388459
}
389460

390461
// If we're calling TryParse or validating parameter optionality and
391462
// wasParamCheckFailure indicates it failed, set a 400 StatusCode instead of calling the method.
392-
private static Expression CreateParamCheckingResponseWritingMethodCall(
393-
MethodInfo methodInfo, Expression? target, Expression[] arguments, FactoryContext factoryContext)
463+
private static Expression CreateParamCheckingResponseWritingMethodCall(Type returnType, FactoryContext factoryContext)
394464
{
395465
// {
396466
// string tempSourceString;
@@ -440,17 +510,40 @@ private static Expression CreateParamCheckingResponseWritingMethodCall(
440510

441511
localVariables[factoryContext.ExtraLocals.Count] = WasParamCheckFailureExpr;
442512

443-
var set400StatusAndReturnCompletedTask = Expression.Block(
444-
Expression.Assign(StatusCodeExpr, Expression.Constant(400)),
445-
CompletedTaskExpr);
446-
447-
var methodCall = CreateMethodCall(methodInfo, target, arguments);
448-
449-
var checkWasParamCheckFailure = Expression.Condition(WasParamCheckFailureExpr,
450-
set400StatusAndReturnCompletedTask,
451-
AddResponseWritingToMethodCall(methodCall, methodInfo.ReturnType));
513+
// If filters have been registered, we set the `wasParamCheckFailure` property
514+
// but do not return from the invocation to allow the filters to run.
515+
if (factoryContext.Filters is { Count: > 0 })
516+
{
517+
// if (wasParamCheckFailure)
518+
// {
519+
// httpContext.Response.StatusCode = 400;
520+
// }
521+
// return RequestDelegateFactory.ExecuteObjectReturn(invocationPipeline.Invoke(context) as object);
522+
var checkWasParamCheckFailureWithFilters = Expression.Block(
523+
Expression.IfThen(
524+
WasParamCheckFailureExpr,
525+
Expression.Assign(StatusCodeExpr, Expression.Constant(400))),
526+
AddResponseWritingToMethodCall(factoryContext.MethodCall!, returnType)
527+
);
452528

453-
checkParamAndCallMethod[factoryContext.ParamCheckExpressions.Count] = checkWasParamCheckFailure;
529+
checkParamAndCallMethod[factoryContext.ParamCheckExpressions.Count] = checkWasParamCheckFailureWithFilters;
530+
}
531+
else
532+
{
533+
// wasParamCheckFailure ? {
534+
// httpContext.Response.StatusCode = 400;
535+
// return Task.CompletedTask;
536+
// } : {
537+
// return RequestDelegateFactory.ExecuteObjectReturn(invocationPipeline.Invoke(context) as object);
538+
// }
539+
var checkWasParamCheckFailure = Expression.Condition(
540+
WasParamCheckFailureExpr,
541+
Expression.Block(
542+
Expression.Assign(StatusCodeExpr, Expression.Constant(400)),
543+
CompletedTaskExpr),
544+
AddResponseWritingToMethodCall(factoryContext.MethodCall!, returnType));
545+
checkParamAndCallMethod[factoryContext.ParamCheckExpressions.Count] = checkWasParamCheckFailure;
546+
}
454547

455548
return Expression.Block(localVariables, checkParamAndCallMethod);
456549
}
@@ -1596,6 +1689,11 @@ private class FactoryContext
15961689

15971690
public bool ReadForm { get; set; }
15981691
public ParameterInfo? FirstFormRequestBodyParameter { get; set; }
1692+
// Properties for constructing and managing filters
1693+
public List<Expression> ContextArgAccess { get; } = new();
1694+
public Expression? MethodCall { get; set; }
1695+
public List<Expression> BoxedArgs { get; } = new();
1696+
public List<IRouteHandlerFilter>? Filters { get; init; }
15991697
}
16001698

16011699
private static class RequestDelegateFactoryConstants

src/Http/Http.Extensions/src/RequestDelegateFactoryOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,9 @@ public sealed class RequestDelegateFactoryOptions
3131
/// Prevent the <see cref="RequestDelegateFactory" /> from inferring a parameter should be bound from the request body without an attribute that implements <see cref="IFromBodyMetadata"/>.
3232
/// </summary>
3333
public bool DisableInferBodyFromParameters { get; init; }
34+
35+
/// <summary>
36+
/// The list of filters that must run in the pipeline for a given route handler.
37+
/// </summary>
38+
public IReadOnlyList<IRouteHandlerFilter>? RouteHandlerFilters { get; init; }
3439
}

0 commit comments

Comments
 (0)