Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for endpoint filters in minimal APIs #40491

Merged
merged 5 commits into from
Mar 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Http/Http.Abstractions/src/IRouteHandlerFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Http;

/// <summary>
/// Provides an interface for implementing a filter targetting a route handler.
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
public interface IRouteHandlerFilter
{
/// <summary>
/// Implements the core logic associated with the filter given a <see cref="RouteHandlerFilterContext"/>
/// and the next filter to call in the pipeline.
/// </summary>
/// <param name="context">The <see cref="RouteHandlerFilterContext"/> associated with the current request/response.</param>
/// <param name="next">The next filter in the pipeline.</param>
/// <returns>An awaitable result of calling the handler and apply
/// any modifications made by filters in the pipeline.</returns>
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
ValueTask<object?> InvokeAsync(RouteHandlerFilterContext context, Func<RouteHandlerFilterContext, ValueTask<object?>> next);
}
6 changes: 6 additions & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
#nullable enable
*REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string!
Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata<T>() -> T!
Microsoft.AspNetCore.Http.RouteHandlerFilterContext.RouteHandlerFilterContext(Microsoft.AspNetCore.Http.HttpContext! httpContext, params object![]! parameters) -> void
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?>
Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata
Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string?
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(Microsoft.AspNetCore.Routing.RouteValueDictionary? dictionary) -> void
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string!, object?>>? values) -> void
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string!, string?>>? values) -> void
abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string?
Microsoft.AspNetCore.Http.Metadata.ISkipStatusCodePagesMetadata
Microsoft.AspNetCore.Http.RouteHandlerFilterContext
Microsoft.AspNetCore.Http.RouteHandlerFilterContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext!
Microsoft.AspNetCore.Http.RouteHandlerFilterContext.Parameters.get -> System.Collections.Generic.IList<object?>!
Microsoft.AspNetCore.Http.IRouteHandlerFilter
35 changes: 35 additions & 0 deletions src/Http/Http.Abstractions/src/RouteHandlerFilterContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Http;

/// <summary>
/// Provides an abstraction for wrapping the <see cref="HttpContext"/> and parameters
/// provided to a route handler.
/// </summary>
public class RouteHandlerFilterContext
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
{
/// <summary>
/// Creates a new instance of the <see cref="RouteHandlerFilterContext"/> for a given request.
/// </summary>
/// <param name="httpContext">The <see cref="HttpContext"/> associated with the current request.</param>
/// <param name="parameters">A list of parameters provided in the current request.</param>
public RouteHandlerFilterContext(HttpContext httpContext, params object[] parameters)
{
HttpContext = httpContext;
Parameters = parameters;
}

/// <summary>
/// The <see cref="HttpContext"/> associated with the current request being processed by the filter.
/// </summary>
public HttpContext HttpContext { get; }

/// <summary>
/// A list of parameters provided in the current request to the filter.
/// <remarks>
/// This list is not read-only to premit modifying of existing parameters by filters.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next PR:

Suggested change
/// This list is not read-only to premit modifying of existing parameters by filters.
/// This list is not read-only to permit modifying of existing parameters by filters.

/// </remarks>
/// </summary>
public IList<object?> Parameters { get; }
}
2 changes: 2 additions & 0 deletions src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#nullable enable
Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions
static Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions.ConfigureRouteHandlerJsonOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.AspNetCore.Http.Json.JsonOptions!>! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.RouteHandlerFilters.get -> System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Http.IRouteHandlerFilter!>?
Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.RouteHandlerFilters.init -> void
132 changes: 115 additions & 17 deletions src/Http/Http.Extensions/src/RequestDelegateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public static partial class RequestDelegateFactory
private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo StringResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteWriteStringResponseAsync), BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo StringIsNullOrEmptyMethod = typeof(string).GetMethod(nameof(string.IsNullOrEmpty), BindingFlags.Static | BindingFlags.Public)!;
private static readonly MethodInfo WrapObjectAsValueTaskMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WrapObjectAsValueTask), BindingFlags.NonPublic | BindingFlags.Static)!;

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

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

