From d27c95d759fca5b5fb3219dcd108fda5b654d8ce Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 13 Jan 2023 09:10:54 +0800 Subject: [PATCH] [AOT] Add expression free request filter pipeline for RequestDelegate (#46020) Co-authored-by: Eric Erhardt --- ...icrosoft.AspNetCore.Http.Extensions.csproj | 1 + .../src/RequestDelegateFactory.cs | 52 +--- .../src/RequestDelegateMetadataResult.cs | 3 +- .../Builder/EndpointRouteBuilderExtensions.cs | 24 +- .../src/Builder/RouteHandlerBuilder.cs | 8 - .../src/Microsoft.AspNetCore.Routing.csproj | 2 + .../RequestDelegateFilterPipelineBuilder.cs | 68 +++++ .../Routing/src/RouteEndpointDataSource.cs | 61 ++--- ...egateEndpointRouteBuilderExtensionsTest.cs | 232 ++++++++++++++++-- .../Microsoft.AspNetCore.Routing.Tests.csproj | 1 + .../RouteHandlers/ExecuteHandlerHelper.cs | 54 ++++ 11 files changed, 400 insertions(+), 106 deletions(-) create mode 100644 src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs create mode 100644 src/Shared/RouteHandlers/ExecuteHandlerHelper.cs diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj index 748965a360a6..65576652a378 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index d86e3a48a820..2faaf9c60984 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; @@ -260,12 +261,12 @@ public static RequestDelegateResult Create(MethodInfo methodInfo, Func task, HttpContext httpContext, Jso private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) { - // Terminal built ins - if (obj is IResult result) - { - return ExecuteResultWriteResponse(result, httpContext); - } - else if (obj is string stringValue) - { - SetPlaintextContentType(httpContext); - return httpContext.Response.WriteAsync(stringValue); - } - else - { - // Otherwise, we JSON serialize when we reach the terminal state - return WriteJsonResponse(httpContext.Response, obj, options, jsonTypeInfo); - } + return ExecuteHandlerHelper.ExecuteReturnAsync(obj, httpContext, options, jsonTypeInfo); } private static Task ExecuteTaskOfTFast(Task task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) @@ -2188,7 +2175,7 @@ static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonSeri private static Task ExecuteTaskOfString(Task task, HttpContext httpContext) { - SetPlaintextContentType(httpContext); + ExecuteHandlerHelper.SetPlaintextContentType(httpContext); EnsureRequestTaskNotNull(task); static async Task ExecuteAwaited(Task task, HttpContext httpContext) @@ -2206,7 +2193,7 @@ static async Task ExecuteAwaited(Task task, HttpContext httpContext) private static Task ExecuteWriteStringResponseAsync(HttpContext httpContext, string text) { - SetPlaintextContentType(httpContext); + ExecuteHandlerHelper.SetPlaintextContentType(httpContext); return httpContext.Response.WriteAsync(text); } @@ -2293,7 +2280,7 @@ static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext, Jso private static Task ExecuteValueTaskOfString(ValueTask task, HttpContext httpContext) { - SetPlaintextContentType(httpContext); + ExecuteHandlerHelper.SetPlaintextContentType(httpContext); static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext) { @@ -2342,21 +2329,7 @@ private static Task WriteJsonResponseFast(HttpResponse response, T value, Jso private static Task WriteJsonResponse(HttpResponse response, T? value, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) { - var runtimeType = value?.GetType(); - - if (runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.IsPolymorphicSafe()) - { - // In this case the polymorphism is not - // relevant for us and will be handled by STJ, if needed. - return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, jsonTypeInfo, default); - } - - // Call WriteAsJsonAsync() with the runtime type to serialize the runtime type rather than the declared type - // and avoid source generators issues. - // https://github.com/dotnet/aspnetcore/issues/43894 - // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism - var runtimeTypeInfo = options.GetTypeInfo(runtimeType); - return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, runtimeTypeInfo, default); + return ExecuteHandlerHelper.WriteJsonResponseAsync(response, value, options, jsonTypeInfo); } private static NotSupportedException GetUnsupportedReturnTypeException(Type returnType) @@ -2545,11 +2518,6 @@ private static IResult EnsureRequestResultNotNull(IResult? result) return result; } - private static void SetPlaintextContentType(HttpContext httpContext) - { - httpContext.Response.ContentType ??= "text/plain; charset=utf-8"; - } - private static string BuildErrorMessageForMultipleBodyParameters(RequestDelegateFactoryContext factoryContext) { var errorMessage = new StringBuilder(); diff --git a/src/Http/Http.Extensions/src/RequestDelegateMetadataResult.cs b/src/Http/Http.Extensions/src/RequestDelegateMetadataResult.cs index 3ca91ac8bacf..a50e4b7c7fee 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateMetadataResult.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateMetadataResult.cs @@ -19,5 +19,6 @@ public sealed class RequestDelegateMetadataResult // This internal cached context avoids redoing unnecessary reflection in Create that was already done in InferMetadata. // InferMetadata currently does more work than it needs to building up expression trees, but the expectation is that InferMetadata will usually be followed by Create. - internal RequestDelegateFactoryContext? CachedFactoryContext { get; set; } + // The property is typed as object to avoid having a dependency System.Linq.Expressions. The value is RequestDelegateFactoryContext. + internal object? CachedFactoryContext { get; set; } } diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index 97f01d862be2..648356d1a304 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -196,7 +196,25 @@ private static IEndpointConventionBuilder Map( ArgumentNullException.ThrowIfNull(pattern); ArgumentNullException.ThrowIfNull(requestDelegate); - return endpoints.GetOrAddRouteEndpointDataSource().AddRequestDelegate(pattern, requestDelegate, httpMethods); + return endpoints + .GetOrAddRouteEndpointDataSource() + .AddRequestDelegate(pattern, requestDelegate, httpMethods, CreateHandlerRequestDelegate); + + static RequestDelegateResult CreateHandlerRequestDelegate(Delegate handler, RequestDelegateFactoryOptions options, RequestDelegateMetadataResult? metadataResult) + { + var requestDelegate = (RequestDelegate)handler; + + // Create request delegate that calls filter pipeline. + if (options.EndpointBuilder?.FilterFactories.Count > 0) + { + requestDelegate = RequestDelegateFilterPipelineBuilder.Create(requestDelegate, options); + } + + IReadOnlyList metadata = options.EndpointBuilder?.Metadata is not null ? + new List(options.EndpointBuilder.Metadata) : + Array.Empty(); + return new RequestDelegateResult(requestDelegate, metadata); + } } /// @@ -416,7 +434,9 @@ private static RouteHandlerBuilder Map( ArgumentNullException.ThrowIfNull(pattern); ArgumentNullException.ThrowIfNull(handler); - return endpoints.GetOrAddRouteEndpointDataSource().AddRouteHandler(pattern, handler, httpMethods, isFallback); + return endpoints + .GetOrAddRouteEndpointDataSource() + .AddRouteHandler(pattern, handler, httpMethods, isFallback, RequestDelegateFactory.InferMetadata, RequestDelegateFactory.Create); } private static RouteEndpointDataSource GetOrAddRouteEndpointDataSource(this IEndpointRouteBuilder endpoints) diff --git a/src/Http/Routing/src/Builder/RouteHandlerBuilder.cs b/src/Http/Routing/src/Builder/RouteHandlerBuilder.cs index 051972f44ded..86a474189556 100644 --- a/src/Http/Routing/src/Builder/RouteHandlerBuilder.cs +++ b/src/Http/Routing/src/Builder/RouteHandlerBuilder.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.AspNetCore.Routing; - namespace Microsoft.AspNetCore.Builder; /// @@ -14,12 +12,6 @@ public sealed class RouteHandlerBuilder : IEndpointConventionBuilder private readonly ICollection>? _conventions; private readonly ICollection>? _finallyConventions; - /// - /// Instantiates a new given a ThrowOnAddAfterEndpointBuiltConventionCollection from - /// . - /// - /// The convention list returned from . - /// The final convention list returned from . internal RouteHandlerBuilder(ICollection> conventions, ICollection> finallyConventions) { _conventions = conventions; diff --git a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj index 976eee6daf60..bfa84d35edb5 100644 --- a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj +++ b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj @@ -31,6 +31,8 @@ + + diff --git a/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs b/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs new file mode 100644 index 000000000000..5f6fd3ffaf33 --- /dev/null +++ b/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Routing; + +internal static class RequestDelegateFilterPipelineBuilder +{ + // Due to https://github.com/dotnet/aspnetcore/issues/41330 we cannot reference the EmptyHttpResult type + // but users still need to assert on it as in https://github.com/dotnet/aspnetcore/issues/45063 + // so we temporarily work around this here by using reflection to get the actual type. + private static readonly object? EmptyHttpResultInstance = Type.GetType("Microsoft.AspNetCore.Http.HttpResults.EmptyHttpResult, Microsoft.AspNetCore.Http.Results")?.GetProperty("Instance")?.GetValue(null, null); + + public static RequestDelegate Create(RequestDelegate requestDelegate, RequestDelegateFactoryOptions options) + { + Debug.Assert(options.EndpointBuilder != null); + + var serviceProvider = options.ServiceProvider ?? options.EndpointBuilder.ApplicationServices; + var jsonOptions = serviceProvider?.GetService>()?.Value ?? new JsonOptions(); + var jsonSerializerOptions = jsonOptions.SerializerOptions; + + var factoryContext = new EndpointFilterFactoryContext + { + MethodInfo = requestDelegate.Method, + ApplicationServices = options.EndpointBuilder.ApplicationServices + }; + var jsonTypeInfo = (JsonTypeInfo)jsonSerializerOptions.GetReadOnlyTypeInfo(typeof(object)); + + EndpointFilterDelegate filteredInvocation = async (EndpointFilterInvocationContext context) => + { + Debug.Assert(EmptyHttpResultInstance != null, "Unable to get EmptyHttpResult instance via reflection."); + if (context.HttpContext.Response.StatusCode < 400) + { + await requestDelegate(context.HttpContext); + } + return EmptyHttpResultInstance; + }; + + var initialFilteredInvocation = filteredInvocation; + for (var i = options.EndpointBuilder.FilterFactories.Count - 1; i >= 0; i--) + { + var currentFilterFactory = options.EndpointBuilder.FilterFactories[i]; + filteredInvocation = currentFilterFactory(factoryContext, filteredInvocation); + } + + // The filter factories have run without modifying per-request behavior, we can skip running the pipeline. + if (ReferenceEquals(initialFilteredInvocation, filteredInvocation)) + { + return requestDelegate; + } + + return async (HttpContext httpContext) => + { + var obj = await filteredInvocation(new DefaultEndpointFilterInvocationContext(httpContext, new object[] { httpContext })); + if (obj is not null) + { + await ExecuteHandlerHelper.ExecuteReturnAsync(obj, httpContext, jsonSerializerOptions, jsonTypeInfo); + } + }; + } +} diff --git a/src/Http/Routing/src/RouteEndpointDataSource.cs b/src/Http/Routing/src/RouteEndpointDataSource.cs index 1bacad5cd6f0..96fbdf6c6cb1 100644 --- a/src/Http/Routing/src/RouteEndpointDataSource.cs +++ b/src/Http/Routing/src/RouteEndpointDataSource.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Builder; @@ -28,9 +27,9 @@ public RouteEndpointDataSource(IServiceProvider applicationServices, bool throwO public RouteHandlerBuilder AddRequestDelegate( RoutePattern pattern, RequestDelegate requestDelegate, - IEnumerable? httpMethods) + IEnumerable? httpMethods, + Func createHandlerRequestDelegateFunc) { - var conventions = new ThrowOnAddAfterEndpointBuiltConventionCollection(); var finallyConventions = new ThrowOnAddAfterEndpointBuiltConventionCollection(); @@ -41,7 +40,9 @@ public RouteHandlerBuilder AddRequestDelegate( HttpMethods = httpMethods, RouteAttributes = RouteAttributes.None, Conventions = conventions, - FinallyConventions = finallyConventions + FinallyConventions = finallyConventions, + InferMetadataFunc = null, // Metadata isn't infered from RequestDelegate endpoints + CreateHandlerRequestDelegateFunc = createHandlerRequestDelegateFunc }); return new RouteHandlerBuilder(conventions, finallyConventions); @@ -51,7 +52,9 @@ public RouteHandlerBuilder AddRouteHandler( RoutePattern pattern, Delegate routeHandler, IEnumerable? httpMethods, - bool isFallback) + bool isFallback, + Func? inferMetadataFunc, + Func createHandlerRequestDelegateFunc) { var conventions = new ThrowOnAddAfterEndpointBuiltConventionCollection(); var finallyConventions = new ThrowOnAddAfterEndpointBuiltConventionCollection(); @@ -69,7 +72,9 @@ public RouteHandlerBuilder AddRouteHandler( HttpMethods = httpMethods, RouteAttributes = routeAttributes, Conventions = conventions, - FinallyConventions = finallyConventions + FinallyConventions = finallyConventions, + InferMetadataFunc = inferMetadataFunc, + CreateHandlerRequestDelegateFunc = createHandlerRequestDelegateFunc }); return new RouteHandlerBuilder(conventions, finallyConventions); @@ -196,8 +201,10 @@ private RouteEndpointBuilder CreateRouteEndpointBuilder( // they can do so via IEndpointConventionBuilder.Finally like the do to override any other entry-specific metadata. if (isRouteHandler) { + Debug.Assert(entry.InferMetadataFunc != null, "A func to infer metadata must be provided for route handlers."); + rdfOptions = CreateRdfOptions(entry, pattern, builder); - rdfMetadataResult = InferHandlerMetadata(entry.RouteHandler.Method, rdfOptions); + rdfMetadataResult = entry.InferMetadataFunc(entry.RouteHandler.Method, rdfOptions); } // Add delegate attributes as metadata before entry-specific conventions but after group conventions. @@ -225,7 +232,7 @@ private RouteEndpointBuilder CreateRouteEndpointBuilder( // We ignore the returned EndpointMetadata has been already populated since we passed in non-null EndpointMetadata. // We always set factoryRequestDelegate in case something is still referencing the redirected version of the RequestDelegate. - factoryCreatedRequestDelegate = CreateHandlerRequestDelegate(entry.RouteHandler, rdfOptions, rdfMetadataResult); + factoryCreatedRequestDelegate = entry.CreateHandlerRequestDelegateFunc(entry.RouteHandler, rdfOptions, rdfMetadataResult).RequestDelegate; } Debug.Assert(factoryCreatedRequestDelegate is not null); @@ -251,28 +258,6 @@ private RouteEndpointBuilder CreateRouteEndpointBuilder( } return builder; - - [UnconditionalSuppressMessage("Trimmer", "IL2026", - Justification = "We surface a RequireUnreferencedCode in the call to the Map methods adding route handlers to this EndpointDataSource. Analysis is unable to infer this. " + - "Map methods that configure a RequestDelegate don't use trimmer unsafe features.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "We surface a RequiresDynamicCode in the call to the Map methods adding route handlers this EndpointDataSource. Analysis is unable to infer this. " + - "Map methods that configure a RequestDelegate don't use AOT unsafe features.")] - static RequestDelegateMetadataResult InferHandlerMetadata(MethodInfo methodInfo, RequestDelegateFactoryOptions? options = null) - { - return RequestDelegateFactory.InferMetadata(methodInfo, options); - } - - [UnconditionalSuppressMessage("Trimmer", "IL2026", - Justification = "We surface a RequireUnreferencedCode in the call to the Map methods adding route handlers to this EndpointDataSource. Analysis is unable to infer this. " + - "Map methods that configure a RequestDelegate don't use trimmer unsafe features.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "We surface a RequiresDynamicCode in the call to the Map methods adding route handlers this EndpointDataSource. Analysis is unable to infer this. " + - "Map methods that configure a RequestDelegate don't use AOT unsafe features.")] - static RequestDelegate CreateHandlerRequestDelegate(Delegate handler, RequestDelegateFactoryOptions options, RequestDelegateMetadataResult? metadataResult) - { - return RequestDelegateFactory.Create(handler, options, metadataResult).RequestDelegate; - } } private RequestDelegateFactoryOptions CreateRdfOptions(RouteEntry entry, RoutePattern pattern, RouteEndpointBuilder builder) @@ -323,14 +308,16 @@ static bool ShouldDisableInferredBodyForMethod(string method) => return false; } - private struct RouteEntry + private readonly struct RouteEntry { - public RoutePattern RoutePattern { get; init; } - public Delegate RouteHandler { get; init; } - public IEnumerable? HttpMethods { get; init; } - public RouteAttributes RouteAttributes { get; init; } - public ThrowOnAddAfterEndpointBuiltConventionCollection Conventions { get; init; } - public ThrowOnAddAfterEndpointBuiltConventionCollection FinallyConventions { get; init; } + public required RoutePattern RoutePattern { get; init; } + public required Delegate RouteHandler { get; init; } + public required IEnumerable? HttpMethods { get; init; } + public required RouteAttributes RouteAttributes { get; init; } + public required ThrowOnAddAfterEndpointBuiltConventionCollection Conventions { get; init; } + public required ThrowOnAddAfterEndpointBuiltConventionCollection FinallyConventions { get; init; } + public required Func? InferMetadataFunc { get; init; } + public required Func CreateHandlerRequestDelegateFunc { get; init; } } [Flags] diff --git a/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs index 0c693a01a2dd..c4924422a748 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs @@ -7,9 +7,11 @@ using System.Reflection; using System.Text; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Builder; @@ -29,28 +31,28 @@ public static object[][] MapMethods { get { - IEndpointConventionBuilder MapGet(IEndpointRouteBuilder routes, string template, Delegate action) => + IEndpointConventionBuilder MapGet(IEndpointRouteBuilder routes, string template, RequestDelegate action) => routes.MapGet(template, action); - IEndpointConventionBuilder MapPost(IEndpointRouteBuilder routes, string template, Delegate action) => + IEndpointConventionBuilder MapPost(IEndpointRouteBuilder routes, string template, RequestDelegate action) => routes.MapPost(template, action); - IEndpointConventionBuilder MapPut(IEndpointRouteBuilder routes, string template, Delegate action) => + IEndpointConventionBuilder MapPut(IEndpointRouteBuilder routes, string template, RequestDelegate action) => routes.MapPut(template, action); - IEndpointConventionBuilder MapDelete(IEndpointRouteBuilder routes, string template, Delegate action) => + IEndpointConventionBuilder MapDelete(IEndpointRouteBuilder routes, string template, RequestDelegate action) => routes.MapDelete(template, action); - IEndpointConventionBuilder Map(IEndpointRouteBuilder routes, string template, Delegate action) => + IEndpointConventionBuilder Map(IEndpointRouteBuilder routes, string template, RequestDelegate action) => routes.Map(template, action); return new object[][] { - new object[] { (Func)MapGet }, - new object[] { (Func)MapPost }, - new object[] { (Func)MapPut }, - new object[] { (Func)MapDelete }, - new object[] { (Func)Map }, + new object[] { (Func)MapGet }, + new object[] { (Func)MapPost }, + new object[] { (Func)MapPut }, + new object[] { (Func)MapDelete }, + new object[] { (Func)Map }, }; } } @@ -75,7 +77,7 @@ public void MapEndpoint_StringPattern_BuildsEndpoint() [Theory] [MemberData(nameof(MapMethods))] - public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func map) + public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func map) { var httpContext = new DefaultHttpContext(); var responseBodyStream = new MemoryStream(); @@ -83,7 +85,11 @@ public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func GenericTypeTaskDelegate(HttpContext context) => await Task.FromResult("String Test"); + static async Task GenericTypeTaskDelegate(HttpContext context) + { + await context.Response.WriteAsync("Response string text"); + return await Task.FromResult("String Test"); + } // Act var endpointBuilder = map(builder, "/", GenericTypeTaskDelegate); @@ -98,12 +104,12 @@ public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func map) + public async Task MapEndpoint_CanBeFiltered_EndpointFilterFactory(Func map) { var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance)); var httpContext = new DefaultHttpContext(); @@ -111,7 +117,6 @@ public async Task MapEndpoint_CanBeFiltered_ByEndpointFilters(Func Task.CompletedTask; - var filterTag = new TagsAttribute("filter"); var endpointBuilder = map(builder, "/", initialRequestDelegate).AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) => { @@ -119,7 +124,7 @@ public async Task MapEndpoint_CanBeFiltered_ByEndpointFilters(Func(Assert.Single(invocationContext.Arguments)); // Ignore thre result and write filtered because we can! - await next(invocationContext); + _ = await next(invocationContext); return "filtered!"; }; }); @@ -138,6 +143,185 @@ public async Task MapEndpoint_CanBeFiltered_ByEndpointFilters(Func Task.CompletedTask; + + var endpointBuilder = builder.Map("/", initialRequestDelegate) + .AddEndpointFilter(new HttpContextArgFilter("First")) + .AddEndpointFilter(new HttpContextArgFilter("Second")); + + var dataSource = GetBuilderEndpointDataSource(builder); + var endpoint = Assert.Single(dataSource.Endpoints); + + Assert.NotSame(initialRequestDelegate, endpoint.RequestDelegate); + + Assert.NotNull(endpoint.RequestDelegate); + var requestDelegate = endpoint.RequestDelegate!; + await requestDelegate(httpContext); + + var responseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + + Assert.Equal("filtered!", responseBody); + Assert.Equal(1, (int)httpContext.Items["First-Order"]!); + Assert.Equal(2, (int)httpContext.Items["Second-Order"]!); + } + + [Fact] + public async Task MapEndpoint_Filtered_DontExecuteEndpointWhenErrorResponseStatus() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance)); + var httpContext = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + RequestDelegate initialRequestDelegate = static (context) => + { + context.Items["ExecutedEndpoint"] = true; + throw new Exception("Shouldn't reach here."); + }; + + var endpointBuilder = builder.Map("/", initialRequestDelegate) + .AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) => + { + return async invocationContext => + { + var httpContext = Assert.IsAssignableFrom(Assert.Single(invocationContext.Arguments)); + httpContext.Items["First"] = true; + httpContext.Response.StatusCode = 400; + return await next(invocationContext); + }; + }) + .AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) => + { + return invocationContext => + { + var httpContext = Assert.IsAssignableFrom(Assert.Single(invocationContext.Arguments)); + httpContext.Items["Second"] = true; + return next(invocationContext); + }; + }); + + var dataSource = GetBuilderEndpointDataSource(builder); + var endpoint = Assert.Single(dataSource.Endpoints); + + Assert.NotSame(initialRequestDelegate, endpoint.RequestDelegate); + + Assert.NotNull(endpoint.RequestDelegate); + var requestDelegate = endpoint.RequestDelegate!; + await requestDelegate(httpContext); + + Assert.True((bool)httpContext.Items["First"]!); + Assert.True((bool)httpContext.Items["Second"]!); + Assert.False(httpContext.Items.ContainsKey("ExecutedEndpoint")); + } + + [Fact] + public async Task RequestFilters_CanAssertOnEmptyResult() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance)); + var httpContext = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + var @delegate = (HttpContext context) => context.Items.Add("param", "Value"); + + object? response = null; + var endpointBuilder = builder.Map("/", @delegate) + .AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) => + { + return async invocationContext => + { + response = await next(invocationContext); + return response; + }; + }); + + var dataSource = GetBuilderEndpointDataSource(builder); + var endpoint = Assert.Single(dataSource.Endpoints); + + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "Tester" + }); + + await endpoint.RequestDelegate!(httpContext); + + Assert.IsType(response); + Assert.Same(Results.Empty, response); + } + + [Fact] + public async Task RequestFilters_ReturnValue_SerializeJson() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance)); + var httpContext = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + RequestDelegate requestDelegate = (HttpContext context) => Task.CompletedTask; + + var endpointBuilder = builder.Map("/", requestDelegate) + .AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) => + { + return async invocationContext => + { + await next(invocationContext); + return new MyCoolType(Name: "你好"); // serialized as JSON + }; + }); + + var dataSource = GetBuilderEndpointDataSource(builder); + var endpoint = Assert.Single(dataSource.Endpoints); + + await endpoint.RequestDelegate!(httpContext); + + var responseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + Assert.Equal(@"{""name"":""你好""}", responseBody); + } + + private record struct MyCoolType(string Name); + + private sealed class HttpContextArgFilter : IEndpointFilter + { + private readonly string _name; + + public HttpContextArgFilter(string name) + { + _name = name; + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + if (context.Arguments[0] is HttpContext httpContext) + { + int order; + if (httpContext.Items["CurrentOrder"] is int) + { + order = (int)httpContext.Items["CurrentOrder"]!; + order++; + httpContext.Items["CurrentOrder"] = order; + } + else + { + order = 1; + httpContext.Items["CurrentOrder"] = order; + } + httpContext.Items[$"{_name}-Order"] = order; + } + + // Ignore thre result and write filtered because we can! + _ = await next(context); + return "filtered!"; + } + } + [Theory] [MemberData(nameof(MapMethods))] public void MapEndpoint_UsesOriginalRequestDelegateInstance_IfFilterDoesNotChangePerRequestBehavior(Func map) @@ -318,6 +502,22 @@ public void Map_AddsMetadata_InCorrectOrder() m => Assert.IsAssignableFrom(m)); } + [Fact] + public void MapEndpoint_Filter() + { + // Arrange + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance)); + + // Act + var endpointBuilder = builder + .Map(RoutePatternFactory.Parse("/"), context => Task.CompletedTask) + .AddEndpointFilter(new HttpContextArgFilter("")); + + // Assert + var endpointBuilder1 = GetRouteEndpointBuilder(builder); + Assert.Equal("/", endpointBuilder1.RoutePattern.RawText); + } + [Attribute1] [Attribute2] private static Task Handle(HttpContext context) => Task.CompletedTask; diff --git a/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj b/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj index b184a2f3a01d..625d4361d03a 100644 --- a/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj +++ b/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs b/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs new file mode 100644 index 000000000000..7961796af105 --- /dev/null +++ b/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using System.Text.Json.Serialization.Metadata; +using System.Text.Json; + +namespace Microsoft.AspNetCore.Internal; + +internal static class ExecuteHandlerHelper +{ + public static Task ExecuteReturnAsync(object obj, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + { + // Terminal built ins + if (obj is IResult result) + { + return result.ExecuteAsync(httpContext); + } + else if (obj is string stringValue) + { + SetPlaintextContentType(httpContext); + return httpContext.Response.WriteAsync(stringValue); + } + else + { + // Otherwise, we JSON serialize when we reach the terminal state + return WriteJsonResponseAsync(httpContext.Response, obj, options, jsonTypeInfo); + } + } + + public static void SetPlaintextContentType(HttpContext httpContext) + { + httpContext.Response.ContentType ??= "text/plain; charset=utf-8"; + } + + public static Task WriteJsonResponseAsync(HttpResponse response, T? value, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + { + var runtimeType = value?.GetType(); + + if (runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.IsPolymorphicSafe()) + { + // In this case the polymorphism is not + // relevant for us and will be handled by STJ, if needed. + return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, jsonTypeInfo, default); + } + + // Call WriteAsJsonAsync() with the runtime type to serialize the runtime type rather than the declared type + // and avoid source generators issues. + // https://github.com/dotnet/aspnetcore/issues/43894 + // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism + var runtimeTypeInfo = options.GetTypeInfo(runtimeType); + return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, runtimeTypeInfo, default); + } +}