File: Builder\RequestDelegateEndpointRouteBuilderExtensionsTest.cs
Web Access
Project: src\src\Http\Routing\test\UnitTests\Microsoft.AspNetCore.Routing.Tests.csproj (Microsoft.AspNetCore.Routing.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable enable
 
using System.Linq.Expressions;
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;
 
public class RequestDelegateEndpointRouteBuilderExtensionsTest
{
    private EndpointDataSource GetBuilderEndpointDataSource(IEndpointRouteBuilder endpointRouteBuilder) =>
        Assert.Single(endpointRouteBuilder.DataSources);
 
    private RouteEndpointBuilder GetRouteEndpointBuilder(IEndpointRouteBuilder endpointRouteBuilder) =>
        GetBuilderEndpointDataSource(endpointRouteBuilder) switch
        {
            RouteEndpointDataSource routeDataSource => routeDataSource.GetSingleRouteEndpointBuilder(),
            _ => throw new InvalidOperationException($"Unknown EndointDataSource type!"),
        };
 
    public static object[][] MapMethods
    {
        get
        {
            IEndpointConventionBuilder MapGet(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
                routes.MapGet(template, action);
 
            IEndpointConventionBuilder MapPost(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
                routes.MapPost(template, action);
 
            IEndpointConventionBuilder MapPut(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
                routes.MapPut(template, action);
 
            IEndpointConventionBuilder MapDelete(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
                routes.MapDelete(template, action);
 
            IEndpointConventionBuilder Map(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
                routes.Map(template, action);
 
            return new object[][]
            {
                new object[] { (Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder>)MapGet },
                new object[] { (Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder>)MapPost },
                new object[] { (Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder>)MapPut },
                new object[] { (Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder>)MapDelete },
                new object[] { (Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder>)Map },
            };
        }
    }
 
    [Fact]
    public void MapEndpoint_StringPattern_BuildsEndpoint()
    {
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
        RequestDelegate requestDelegate = (d) => Task.CompletedTask;
 
        // Act
        var endpointBuilder = builder.Map("/", requestDelegate);
 
        // Assert
        var endpointBuilder1 = GetRouteEndpointBuilder(builder);
 
        Assert.Equal(requestDelegate, endpointBuilder1.RequestDelegate);
        Assert.Equal("/", endpointBuilder1.DisplayName);
        Assert.Equal("/", endpointBuilder1.RoutePattern.RawText);
    }
 
    [Theory]
    [MemberData(nameof(MapMethods))]
    public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder> map)
    {
        var httpContext = new DefaultHttpContext();
        var responseBodyStream = new MemoryStream();
        httpContext.Response.Body = responseBodyStream;
 
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
        static async Task<string> GenericTypeTaskDelegate(HttpContext context)
        {
            await context.Response.WriteAsync("Response string text");
            return await Task.FromResult("String Test");
        }
 
        // Act
        var endpointBuilder = map(builder, "/", GenericTypeTaskDelegate);
 
        // Assert
        var dataSource = GetBuilderEndpointDataSource(builder);
        var endpoint = Assert.Single(dataSource.Endpoints); // Triggers build and construction of delegate
 
        Assert.NotNull(endpoint.RequestDelegate);
        var requestDelegate = endpoint.RequestDelegate!;
        await requestDelegate(httpContext);
 
        var responseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray());
 
        Assert.Equal("Response string text", responseBody);
    }
 
    [Theory]
    [MemberData(nameof(MapMethods))]
    public async Task MapEndpoint_CanBeFiltered_EndpointFilterFactory(Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder> map)
    {
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
        var httpContext = new DefaultHttpContext();
        var responseBodyStream = new MemoryStream();
        httpContext.Response.Body = responseBodyStream;
 
        RequestDelegate initialRequestDelegate = static (context) => Task.CompletedTask;
 
        var endpointBuilder = map(builder, "/", initialRequestDelegate).AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) =>
        {
            return async invocationContext =>
            {
                Assert.IsAssignableFrom<HttpContext>(Assert.Single(invocationContext.Arguments));
                // Ignore thre result and write filtered because we can!
                _ = await next(invocationContext);
                return "filtered!";
            };
        });
 
        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);
    }
 
    [Fact]
    public async Task MapEndpoint_CanBeFiltered_EndpointFilter()
    {
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
        var httpContext = new DefaultHttpContext();
        var responseBodyStream = new MemoryStream();
        httpContext.Response.Body = responseBodyStream;
 
        RequestDelegate initialRequestDelegate = static (context) => 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<HttpContext>(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<HttpContext>(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<string, StringValues>
        {
            ["name"] = "Tester"
        });
 
        await endpoint.RequestDelegate!(httpContext);
 
        Assert.IsType<EmptyHttpResult>(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"":""你好""}"{""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<object?> 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<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder> map)
    {
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
 
        RequestDelegate initialRequestDelegate = static (context) => Task.CompletedTask;
        var runCount = 0;
 
        var endpointBuilder = map(builder, "/", initialRequestDelegate).AddEndpointFilterFactory((routeHandlerContext, next) =>
        {
            runCount++;
            return next;
        });
 
        var dataSource = GetBuilderEndpointDataSource(builder);
        var endpoint = Assert.Single(dataSource.Endpoints);
 
        Assert.Same(initialRequestDelegate, endpoint.RequestDelegate);
        Assert.Equal(1, runCount);
    }
 
    [Fact]
    public void MapEndpoint_TypedPattern_BuildsEndpoint()
    {
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
        RequestDelegate requestDelegate = (d) => Task.CompletedTask;
 
        // Act
        var endpointBuilder = builder.Map(RoutePatternFactory.Parse("/"), requestDelegate);
 
        // Assert
        var endpointBuilder1 = GetRouteEndpointBuilder(builder);
 
        Assert.Equal(requestDelegate, endpointBuilder1.RequestDelegate);
        Assert.Equal("/", endpointBuilder1.DisplayName);
        Assert.Equal("/", endpointBuilder1.RoutePattern.RawText);
    }
 
    [Fact]
    public void MapEndpoint_AttributesCollectedAsMetadata()
    {
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
 
        // Act
        var endpointBuilder = builder.Map(RoutePatternFactory.Parse("/"), Handle);
 
        // Assert
        var endpointBuilder1 = GetRouteEndpointBuilder(builder);
        Assert.Equal("/", endpointBuilder1.RoutePattern.RawText);
        Assert.Equal(2, endpointBuilder1.Metadata.Count);
        Assert.IsType<Attribute1>(endpointBuilder1.Metadata[0]);
        Assert.IsType<Attribute2>(endpointBuilder1.Metadata[1]);
    }
 
    [Fact]
    public void MapEndpoint_GeneratedDelegateWorks()
    {
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
 
        Expression<RequestDelegate> handler = context => Task.CompletedTask;
 
        // Act
        var endpointBuilder = builder.Map(RoutePatternFactory.Parse("/"), handler.Compile());
 
        // Assert
        var endpointBuilder1 = GetRouteEndpointBuilder(builder);
        Assert.Equal("/", endpointBuilder1.RoutePattern.RawText);
    }
 
    [Fact]
    public void MapEndpoint_PrecedenceOfMetadata_BuilderMetadataReturned()
    {
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
 
        // Act
        var endpointBuilder = builder.MapMethods("/", new[] { "METHOD" }, HandleHttpMetdata);
        endpointBuilder.WithMetadata(new HttpMethodMetadata(new[] { "BUILDER" }));
 
        // Assert
        var dataSource = Assert.Single(builder.DataSources);
        var endpoint = Assert.Single(dataSource.Endpoints);
 
        // As with the Delegate Map method overloads for route handlers, the attributes on the RequestDelegate
        // can override the HttpMethodMetadata. Extension methods could already do this.
        Assert.Equal(4, endpoint.Metadata.Count);
        Assert.Equal("METHOD", GetMethod(endpoint.Metadata[0]));
        Assert.Equal("ATTRIBUTE", GetMethod(endpoint.Metadata[1]));
        Assert.Equal("BUILDER", GetMethod(endpoint.Metadata[2]));
        Assert.IsAssignableFrom<IRouteDiagnosticsMetadata>(endpoint.Metadata[3]);
 
        Assert.Equal("BUILDER", endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods.Single());
 
        string GetMethod(object metadata)
        {
            var httpMethodMetadata = Assert.IsAssignableFrom<IHttpMethodMetadata>(metadata);
            return Assert.Single(httpMethodMetadata.HttpMethods);
        }
    }
 
    [Theory]
    [MemberData(nameof(MapMethods))]
    public void Map_EndpointMetadataNotDuplicated(Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder> map)
    {
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
 
        // Act
        var endpointBuilder = map(builder, "/", context => Task.CompletedTask).WithMetadata(new EndpointNameMetadata("MapMe"));
 
        // Assert
        var ds = GetBuilderEndpointDataSource(builder);
 
        _ = ds.Endpoints;
        _ = ds.Endpoints;
        _ = ds.Endpoints;
 
        Assert.Single(ds.Endpoints);
        var endpoint = ds.Endpoints.Single();
 
        Assert.Single(endpoint.Metadata.GetOrderedMetadata<IEndpointNameMetadata>());
    }
 
    [Theory]
    [MemberData(nameof(MapMethods))]
    public void AddingMetadataAfterBuildingEndpointThrows(Func<IEndpointRouteBuilder, string, RequestDelegate, IEndpointConventionBuilder> map)
    {
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
 
        // Act
        var endpointBuilder = map(builder, "/", context => Task.CompletedTask).WithMetadata(new EndpointNameMetadata("MapMe"));
 
        // Assert
        var ds = GetBuilderEndpointDataSource(builder);
 
        var endpoint = Assert.Single(ds.Endpoints);
 
        Assert.Single(endpoint.Metadata.GetOrderedMetadata<IEndpointNameMetadata>());
 
        Assert.Throws<InvalidOperationException>(() => endpointBuilder.WithMetadata(new RouteNameMetadata("Foo")));
        Assert.Throws<InvalidOperationException>(() => endpointBuilder.Finally(b => b.Metadata.Add(new RouteNameMetadata("Foo"))));
    }
 
    [Fact]
    public void Map_AddsMetadata_InCorrectOrder()
    {
        // Arrange
        var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
        var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new AddsCustomEndpointMetadataResult();
 
        // Act
        builder.Map("/test", @delegate);
 
        // Assert
        var ds = GetBuilderEndpointDataSource(builder);
        var endpoint = Assert.Single(ds.Endpoints);
        var metadata = endpoint.Metadata;
 
        Assert.Collection(metadata,
            m => Assert.IsAssignableFrom<MethodInfo>(m),
            m => Assert.IsAssignableFrom<ParameterNameMetadata>(m),
            m =>
            {
                Assert.IsAssignableFrom<CustomEndpointMetadata>(m);
                Assert.Equal(MetadataSource.Parameter, ((CustomEndpointMetadata)m).Source);
            },
            m =>
            {
                Assert.IsAssignableFrom<CustomEndpointMetadata>(m);
                Assert.Equal(MetadataSource.ReturnType, ((CustomEndpointMetadata)m).Source);
            },
            m => Assert.Equal("System.Runtime.CompilerServices.NullableContextAttribute", m.ToString()),
            m => Assert.IsAssignableFrom<Attribute1>(m),
            m => Assert.IsAssignableFrom<Attribute2>(m),
            m => Assert.IsAssignableFrom<IRouteDiagnosticsMetadata>(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;
 
    [HttpMethod("ATTRIBUTE")]
    private static Task HandleHttpMetdata(HttpContext context) => Task.CompletedTask;
 
    private class HttpMethodAttribute : Attribute, IHttpMethodMetadata
    {
        public bool AcceptCorsPreflight => false;
 
        public IReadOnlyList<string> HttpMethods { get; }
 
        public HttpMethodAttribute(params string[] httpMethods)
        {
            HttpMethods = httpMethods;
        }
    }
 
    private class Attribute1 : Attribute
    {
    }
 
    private class Attribute2 : Attribute
    {
    }
 
    private class AddsCustomEndpointMetadataResult : IEndpointMetadataProvider, IResult
    {
        public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder)
        {
            builder.Metadata.Add(new CustomEndpointMetadata { Source = MetadataSource.ReturnType });
        }
 
        public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException();
    }
 
    private class AddsCustomParameterMetadata : IEndpointParameterMetadataProvider, IEndpointMetadataProvider
    {
        public static ValueTask<AddsCustomParameterMetadata?> BindAsync(HttpContext context, ParameterInfo parameter) => default;
 
        public static void PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder)
        {
            builder.Metadata.Add(new ParameterNameMetadata { Name = parameter.Name ?? string.Empty });
        }
 
        public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder)
        {
            builder.Metadata.Add(new CustomEndpointMetadata { Source = MetadataSource.Parameter });
        }
    }
 
    private class ParameterNameMetadata
    {
        public string Name { get; init; } = string.Empty;
    }
 
    private class CustomEndpointMetadata
    {
        public string Data { get; init; } = string.Empty;
 
        public MetadataSource Source { get; init; }
    }
 
    private enum MetadataSource
    {
        Parameter,
        ReturnType
    }
 
    private sealed class EmptyServiceProvider : IServiceProvider
    {
        public static EmptyServiceProvider Instance { get; } = new EmptyServiceProvider();
        public object? GetService(Type serviceType) => null;
    }
}