private static readonly ConstructorInfo RouteHandlerFilterContextConstructor = typeof(RouteHandlerFilterContext).GetConstructor(new[] { typeof(HttpContext), typeof(object[]) })!;
private static readonly ParameterExpression FilterContextExpr = Expression.Parameter(typeof(RouteHandlerFilterContext), "context");
private static readonly MemberExpression FilterContextParametersExpr = Expression.Property(FilterContextExpr, typeof(RouteHandlerFilterContext).GetProperty(nameof(RouteHandlerFilterContext.Parameters))!);
private static readonly MemberExpression FilterContextHttpContextExpr = Expression.Property(FilterContextExpr, typeof(RouteHandlerFilterContext).GetProperty(nameof(RouteHandlerFilterContext.HttpContext))!);
private static readonly MemberExpression FilterContextHttpContextResponseExpr = Expression.Property(FilterContextHttpContextExpr, typeof(HttpContext).GetProperty(nameof(HttpContext.Response))!);
private static readonly MemberExpression FilterContextHttpContextStatusCodeExpr = Expression.Property(FilterContextHttpContextResponseExpr, typeof(HttpResponse).GetProperty(nameof(HttpResponse.StatusCode))!);
private static readonly ParameterExpression InvokedFilterContextExpr = Expression.Parameter(typeof(RouteHandlerFilterContext), "filterContext");

private static readonly string[] DefaultAcceptsContentType = new[] { "application/json" };
private static readonly string[] FormFileContentType = new[] { "multipart/form-data" };

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

var factoryContext = CreateFactoryContext(options);

var targetableRequestDelegate = CreateTargetableRequestDelegate(handler.Method, targetExpression, factoryContext);

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

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

var arguments = CreateArguments(methodInfo.GetParameters(), factoryContext);
var returnType = methodInfo.ReturnType;
factoryContext.MethodCall = CreateMethodCall(methodInfo, targetExpression, arguments);

// If there are filters registered on the route handler, then we update the method call and
// return type associated with the request to allow for the filter invocation pipeline.
if (factoryContext.Filters is { Count: > 0 })
{
var filterPipeline = CreateFilterPipeline(methodInfo, targetExpression, factoryContext);
Expression<Func<RouteHandlerFilterContext, ValueTask<object?>>> invokePipeline = (context) => filterPipeline(context);
returnType = typeof(ValueTask<object?>);
// var filterContext = new RouteHandlerFilterContext(httpContext, new[] { (object)name_local, (object)int_local });
// invokePipeline.Invoke(filterContext);
factoryContext.MethodCall = Expression.Block(
new[] { InvokedFilterContextExpr },
Expression.Assign(
InvokedFilterContextExpr,
Expression.New(RouteHandlerFilterContextConstructor,
new Expression[] { HttpContextExpr, Expression.NewArrayInit(typeof(object), factoryContext.BoxedArgs) })),
Expression.Invoke(invokePipeline, InvokedFilterContextExpr)
);
}

var responseWritingMethodCall = factoryContext.ParamCheckExpressions.Count > 0 ?
CreateParamCheckingResponseWritingMethodCall(methodInfo, targetExpression, arguments, factoryContext) :
CreateResponseWritingMethodCall(methodInfo, targetExpression, arguments);
CreateParamCheckingResponseWritingMethodCall(returnType, factoryContext) :
AddResponseWritingToMethodCall(factoryContext.MethodCall, returnType);

if (factoryContext.UsingTempSourceString)
{
Expand All @@ -189,6 +222,35 @@ private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions
return HandleRequestBodyAndCompileRequestDelegate(responseWritingMethodCall, factoryContext);
}

private static Func<RouteHandlerFilterContext, ValueTask<object?>> CreateFilterPipeline(MethodInfo methodInfo, Expression? target, FactoryContext factoryContext)
{
Debug.Assert(factoryContext.Filters is not null);
// httpContext.Response.StatusCode >= 400
// ? Task.CompletedTask
// : handler((string)context.Parameters[0], (int)context.Parameters[1])
var filteredInvocation = Expression.Lambda<Func<RouteHandlerFilterContext, ValueTask<object?>>>(
Expression.Condition(
Expression.GreaterThanOrEqual(FilterContextHttpContextStatusCodeExpr, Expression.Constant(400)),
CompletedValueTaskExpr,
Expression.Block(
new[] { TargetExpr },
Expression.Call(WrapObjectAsValueTaskMethod,
target is null
? Expression.Call(methodInfo, factoryContext.ContextArgAccess)
: Expression.Call(target, methodInfo, factoryContext.ContextArgAccess))
)),
FilterContextExpr).Compile();

for (var i = factoryContext.Filters.Count - 1; i >= 0; i--)
{
var currentFilter = factoryContext.Filters![i];
var nextFilter = filteredInvocation;
filteredInvocation = (RouteHandlerFilterContext context) => currentFilter.InvokeAsync(context, nextFilter);

}
return filteredInvocation;
}

private static Expression[] CreateArguments(ParameterInfo[]? parameters, FactoryContext factoryContext)
{
if (parameters is null || parameters.Length == 0)
Expand All @@ -201,6 +263,16 @@ private static Expression[] CreateArguments(ParameterInfo[]? parameters, Factory
for (var i = 0; i < parameters.Length; i++)
{
args[i] = CreateArgument(parameters[i], factoryContext);
// Register expressions containing the boxed and unboxed variants
// of the route handler's arguments for use in RouteHandlerFilterContext
// construction and route handler invocation.
// (string)context.Parameters[0];
factoryContext.ContextArgAccess.Add(
Expression.Convert(
Expression.Property(FilterContextParametersExpr, "Item", Expression.Constant(i)),
parameters[i].ParameterType));
// (object)name_local
factoryContext.BoxedArgs.Add(Expression.Convert(args[i], typeof(object)));
}

if (factoryContext.HasInferredBody && factoryContext.DisableInferredFromBody)
Expand Down Expand Up @@ -381,16 +453,14 @@ target is null ?
Expression.Call(methodInfo, arguments) :
Expression.Call(target, methodInfo, arguments);

private static Expression CreateResponseWritingMethodCall(MethodInfo methodInfo, Expression? target, Expression[] arguments)
private static ValueTask<object?> WrapObjectAsValueTask(object? obj)
{
var callMethod = CreateMethodCall(methodInfo, target, arguments);
return AddResponseWritingToMethodCall(callMethod, methodInfo.ReturnType);
return ValueTask.FromResult<object?>(obj);
}

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

localVariables[factoryContext.ExtraLocals.Count] = WasParamCheckFailureExpr;

var set400StatusAndReturnCompletedTask = Expression.Block(
Expression.Assign(StatusCodeExpr, Expression.Constant(400)),
CompletedTaskExpr);

var methodCall = CreateMethodCall(methodInfo, target, arguments);

var checkWasParamCheckFailure = Expression.Condition(WasParamCheckFailureExpr,
set400StatusAndReturnCompletedTask,
AddResponseWritingToMethodCall(methodCall, methodInfo.ReturnType));
// If filters have been registered, we set the `wasParamCheckFailure` property
// but do not return from the invocation to allow the filters to run.
if (factoryContext.Filters is { Count: > 0 })
{
// if (wasParamCheckFailure)
// {
// httpContext.Response.StatusCode = 400;
// }
// return RequestDelegateFactory.ExecuteObjectReturn(invocationPipeline.Invoke(context) as object);
var checkWasParamCheckFailureWithFilters = Expression.Block(
Expression.IfThen(
WasParamCheckFailureExpr,
Expression.Assign(StatusCodeExpr, Expression.Constant(400))),
AddResponseWritingToMethodCall(factoryContext.MethodCall!, returnType)
);

checkParamAndCallMethod[factoryContext.ParamCheckExpressions.Count] = checkWasParamCheckFailure;
checkParamAndCallMethod[factoryContext.ParamCheckExpressions.Count] = checkWasParamCheckFailureWithFilters;
}
else
{
// wasParamCheckFailure ? {
// httpContext.Response.StatusCode = 400;
// return Task.CompletedTask;
// } : {
// return RequestDelegateFactory.ExecuteObjectReturn(invocationPipeline.Invoke(context) as object);
// }
var checkWasParamCheckFailure = Expression.Condition(
WasParamCheckFailureExpr,
Expression.Block(
Expression.Assign(StatusCodeExpr, Expression.Constant(400)),
CompletedTaskExpr),
AddResponseWritingToMethodCall(factoryContext.MethodCall!, returnType));
checkParamAndCallMethod[factoryContext.ParamCheckExpressions.Count] = checkWasParamCheckFailure;
}

return Expression.Block(localVariables, checkParamAndCallMethod);
}
Expand Down Expand Up @@ -1596,6 +1689,11 @@ private class FactoryContext

public bool ReadForm { get; set; }
public ParameterInfo? FirstFormRequestBodyParameter { get; set; }
// Properties for constructing and managing filters
public List<Expression> ContextArgAccess { get; } = new();
public Expression? MethodCall { get; set; }
public List<Expression> BoxedArgs { get; } = new();
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
public List<IRouteHandlerFilter>? Filters { get; init; }
}

private static class RequestDelegateFactoryConstants
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ public sealed class RequestDelegateFactoryOptions
/// Prevent the <see cref="RequestDelegateFactory" /> from inferring a parameter should be bound from the request body without an attribute that implements <see cref="IFromBodyMetadata"/>.
/// </summary>
public bool DisableInferBodyFromParameters { get; init; }

/// <summary>
/// The list of filters that must run in the pipeline for a given route handler.
/// </summary>
public IReadOnlyList<IRouteHandlerFilter>? RouteHandlerFilters { get; init; }
}
Loading