From f395842c3ecb44866fda9caea3246b4b112d9517 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 8 Feb 2021 17:16:24 -0800 Subject: [PATCH 01/17] MapAction MVP (WIP) --- AspNetCore.sln | 18 + .../src/Api/IFromBodyMetadata.cs | 12 + .../src/Api/IFromRouteMetadata.cs | 16 + src/Http/Http.Abstractions/src/Api/IResult.cs | 20 + src/Http/HttpAbstractions.slnf | 57 +-- .../MapActionSample/MapActionSample.csproj | 16 + .../samples/MapActionSample/Program.cs | 26 ++ .../Properties/launchSettings.json | 13 + .../samples/MapActionSample/Startup.cs | 49 +++ .../Routing/samples/MapActionSample/Todo.cs | 9 + .../appsettings.Development.json | 9 + .../samples/MapActionSample/appsettings.json | 10 + .../Routing/src}/IRouteTemplateProvider.cs | 0 ...MapActionEndpointRouteBuilderExtensions.cs | 96 +++++ .../MapActionExpressionTreeBuilder.cs | 393 ++++++++++++++++++ .../src/Microsoft.AspNetCore.Routing.csproj | 5 + .../test/FunctionalTests/MapActionTest.cs | 93 +++++ ...ctionEndpointRouteBuilderExtensionsTest.cs | 90 ++++ src/Mvc/Mvc.Core/src/AcceptVerbsAttribute.cs | 14 +- src/Mvc/Mvc.Core/src/FromBodyAttribute.cs | 4 +- src/Mvc/Mvc.Core/src/FromRouteAttribute.cs | 8 +- src/Mvc/Mvc.Core/src/JsonResult.cs | 14 +- .../Mvc.Core/src/Properties/AssemblyInfo.cs | 2 + .../src/Routing/HttpMethodAttribute.cs | 14 +- src/Mvc/Mvc.Core/src/StatusCodeResult.cs | 25 +- src/Mvc/Mvc.slnf | 202 ++++----- 26 files changed, 1072 insertions(+), 143 deletions(-) create mode 100644 src/Http/Http.Abstractions/src/Api/IFromBodyMetadata.cs create mode 100644 src/Http/Http.Abstractions/src/Api/IFromRouteMetadata.cs create mode 100644 src/Http/Http.Abstractions/src/Api/IResult.cs create mode 100644 src/Http/Routing/samples/MapActionSample/MapActionSample.csproj create mode 100644 src/Http/Routing/samples/MapActionSample/Program.cs create mode 100644 src/Http/Routing/samples/MapActionSample/Properties/launchSettings.json create mode 100644 src/Http/Routing/samples/MapActionSample/Startup.cs create mode 100644 src/Http/Routing/samples/MapActionSample/Todo.cs create mode 100644 src/Http/Routing/samples/MapActionSample/appsettings.Development.json create mode 100644 src/Http/Routing/samples/MapActionSample/appsettings.json rename src/{Mvc/Mvc.Core/src/Routing => Http/Routing/src}/IRouteTemplateProvider.cs (100%) create mode 100644 src/Http/Routing/src/MapAction/MapActionEndpointRouteBuilderExtensions.cs create mode 100644 src/Http/Routing/src/MapAction/MapActionExpressionTreeBuilder.cs create mode 100644 src/Http/Routing/test/FunctionalTests/MapActionTest.cs create mode 100644 src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs diff --git a/AspNetCore.sln b/AspNetCore.sln index 86cea0758faf..44be86231669 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1572,6 +1572,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BrowserTesting", "BrowserTe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.BrowserTesting", "src\Shared\BrowserTesting\src\Microsoft.AspNetCore.BrowserTesting.csproj", "{B739074E-6652-4F5B-B37E-775DC2245FEC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{722E5A66-D84A-4689-AA87-7197FF5D7070}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MapActionSample", "src\Http\Routing\samples\MapActionSample\MapActionSample.csproj", "{8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -7451,6 +7455,18 @@ Global {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x64.Build.0 = Release|Any CPU {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x86.ActiveCfg = Release|Any CPU {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x86.Build.0 = Release|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|x64.Build.0 = Debug|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Debug|x86.Build.0 = Debug|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Release|Any CPU.Build.0 = Release|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Release|x64.ActiveCfg = Release|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Release|x64.Build.0 = Release|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Release|x86.ActiveCfg = Release|Any CPU + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -8227,6 +8243,8 @@ Global {22EA0993-8DFC-40C2-8481-8E85E21EFB56} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} {8F33439F-5532-45D6-8A44-20EF9104AA9D} = {5F0044F2-4C66-46A8-BD79-075F001AA034} {B739074E-6652-4F5B-B37E-775DC2245FEC} = {8F33439F-5532-45D6-8A44-20EF9104AA9D} + {722E5A66-D84A-4689-AA87-7197FF5D7070} = {54C42F57-5447-4C21-9812-4AF665567566} + {8F510BAA-FA6B-4648-8F98-28DF5C69DBB2} = {722E5A66-D84A-4689-AA87-7197FF5D7070} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/Http/Http.Abstractions/src/Api/IFromBodyMetadata.cs b/src/Http/Http.Abstractions/src/Api/IFromBodyMetadata.cs new file mode 100644 index 000000000000..bbccb04d8156 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Api/IFromBodyMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Api +{ + /// + /// Interface marking attributes that specify a parameter or property should be bound using the request body. + /// + public interface IFromBodyMetadata + { + } +} diff --git a/src/Http/Http.Abstractions/src/Api/IFromRouteMetadata.cs b/src/Http/Http.Abstractions/src/Api/IFromRouteMetadata.cs new file mode 100644 index 000000000000..68576ba07452 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Api/IFromRouteMetadata.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Api +{ + /// + /// Interface marking attributes that specify a parameter or property should be bound using route-data from the current request. + /// + public interface IFromRouteMetadata + { + /// + /// The name. + /// + string Name { get; } + } +} diff --git a/src/Http/Http.Abstractions/src/Api/IResult.cs b/src/Http/Http.Abstractions/src/Api/IResult.cs new file mode 100644 index 000000000000..48da8c4ec453 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Api/IResult.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Http.Api +{ + /// + /// Defines a contract that represents the result of an HTTP endpoint. + /// + public interface IResult + { + /// + /// Write an HTTP response reflecting the result. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + Task ExecuteAsync(HttpContext httpContext); + } +} diff --git a/src/Http/HttpAbstractions.slnf b/src/Http/HttpAbstractions.slnf index 7c36d355ae50..61dd3673f847 100644 --- a/src/Http/HttpAbstractions.slnf +++ b/src/Http/HttpAbstractions.slnf @@ -1,53 +1,54 @@ -{ +{ "solution": { "path": "..\\..\\AspNetCore.sln", - "projects" : [ + "projects": [ + "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", + "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj", + "src\\Hosting\\Server.Abstractions\\src\\Microsoft.AspNetCore.Hosting.Server.Abstractions.csproj", + "src\\Hosting\\TestHost\\src\\Microsoft.AspNetCore.TestHost.csproj", "src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj", "src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj", "src\\Http\\Authentication.Core\\test\\Microsoft.AspNetCore.Authentication.Core.Test.csproj", "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj", "src\\Http\\Headers\\test\\Microsoft.Net.Http.Headers.Tests.csproj", - "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", - "src\\Http\\Http\\test\\Microsoft.AspNetCore.Http.Tests.csproj", "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj", "src\\Http\\Http.Abstractions\\test\\Microsoft.AspNetCore.Http.Abstractions.Tests.csproj", "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj", "src\\Http\\Http.Extensions\\test\\Microsoft.AspNetCore.Http.Extensions.Tests.csproj", "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj", "src\\Http\\Http.Features\\test\\Microsoft.AspNetCore.Http.Features.Tests.csproj", + "src\\Http\\Http\\perf\\Microsoft.AspNetCore.Http.Performance.csproj", + "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", + "src\\Http\\Http\\test\\Microsoft.AspNetCore.Http.Tests.csproj", + "src\\Http\\Metadata\\src\\Microsoft.AspNetCore.Metadata.csproj", "src\\Http\\Owin\\src\\Microsoft.AspNetCore.Owin.csproj", "src\\Http\\Owin\\test\\Microsoft.AspNetCore.Owin.Tests.csproj", - "src\\Http\\samples\\SampleApp\\HttpAbstractions.SampleApp.csproj", - "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj", - "src\\Http\\WebUtilities\\test\\Microsoft.AspNetCore.WebUtilities.Tests.csproj", - "src\\Http\\Http\\perf\\Microsoft.AspNetCore.Http.Performance.csproj", + "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", + "src\\Http\\Routing.Abstractions\\test\\Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests.csproj", "src\\Http\\Routing\\perf\\Microsoft.AspNetCore.Routing.Performance.csproj", + "src\\Http\\Routing\\samples\\MapActionSample\\MapActionSample.csproj", "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", "src\\Http\\Routing\\test\\FunctionalTests\\Microsoft.AspNetCore.Routing.FunctionalTests.csproj", "src\\Http\\Routing\\test\\UnitTests\\Microsoft.AspNetCore.Routing.Tests.csproj", - "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", - "src\\Http\\Routing.Abstractions\\test\\Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests.csproj", - "src\\Hosting\\TestHost\\src\\Microsoft.AspNetCore.TestHost.csproj", - "src\\Http\\Routing\\test\\testassets\\RoutingWebSite\\RoutingWebSite.csproj", - "src\\Http\\Routing\\test\\testassets\\RoutingSandbox\\RoutingSandbox.csproj", "src\\Http\\Routing\\test\\testassets\\Benchmarks\\Benchmarks.csproj", - "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", - "src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj", - "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", - "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj", - "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", - "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", - "src\\Hosting\\Server.Abstractions\\src\\Microsoft.AspNetCore.Hosting.Server.Abstractions.csproj", - "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj", - "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", - "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", + "src\\Http\\Routing\\test\\testassets\\RoutingSandbox\\RoutingSandbox.csproj", + "src\\Http\\Routing\\test\\testassets\\RoutingWebSite\\RoutingWebSite.csproj", "src\\Http\\WebUtilities\\perf\\Microsoft.AspNetCore.WebUtilities.Performance\\Microsoft.AspNetCore.WebUtilities.Performance.csproj", - "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", + "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj", + "src\\Http\\WebUtilities\\test\\Microsoft.AspNetCore.WebUtilities.Tests.csproj", + "src\\Http\\samples\\SampleApp\\HttpAbstractions.SampleApp.csproj", "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", - "src\\Http\\Metadata\\src\\Microsoft.AspNetCore.Metadata.csproj", + "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", + "src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj", "src\\ObjectPool\\src\\Microsoft.Extensions.ObjectPool.csproj", - "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj", - "src\\Testing\\src\\Microsoft.AspNetCore.Testing.csproj" + "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", + "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", + "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", + "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", + "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", + "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj", + "src\\Testing\\src\\Microsoft.AspNetCore.Testing.csproj", + "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Http/Routing/samples/MapActionSample/MapActionSample.csproj b/src/Http/Routing/samples/MapActionSample/MapActionSample.csproj new file mode 100644 index 000000000000..6b59d1446b9b --- /dev/null +++ b/src/Http/Routing/samples/MapActionSample/MapActionSample.csproj @@ -0,0 +1,16 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + + diff --git a/src/Http/Routing/samples/MapActionSample/Program.cs b/src/Http/Routing/samples/MapActionSample/Program.cs new file mode 100644 index 000000000000..0e33ff8e1f6f --- /dev/null +++ b/src/Http/Routing/samples/MapActionSample/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace HttpApiSampleApp +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/Http/Routing/samples/MapActionSample/Properties/launchSettings.json b/src/Http/Routing/samples/MapActionSample/Properties/launchSettings.json new file mode 100644 index 000000000000..af085af38a7e --- /dev/null +++ b/src/Http/Routing/samples/MapActionSample/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "HttpApiSampleApp": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Http/Routing/samples/MapActionSample/Startup.cs b/src/Http/Routing/samples/MapActionSample/Startup.cs new file mode 100644 index 000000000000..2efb7dd71e5b --- /dev/null +++ b/src/Http/Routing/samples/MapActionSample/Startup.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HttpApiSampleApp +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices(IServiceCollection services) + { + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + [HttpPost("/EchoTodo")] + JsonResult EchoTodo([FromBody] Todo todo) => new(todo); + + endpoints.MapAction((Func)EchoTodo); + + endpoints.MapPost("/EchoTodoProto", async httpContext => + { + var todo = await httpContext.Request.ReadFromJsonAsync(); + await httpContext.Response.WriteAsJsonAsync(todo); + }); + + endpoints.MapGet("/", async context => + { + await context.Response.WriteAsync("Hello World!"); + }); + }); + } + } +} diff --git a/src/Http/Routing/samples/MapActionSample/Todo.cs b/src/Http/Routing/samples/MapActionSample/Todo.cs new file mode 100644 index 000000000000..2bcc698c8e5a --- /dev/null +++ b/src/Http/Routing/samples/MapActionSample/Todo.cs @@ -0,0 +1,9 @@ +namespace HttpApiSampleApp +{ + public class Todo + { + public int Id { get; set; } + public string Name { get; set; } + public bool IsComplete { get; set; } + } +} diff --git a/src/Http/Routing/samples/MapActionSample/appsettings.Development.json b/src/Http/Routing/samples/MapActionSample/appsettings.Development.json new file mode 100644 index 000000000000..8983e0fc1c5e --- /dev/null +++ b/src/Http/Routing/samples/MapActionSample/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/Http/Routing/samples/MapActionSample/appsettings.json b/src/Http/Routing/samples/MapActionSample/appsettings.json new file mode 100644 index 000000000000..d9d9a9bff6fd --- /dev/null +++ b/src/Http/Routing/samples/MapActionSample/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Mvc/Mvc.Core/src/Routing/IRouteTemplateProvider.cs b/src/Http/Routing/src/IRouteTemplateProvider.cs similarity index 100% rename from src/Mvc/Mvc.Core/src/Routing/IRouteTemplateProvider.cs rename to src/Http/Routing/src/IRouteTemplateProvider.cs diff --git a/src/Http/Routing/src/MapAction/MapActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/MapAction/MapActionEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000000..1dd4b5d57395 --- /dev/null +++ b/src/Http/Routing/src/MapAction/MapActionEndpointRouteBuilderExtensions.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.MapAction; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Provides extension methods for to define HTTP API endpoints. + /// + public static class MapActionEndpointRouteBuilderExtensions + { + /// + /// Adds a to the that matches the pattern specified via attributes. + /// + /// The to add the route to. + /// The delegate executed when the endpoint is matched. + /// A that can be used to further customize the endpoint. + public static IEndpointConventionBuilder MapAction( + this IEndpointRouteBuilder endpoints, + Delegate action) + { + if (endpoints is null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate(action); + + var routeAttributes = action.Method.GetCustomAttributes().OfType(); + var conventionBuilders = new List(); + + const int defaultOrder = 0; + + foreach (var routeAttribute in routeAttributes) + { + if (routeAttribute.Template is null) + { + continue; + } + + var conventionBuilder = endpoints.Map(routeAttribute.Template, requestDelegate); + + conventionBuilder.Add(endpointBuilder => + { + foreach (var attribute in action.Method.GetCustomAttributes()) + { + endpointBuilder.Metadata.Add(attribute); + } + + endpointBuilder.DisplayName = routeAttribute.Name ?? routeAttribute.Template; + + ((RouteEndpointBuilder)endpointBuilder).Order = routeAttribute.Order ?? defaultOrder; + }); + + conventionBuilders.Add(conventionBuilder); + } + + if (conventionBuilders.Count == 0) + { + throw new InvalidOperationException("Action must have a pattern. Is it missing a Route attribute?"); + } + + return new CompositeEndpointConventionBuilder(conventionBuilders); + } + + private class CompositeEndpointConventionBuilder : IEndpointConventionBuilder + { + private readonly List _endpointConventionBuilders; + + public CompositeEndpointConventionBuilder(List endpointConventionBuilders) + { + _endpointConventionBuilders = endpointConventionBuilders; + } + + public void Add(Action convention) + { + foreach (var endpointConventionBuilder in _endpointConventionBuilders) + { + endpointConventionBuilder.Add(convention); + } + } + } + } +} diff --git a/src/Http/Routing/src/MapAction/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/MapAction/MapActionExpressionTreeBuilder.cs new file mode 100644 index 000000000000..c332b8533fa2 --- /dev/null +++ b/src/Http/Routing/src/MapAction/MapActionExpressionTreeBuilder.cs @@ -0,0 +1,393 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Api; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Routing.MapAction +{ + internal class MapActionExpressionTreeBuilder + { + private static readonly MethodInfo ChangeTypeMethodInfo = GetMethodInfo>((value, type) => Convert.ChangeType(value, type, CultureInfo.InvariantCulture)); + private static readonly MethodInfo ExecuteTaskOfTMethodInfo = typeof(MapActionExpressionTreeBuilder).GetMethod(nameof(ExecuteTask), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo ExecuteValueTaskOfTMethodInfo = typeof(MapActionExpressionTreeBuilder).GetMethod(nameof(ExecuteValueTask), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo ExecuteTaskResultOfTMethodInfo = typeof(MapActionExpressionTreeBuilder).GetMethod(nameof(ExecuteTaskResult), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo ExecuteValueResultTaskOfTMethodInfo = typeof(MapActionExpressionTreeBuilder).GetMethod(nameof(ExecuteValueTaskResult), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo GetRequiredServiceMethodInfo = typeof(ServiceProviderServiceExtensions).GetMethod(nameof(ServiceProviderServiceExtensions.GetRequiredService), BindingFlags.Public | BindingFlags.Static, new Type[] { typeof(IServiceProvider) })!; + private static readonly MethodInfo ResultWriteResponseAsync = typeof(IResult).GetMethod(nameof(IResult.ExecuteAsync), BindingFlags.Public | BindingFlags.Instance)!; + private static readonly MethodInfo StringResultWriteResponseAsync = GetMethodInfo>((response, text) => HttpResponseWritingExtensions.WriteAsync(response, text, default)); + private static readonly MethodInfo JsonResultWriteResponseAsync = GetMethodInfo>((response, value) => HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, default)); + private static readonly MemberInfo CompletedTaskMemberInfo = GetMemberInfo>(() => Task.CompletedTask); + + private static readonly ParameterExpression TargetArg = Expression.Parameter(typeof(object), "target"); + private static readonly ParameterExpression HttpContextParameter = Expression.Parameter(typeof(HttpContext), "httpContext"); + private static readonly ParameterExpression DeserializedBodyArg = Expression.Parameter(typeof(object), "bodyValue"); + + private static readonly MemberExpression RequestServicesExpr = Expression.Property(HttpContextParameter, nameof(HttpContext.RequestServices)); + private static readonly MemberExpression HttpRequestExpr = Expression.Property(HttpContextParameter, nameof(HttpContext.Request)); + private static readonly MemberExpression HttpResponseExpr = Expression.Property(HttpContextParameter, nameof(HttpContext.Response)); + + public static RequestDelegate BuildRequestDelegate(Delegate action) + { + // Non void return type + + // Task Invoke(HttpContext httpContext) + // { + // // Action parameters are bound from the request, services, etc... based on attribute and type information. + // return ExecuteTask(action(...), httpContext); + // } + + // void return type + + // Task Invoke(HttpContext httpContext) + // { + // action(...); + // return default; + // } + + var method = action.Method; + + var needForm = false; + var needBody = false; + Type? bodyType = null; + + // This argument represents the deserialized body returned from IHttpRequestReader + // when the method has a FromBody attribute declared + + var args = new List(); + + foreach (var parameter in method.GetParameters()) + { + Expression paramterExpression = Expression.Default(parameter.ParameterType); + + //if (parameter.FromQuery != null) + //{ + // var queryProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.Query)); + // paramterExpression = BindParamenter(queryProperty, parameter, parameter.FromQuery); + //} + //else if (parameter.FromHeader != null) + //{ + // var headersProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.Headers)); + // paramterExpression = BindParamenter(headersProperty, parameter, parameter.FromHeader); + //} + //else if (parameter.FromRoute != null) + //{ + // var routeValuesProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.RouteValues)); + // paramterExpression = BindParamenter(routeValuesProperty, parameter, parameter.FromRoute); + //} + //else if (parameter.FromCookie != null) + //{ + // var cookiesProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.Cookies)); + // paramterExpression = BindParamenter(cookiesProperty, parameter, parameter.FromCookie); + //} + //else if (parameter.FromServices) + //{ + // paramterExpression = Expression.Call(GetRequiredServiceMethodInfo.MakeGenericMethod(parameter.ParameterType), requestServicesExpr); + //} + //else if (parameter.FromForm != null) + //{ + // needForm = true; + + // var formProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.Form)); + // paramterExpression = BindParamenter(formProperty, parameter, parameter.FromForm); + //} + //else if (parameter.FromBody) + if (parameter.CustomAttributes.Any(a => typeof(IFromBodyMetadata).IsAssignableFrom(a.AttributeType))) + { + if (needBody) + { + throw new InvalidOperationException("Action cannot have more than one FromBody attribute."); + } + + if (needForm) + { + throw new InvalidOperationException("Action cannot mix FromBody and FromForm on the same method."); + } + + needBody = true; + bodyType = parameter.ParameterType; + paramterExpression = Expression.Convert(DeserializedBodyArg, bodyType); + } + else + { + if (parameter.ParameterType == typeof(IFormCollection)) + { + needForm = true; + + paramterExpression = Expression.Property(HttpRequestExpr, nameof(HttpRequest.Form)); + } + else if (parameter.ParameterType == typeof(HttpContext)) + { + paramterExpression = HttpContextParameter; + } + } + + args.Add(paramterExpression); + } + + Expression? body = null; + + MethodCallExpression methodCall; + + if (action.Target is null) + { + methodCall = Expression.Call(method, args); + } + else + { + var castedTarget = Expression.Convert(TargetArg, action.Target.GetType()); + methodCall = Expression.Call(castedTarget, method, args); + } + + // Exact request delegate match + if (method.ReturnType == typeof(void)) + { + var bodyExpressions = new List + { + methodCall, + Expression.Property(null, (PropertyInfo)CompletedTaskMemberInfo) + }; + + body = Expression.Block(bodyExpressions); + } + else if (AwaitableInfo.IsTypeAwaitable(method.ReturnType, out var info)) + { + if (method.ReturnType == typeof(Task)) + { + body = methodCall; + } + else if (method.ReturnType.IsGenericType && + method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var typeArg = method.ReturnType.GetGenericArguments()[0]; + + if (typeof(IResult).IsAssignableFrom(typeArg)) + { + body = Expression.Call( + ExecuteTaskResultOfTMethodInfo.MakeGenericMethod(typeArg), + methodCall, + TargetArg, + HttpContextParameter); + } + else + { + // ExecuteTask(action(..), httpContext); + body = Expression.Call( + ExecuteTaskOfTMethodInfo.MakeGenericMethod(typeArg), + methodCall, + TargetArg, + HttpContextParameter); + } + } + else if (method.ReturnType.IsGenericType && + method.ReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + var typeArg = method.ReturnType.GetGenericArguments()[0]; + + if (typeof(IResult).IsAssignableFrom(typeArg)) + { + body = Expression.Call( + ExecuteValueResultTaskOfTMethodInfo.MakeGenericMethod(typeArg), + methodCall, + TargetArg, + HttpContextParameter); + } + else + { + // ExecuteTask(action(..), httpContext); + body = Expression.Call( + ExecuteValueTaskOfTMethodInfo.MakeGenericMethod(typeArg), + methodCall, + TargetArg, + HttpContextParameter); + } + } + else + { + // TODO: Handle custom awaitables + throw new NotSupportedException($"Unsupported return type: {method.ReturnType}"); + } + } + else if (typeof(IResult).IsAssignableFrom(method.ReturnType)) + { + body = Expression.Call(methodCall, ResultWriteResponseAsync, HttpContextParameter); + } + else if (method.ReturnType == typeof(string)) + { + body = Expression.Call(StringResultWriteResponseAsync, HttpResponseExpr, methodCall, Expression.Constant(CancellationToken.None)); + } + else + { + body = Expression.Call(JsonResultWriteResponseAsync, HttpResponseExpr, methodCall, Expression.Constant(CancellationToken.None)); + } + + Func? requestDelegate = null; + + if (needBody) + { + // We need to generate the code for reading from the body before calling into the + // delegate + var lambda = Expression.Lambda>(body, TargetArg, HttpContextParameter, DeserializedBodyArg); + var invoker = lambda.Compile(); + + requestDelegate = async (target, httpContext) => + { + var bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType!); + + await invoker(target, httpContext, bodyValue); + }; + } + else if (needForm) + { + var lambda = Expression.Lambda>(body, TargetArg, HttpContextParameter); + var invoker = lambda.Compile(); + + requestDelegate = async (target, httpContext) => + { + // Generating async code would just be insane so if the method needs the form populate it here + // so the within the method it's cached + await httpContext.Request.ReadFormAsync(); + + await invoker(target, httpContext); + }; + } + else + { + var lambda = Expression.Lambda>(body, TargetArg, HttpContextParameter); + var invoker = lambda.Compile(); + + requestDelegate = invoker; + } + + return httpContext => + { + return requestDelegate(action.Target, httpContext); + }; + } + + private static Expression BindParamenter(Expression sourceExpression, ParameterInfo parameter, string name) + { + var key = name ?? parameter.Name; + var type = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + var valueArg = Expression.Convert( + Expression.MakeIndex(sourceExpression, + sourceExpression.Type.GetProperty("Item"), + new[] { Expression.Constant(key) }), + typeof(string)); + + MethodInfo parseMethod = (from m in type.GetMethods(BindingFlags.Public | BindingFlags.Static) + let parameters = m.GetParameters() + where m.Name == "Parse" && parameters.Length == 1 && parameters[0].ParameterType == typeof(string) + select m).FirstOrDefault()!; + + Expression? expr = null; + + if (parseMethod != null) + { + expr = Expression.Call(parseMethod, valueArg); + } + else if (parameter.ParameterType != valueArg.Type) + { + // Convert.ChangeType() + expr = Expression.Call(ChangeTypeMethodInfo, valueArg, Expression.Constant(type)); + } + else + { + expr = valueArg; + } + + if (expr.Type != parameter.ParameterType) + { + expr = Expression.Convert(expr, parameter.ParameterType); + } + + // property[key] == null ? default : (ParameterType){Type}.Parse(property[key]); + expr = Expression.Condition( + Expression.Equal(valueArg, Expression.Constant(null)), + Expression.Default(parameter.ParameterType), + expr); + + return expr; + } + + private static MethodInfo GetMethodInfo(Expression expr) + { + var mc = (MethodCallExpression)expr.Body; + return mc.Method; + } + + private static MemberInfo GetMemberInfo(Expression expr) + { + var mc = (MemberExpression)expr.Body; + return mc.Member; + } + + private static async ValueTask ExecuteTask(Task task, HttpContext httpContext) + { + await new JsonResult(await task).ExecuteAsync(httpContext); + } + + private static Task ExecuteValueTask(ValueTask task, HttpContext httpContext) + { + static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext) + { + await new JsonResult(await task).ExecuteAsync(httpContext); + } + + if (task.IsCompletedSuccessfully) + { + return new JsonResult(task.GetAwaiter().GetResult()).ExecuteAsync(httpContext); + } + + return ExecuteAwaited(task, httpContext); + } + + private static Task ExecuteValueTaskResult(ValueTask task, HttpContext httpContext) where T : IResult + { + static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext) + { + await (await task).ExecuteAsync(httpContext); + } + + if (task.IsCompletedSuccessfully) + { + return task.GetAwaiter().GetResult().ExecuteAsync(httpContext); + } + + return ExecuteAwaited(task, httpContext); + } + + private static async ValueTask ExecuteTaskResult(Task task, HttpContext httpContext) where T : IResult + { + await (await task).ExecuteAsync(httpContext); + } + + /// + /// Equivalent to the IResult part of Microsoft.AspNetCore.Mvc.JsonResult + /// + private class JsonResult : IResult + { + public object? Value { get; } + + public JsonResult(object? value) + { + Value = value; + } + + public Task ExecuteAsync(HttpContext httpContext) + { + return httpContext.Response.WriteAsJsonAsync(Value); + } + } + } +} diff --git a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj index e96f32ceddf5..3f6a759f09a9 100644 --- a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj +++ b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj @@ -24,6 +24,7 @@ Microsoft.AspNetCore.Routing.RouteCollection + @@ -37,4 +38,8 @@ Microsoft.AspNetCore.Routing.RouteCollection + + + + diff --git a/src/Http/Routing/test/FunctionalTests/MapActionTest.cs b/src/Http/Routing/test/FunctionalTests/MapActionTest.cs new file mode 100644 index 000000000000..dd4ca4f81adb --- /dev/null +++ b/src/Http/Routing/test/FunctionalTests/MapActionTest.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Api; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.FunctionalTests +{ + public class MapActionTest + { + [Fact] + public async Task MapAction_FromBodyWorksWithJsonPayload() + { + [HttpMethods(new[] { "POST" }, "/EchoTodo")] + Todo EchoTodo([FromBody] Todo todo) => todo; + + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(b => b.MapAction((Func)EchoTodo)); + }) + .UseTestServer(); + }) + .ConfigureServices(services => + { + services.AddAuthorization(); + services.AddRouting(); + }) + .Build(); + + using var server = host.GetTestServer(); + await host.StartAsync(); + var client = server.CreateClient(); + + var todo = new Todo + { + Name = "Write tests!" + }; + + var response = await client.PostAsJsonAsync("/EchoTodo", todo); + response.EnsureSuccessStatusCode(); + + var echoedTodo = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(todo.Name, echoedTodo?.Name); + } + + private class Todo + { + public int Id { get; set; } + public string Name { get; set; } = "Todo"; + public bool IsComplete { get; set; } + } + + private class FromBodyAttribute : Attribute, IFromBodyMetadata { } + + private class HttpMethodsAttribute : Attribute, IRouteTemplateProvider, IHttpMethodMetadata + { + public HttpMethodsAttribute(string[] httpMethods, string? template) + { + HttpMethods = httpMethods; + Template = template; + } + + public string? Template { get; } + + public IReadOnlyList HttpMethods { get; } + + public int? Order => null; + + public string? Name => null; + + public bool AcceptCorsPreflight => false; + } + } +} diff --git a/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs new file mode 100644 index 000000000000..866d6a64910b --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Builder +{ + public class MapActionEndpointDataSourceBuilderExtensionsTest + { + private ModelEndpointDataSource GetBuilderEndpointDataSource(IEndpointRouteBuilder endpointRouteBuilder) + { + return Assert.IsType(Assert.Single(endpointRouteBuilder.DataSources)); + } + + private RouteEndpointBuilder GetRouteEndpointBuilder(IEndpointRouteBuilder endpointRouteBuilder) + { + return Assert.IsType(Assert.Single(GetBuilderEndpointDataSource(endpointRouteBuilder).EndpointBuilders)); + } + + [Fact] + public void MapAction_BuildsEndpointFromAttributes() + { + const string customTemplate = "/CustomTemplate"; + const string customMethod = "CUSTOM_METHOD"; + + [HttpMethods(Template = customTemplate, Methods = new[] { customMethod })] + void TestAction() { }; + + var builder = new DefaultEndpointRouteBuilder(Mock.Of()); + _ = builder.MapAction((Action)TestAction); + + var routeEndpointBuilder = GetRouteEndpointBuilder(builder); + Assert.Equal(customTemplate, routeEndpointBuilder.RoutePattern.RawText); + + var dataSource = GetBuilderEndpointDataSource(builder); + var endpoint = Assert.Single(dataSource.Endpoints); + + var httpMethodMetadata = Assert.Single(endpoint.Metadata.OfType()); + var method = Assert.Single(httpMethodMetadata.HttpMethods); + Assert.Equal(customMethod, method); + } + + [Fact] + public void MapAction_BuildsEndpointWithRouteNameAndOrder() + { + const string customName = "Custom Name"; + const int customOrder = 1337; + + [HttpMethods(Name = customName, Order = customOrder)] + void TestAction() { }; + + var builder = new DefaultEndpointRouteBuilder(Mock.Of()); + _ = builder.MapAction((Action)TestAction); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var routeEndpointBuilder = GetRouteEndpointBuilder(builder); + Assert.Equal(customName, routeEndpointBuilder.DisplayName); + Assert.Equal(customOrder, routeEndpointBuilder.Order); + } + + private class HttpMethodsAttribute : Attribute, IHttpMethodMetadata, IRouteTemplateProvider + { + public string[] Methods { get; set; } = new[] { "GET" }; + + public string Template { get; set; } = "/"; + + public int Order { get; set; } + + public string? Name { get; set; } + + public bool AcceptCorsPreflight => false; + + IReadOnlyList IHttpMethodMetadata.HttpMethods => Methods; + + int? IRouteTemplateProvider.Order => Order; + } + } +} diff --git a/src/Mvc/Mvc.Core/src/AcceptVerbsAttribute.cs b/src/Mvc/Mvc.Core/src/AcceptVerbsAttribute.cs index 3c889c23205d..5ba2157094cb 100644 --- a/src/Mvc/Mvc.Core/src/AcceptVerbsAttribute.cs +++ b/src/Mvc/Mvc.Core/src/AcceptVerbsAttribute.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Mvc { @@ -12,8 +13,10 @@ namespace Microsoft.AspNetCore.Mvc /// Specifies what HTTP methods an action supports. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public sealed class AcceptVerbsAttribute : Attribute, IActionHttpMethodProvider, IRouteTemplateProvider + public sealed class AcceptVerbsAttribute : Attribute, IHttpMethodMetadata, IActionHttpMethodProvider, IRouteTemplateProvider { + private readonly List _httpMethods; + private int? _order; /// @@ -35,13 +38,16 @@ public AcceptVerbsAttribute(string method) /// The HTTP methods the action supports. public AcceptVerbsAttribute(params string[] methods) { - HttpMethods = methods.Select(method => method.ToUpperInvariant()); + _httpMethods = methods.Select(method => method.ToUpperInvariant()).ToList(); } /// /// Gets the HTTP methods the action supports. /// - public IEnumerable HttpMethods { get; } + public IEnumerable HttpMethods => _httpMethods; + + IReadOnlyList IHttpMethodMetadata.HttpMethods => _httpMethods; + bool IHttpMethodMetadata.AcceptCorsPreflight => false; /// /// The route template. May be null. @@ -69,4 +75,4 @@ public int Order /// public string Name { get; set; } } -} \ No newline at end of file +} diff --git a/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs b/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs index 9894666c7de9..71c0f61c736b 100644 --- a/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs @@ -3,6 +3,7 @@ using System; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Http.Api; namespace Microsoft.AspNetCore.Mvc { @@ -10,7 +11,7 @@ namespace Microsoft.AspNetCore.Mvc /// Specifies that a parameter or property should be bound using the request body. /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] - public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEmptyBodyBehavior + public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEmptyBodyBehavior, IFromBodyMetadata { /// public BindingSource BindingSource => BindingSource.Body; @@ -23,6 +24,7 @@ public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEm /// The default behavior is to use framework defaults as configured by . /// Specifying or will override the framework defaults. /// + // REVIEW: What should we do about this? Type forward EmptyBodyBehavior? Write analyzers to warn against configuring this with MapAction? public EmptyBodyBehavior EmptyBodyBehavior { get; set; } } } diff --git a/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs b/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs index 94952e502f55..a0ea9151d7c1 100644 --- a/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Api; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -10,12 +12,14 @@ namespace Microsoft.AspNetCore.Mvc /// Specifies that a parameter or property should be bound using route-data from the current request. /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] - public class FromRouteAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider + public class FromRouteAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromRouteMetadata { /// public BindingSource BindingSource => BindingSource.Path; - /// + /// + /// The name. + /// public string Name { get; set; } } } diff --git a/src/Mvc/Mvc.Core/src/JsonResult.cs b/src/Mvc/Mvc.Core/src/JsonResult.cs index 6d43cc68323e..8cb094489b63 100644 --- a/src/Mvc/Mvc.Core/src/JsonResult.cs +++ b/src/Mvc/Mvc.Core/src/JsonResult.cs @@ -4,6 +4,8 @@ using System; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Api; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -12,7 +14,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// An action result which formats the given object as JSON. /// - public class JsonResult : ActionResult, IStatusCodeActionResult + public class JsonResult : ActionResult, IResult, IStatusCodeActionResult { /// /// Creates a new with the given . @@ -80,5 +82,15 @@ public override Task ExecuteResultAsync(ActionContext context) var executor = services.GetRequiredService>(); return executor.ExecuteAsync(context, this); } + + /// + /// Write the result as JSON to the HTTP response. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + Task IResult.ExecuteAsync(HttpContext httpContext) + { + return httpContext.Response.WriteAsJsonAsync(Value); + } } } diff --git a/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs b/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs index c217d6411aad..0103378199b6 100644 --- a/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs +++ b/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs @@ -3,8 +3,10 @@ using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.Routing; [assembly: TypeForwardedTo(typeof(InputFormatterException))] +[assembly: TypeForwardedTo(typeof(IRouteTemplateProvider))] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/Mvc.Core/src/Routing/HttpMethodAttribute.cs b/src/Mvc/Mvc.Core/src/Routing/HttpMethodAttribute.cs index 1c204f9cf470..dd0d25b6e4b9 100644 --- a/src/Mvc/Mvc.Core/src/Routing/HttpMethodAttribute.cs +++ b/src/Mvc/Mvc.Core/src/Routing/HttpMethodAttribute.cs @@ -6,6 +6,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Mvc.Routing { @@ -13,8 +15,10 @@ namespace Microsoft.AspNetCore.Mvc.Routing /// Identifies an action that supports a given set of HTTP methods. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public abstract class HttpMethodAttribute : Attribute, IActionHttpMethodProvider, IRouteTemplateProvider + public abstract class HttpMethodAttribute : Attribute, IHttpMethodMetadata, IActionHttpMethodProvider, IRouteTemplateProvider { + private readonly List _httpMethods; + private int? _order; /// @@ -40,12 +44,15 @@ public HttpMethodAttribute(IEnumerable httpMethods, string? template) throw new ArgumentNullException(nameof(httpMethods)); } - HttpMethods = httpMethods; + _httpMethods = httpMethods.ToList(); Template = template; } /// - public IEnumerable HttpMethods { get; } + public IEnumerable HttpMethods => _httpMethods; + + IReadOnlyList IHttpMethodMetadata.HttpMethods => _httpMethods; + bool IHttpMethodMetadata.AcceptCorsPreflight => false; /// public string? Template { get; } @@ -68,5 +75,6 @@ public int Order /// [DisallowNull] public string? Name { get; set; } + } } diff --git a/src/Mvc/Mvc.Core/src/StatusCodeResult.cs b/src/Mvc/Mvc.Core/src/StatusCodeResult.cs index 2f367817bdc0..c66340033d5f 100644 --- a/src/Mvc/Mvc.Core/src/StatusCodeResult.cs +++ b/src/Mvc/Mvc.Core/src/StatusCodeResult.cs @@ -2,6 +2,9 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Api; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -12,7 +15,7 @@ namespace Microsoft.AspNetCore.Mvc /// Represents an that when executed will /// produce an HTTP response with the given response status code. /// - public class StatusCodeResult : ActionResult, IClientErrorActionResult + public class StatusCodeResult : ActionResult, IResult, IClientErrorActionResult { /// /// Initializes a new instance of the class @@ -39,12 +42,28 @@ public override void ExecuteResult(ActionContext context) throw new ArgumentNullException(nameof(context)); } - var factory = context.HttpContext.RequestServices.GetRequiredService(); + Execute(context.HttpContext); + } + + /// + /// Sets the status code on the HTTP response. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + Task IResult.ExecuteAsync(HttpContext httpContext) + { + Execute(httpContext); + return Task.CompletedTask; + } + + private void Execute(HttpContext httpContext) + { + var factory = httpContext.RequestServices.GetRequiredService(); var logger = factory.CreateLogger(); logger.HttpStatusCodeResultExecuting(StatusCode); - context.HttpContext.Response.StatusCode = StatusCode; + httpContext.Response.StatusCode = StatusCode; } } } diff --git a/src/Mvc/Mvc.slnf b/src/Mvc/Mvc.slnf index 49b9606749bf..26363dc5d1db 100644 --- a/src/Mvc/Mvc.slnf +++ b/src/Mvc/Mvc.slnf @@ -1,87 +1,57 @@ -{ +{ "solution": { "path": "..\\..\\AspNetCore.sln", - "projects" : [ - "src\\Mvc\\test\\WebSites\\BasicWebSite\\BasicWebSite.csproj", - "src\\Mvc\\test\\WebSites\\RoutingWebSite\\Mvc.RoutingWebSite.csproj", - "src\\Mvc\\test\\WebSites\\RazorWebSite\\RazorWebSite.csproj", - "src\\Mvc\\test\\WebSites\\FormatterWebSite\\FormatterWebSite.csproj", - "src\\Mvc\\test\\WebSites\\ApiExplorerWebSite\\ApiExplorerWebSite.csproj", - "src\\Mvc\\test\\WebSites\\VersioningWebSite\\VersioningWebSite.csproj", - "src\\Mvc\\test\\WebSites\\TagHelpersWebSite\\TagHelpersWebSite.csproj", - "src\\Mvc\\test\\WebSites\\RoutingWebSite\\Mvc.RoutingWebSite.csproj", - "src\\Mvc\\test\\WebSites\\FilesWebSite\\FilesWebSite.csproj", - "src\\Mvc\\test\\WebSites\\ApplicationModelWebSite\\ApplicationModelWebSite.csproj", - "src\\Mvc\\test\\WebSites\\HtmlGenerationWebSite\\HtmlGenerationWebSite.csproj", - "src\\Mvc\\test\\WebSites\\ErrorPageMiddlewareWebSite\\ErrorPageMiddlewareWebSite.csproj", - "src\\Mvc\\test\\WebSites\\XmlFormattersWebSite\\XmlFormattersWebSite.csproj", - "src\\Mvc\\test\\WebSites\\ControllersFromServicesWebSite\\ControllersFromServicesWebSite.csproj", - "src\\Mvc\\test\\WebSites\\ControllersFromServicesClassLibrary\\ControllersFromServicesClassLibrary.csproj", - "src\\Mvc\\test\\WebSites\\CorsWebSite\\CorsWebSite.csproj", - "src\\Mvc\\samples\\MvcSandbox\\MvcSandbox.csproj", - "src\\Mvc\\test\\WebSites\\SimpleWebSite\\SimpleWebSite.csproj", - "src\\Mvc\\test\\WebSites\\SecurityWebSite\\SecurityWebSite.csproj", - "src\\Mvc\\test\\WebSites\\RazorPagesWebSite\\RazorPagesWebSite.csproj", - "src\\Mvc\\benchmarks\\Microsoft.AspNetCore.Mvc.Performance\\Microsoft.AspNetCore.Mvc.Performance.csproj", - "src\\Mvc\\test\\WebSites\\RazorBuildWebSite\\RazorBuildWebSite.csproj", - "src\\Mvc\\test\\WebSites\\RazorBuildWebSite.Views\\RazorBuildWebSite.Views.csproj", - "src\\Mvc\\Mvc.Analyzers\\src\\Microsoft.AspNetCore.Mvc.Analyzers.csproj", - "src\\Mvc\\Mvc.Analyzers\\test\\Mvc.Analyzers.Test.csproj", - "src\\Mvc\\test\\WebSites\\RazorPagesClassLibrary\\RazorPagesClassLibrary.csproj", - "src\\Mvc\\shared\\Mvc.Views.TestCommon\\Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj", - "src\\Mvc\\Mvc.Api.Analyzers\\test\\Mvc.Api.Analyzers.Test.csproj", - "src\\Mvc\\Mvc.Api.Analyzers\\src\\Microsoft.AspNetCore.Mvc.Api.Analyzers.csproj", - "src\\Html.Abstractions\\src\\Microsoft.AspNetCore.Html.Abstractions.csproj", - "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", - "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", + "projects": [ + "src\\Antiforgery\\src\\Microsoft.AspNetCore.Antiforgery.csproj", + "src\\Components\\Authorization\\src\\Microsoft.AspNetCore.Components.Authorization.csproj", + "src\\Components\\Components\\src\\Microsoft.AspNetCore.Components.csproj", + "src\\Components\\Forms\\src\\Microsoft.AspNetCore.Components.Forms.csproj", + "src\\Components\\Server\\src\\Microsoft.AspNetCore.Components.Server.csproj", + "src\\Components\\Web\\src\\Microsoft.AspNetCore.Components.Web.csproj", + "src\\DataProtection\\Abstractions\\src\\Microsoft.AspNetCore.DataProtection.Abstractions.csproj", + "src\\DataProtection\\Cryptography.Internal\\src\\Microsoft.AspNetCore.Cryptography.Internal.csproj", + "src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj", + "src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj", + "src\\Features\\JsonPatch\\src\\Microsoft.AspNetCore.JsonPatch.csproj", + "src\\FileProviders\\Embedded\\src\\Microsoft.Extensions.FileProviders.Embedded.csproj", + "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj", - "src\\Hosting\\TestHost\\src\\Microsoft.AspNetCore.TestHost.csproj", - "src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj", - "src\\Hosting\\Server.IntegrationTesting\\src\\Microsoft.AspNetCore.Server.IntegrationTesting.csproj", - "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj", - "src\\Middleware\\ResponseCaching\\src\\Microsoft.AspNetCore.ResponseCaching.csproj", - "src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj", - "src\\Middleware\\Session\\src\\Microsoft.AspNetCore.Session.csproj", - "src\\Middleware\\Localization.Routing\\src\\Microsoft.AspNetCore.Localization.Routing.csproj", - "src\\Razor\\Razor.Runtime\\src\\Microsoft.AspNetCore.Razor.Runtime.csproj", - "src\\Security\\Authentication\\Cookies\\src\\Microsoft.AspNetCore.Authentication.Cookies.csproj", - "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", - "src\\Security\\Authentication\\Core\\src\\Microsoft.AspNetCore.Authentication.csproj", - "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", - "src\\Security\\CookiePolicy\\src\\Microsoft.AspNetCore.CookiePolicy.csproj", - "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", - "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", - "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", - "src\\Security\\Authentication\\JwtBearer\\src\\Microsoft.AspNetCore.Authentication.JwtBearer.csproj", "src\\Hosting\\Server.Abstractions\\src\\Microsoft.AspNetCore.Hosting.Server.Abstractions.csproj", - "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", - "src\\Mvc\\benchmarks\\Microsoft.AspNetCore.Mvc.Performance.Views\\Microsoft.AspNetCore.Mvc.Performance.Views.csproj", - "src\\Features\\JsonPatch\\src\\Microsoft.AspNetCore.JsonPatch.csproj", - "src\\Middleware\\Localization\\src\\Microsoft.AspNetCore.Localization.csproj", - "src\\Antiforgery\\src\\Microsoft.AspNetCore.Antiforgery.csproj", - "src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj", - "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj", - "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", + "src\\Hosting\\Server.IntegrationTesting\\src\\Microsoft.AspNetCore.Server.IntegrationTesting.csproj", + "src\\Hosting\\TestHost\\src\\Microsoft.AspNetCore.TestHost.csproj", + "src\\Html.Abstractions\\src\\Microsoft.AspNetCore.Html.Abstractions.csproj", + "src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj", "src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj", "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj", - "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj", - "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj", - "src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj", - "src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj", + "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj", "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj", - "src\\Razor\\Razor\\src\\Microsoft.AspNetCore.Razor.csproj", - "src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj", - "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", + "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", + "src\\Http\\Metadata\\src\\Microsoft.AspNetCore.Metadata.csproj", + "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", + "src\\Http\\Routing\\samples\\MapActionSample\\MapActionSample.csproj", + "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", + "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj", + "src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj", + "src\\Localization\\Abstractions\\src\\Microsoft.Extensions.Localization.Abstractions.csproj", + "src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj", + "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", + "src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj", + "src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj", "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", - "src\\DataProtection\\Cryptography.Internal\\src\\Microsoft.AspNetCore.Cryptography.Internal.csproj", - "src\\DataProtection\\Abstractions\\src\\Microsoft.AspNetCore.DataProtection.Abstractions.csproj", - "src\\Mvc\\test\\WebSites\\GenericHostWebSite\\GenericHostWebSite.csproj", - "src\\Components\\Components\\src\\Microsoft.AspNetCore.Components.csproj", - "src\\Mvc\\Mvc\\src\\Microsoft.AspNetCore.Mvc.csproj", - "src\\Mvc\\Mvc\\test\\Microsoft.AspNetCore.Mvc.Test.csproj", + "src\\Middleware\\Localization.Routing\\src\\Microsoft.AspNetCore.Localization.Routing.csproj", + "src\\Middleware\\Localization\\src\\Microsoft.AspNetCore.Localization.csproj", + "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", + "src\\Middleware\\ResponseCaching\\src\\Microsoft.AspNetCore.ResponseCaching.csproj", + "src\\Middleware\\Session\\src\\Microsoft.AspNetCore.Session.csproj", + "src\\Middleware\\StaticFiles\\src\\Microsoft.AspNetCore.StaticFiles.csproj", + "src\\Middleware\\WebSockets\\src\\Microsoft.AspNetCore.WebSockets.csproj", "src\\Mvc\\Mvc.Abstractions\\src\\Microsoft.AspNetCore.Mvc.Abstractions.csproj", "src\\Mvc\\Mvc.Abstractions\\test\\Microsoft.AspNetCore.Mvc.Abstractions.Test.csproj", + "src\\Mvc\\Mvc.Analyzers\\src\\Microsoft.AspNetCore.Mvc.Analyzers.csproj", + "src\\Mvc\\Mvc.Analyzers\\test\\Mvc.Analyzers.Test.csproj", + "src\\Mvc\\Mvc.Api.Analyzers\\src\\Microsoft.AspNetCore.Mvc.Api.Analyzers.csproj", + "src\\Mvc\\Mvc.Api.Analyzers\\test\\Mvc.Api.Analyzers.Test.csproj", "src\\Mvc\\Mvc.ApiExplorer\\src\\Microsoft.AspNetCore.Mvc.ApiExplorer.csproj", "src\\Mvc\\Mvc.ApiExplorer\\test\\Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj", "src\\Mvc\\Mvc.Core\\src\\Microsoft.AspNetCore.Mvc.Core.csproj", @@ -95,44 +65,74 @@ "src\\Mvc\\Mvc.Formatters.Xml\\test\\Microsoft.AspNetCore.Mvc.Formatters.Xml.Test.csproj", "src\\Mvc\\Mvc.Localization\\src\\Microsoft.AspNetCore.Mvc.Localization.csproj", "src\\Mvc\\Mvc.Localization\\test\\Microsoft.AspNetCore.Mvc.Localization.Test.csproj", - "src\\Mvc\\Mvc.Razor\\src\\Microsoft.AspNetCore.Mvc.Razor.csproj", - "src\\Mvc\\Mvc.Razor\\test\\Microsoft.AspNetCore.Mvc.Razor.Test.csproj", + "src\\Mvc\\Mvc.NewtonsoftJson\\src\\Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj", + "src\\Mvc\\Mvc.NewtonsoftJson\\test\\Microsoft.AspNetCore.Mvc.NewtonsoftJson.Test.csproj", + "src\\Mvc\\Mvc.Razor.RuntimeCompilation\\src\\Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation.csproj", + "src\\Mvc\\Mvc.Razor.RuntimeCompilation\\test\\Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation.Test.csproj", "src\\Mvc\\Mvc.RazorPages\\src\\Microsoft.AspNetCore.Mvc.RazorPages.csproj", "src\\Mvc\\Mvc.RazorPages\\test\\Microsoft.AspNetCore.Mvc.RazorPages.Test.csproj", + "src\\Mvc\\Mvc.Razor\\src\\Microsoft.AspNetCore.Mvc.Razor.csproj", + "src\\Mvc\\Mvc.Razor\\test\\Microsoft.AspNetCore.Mvc.Razor.Test.csproj", "src\\Mvc\\Mvc.TagHelpers\\src\\Microsoft.AspNetCore.Mvc.TagHelpers.csproj", "src\\Mvc\\Mvc.TagHelpers\\test\\Microsoft.AspNetCore.Mvc.TagHelpers.Test.csproj", + "src\\Mvc\\Mvc.Testing.Tasks\\src\\Microsoft.AspNetCore.Mvc.Testing.Tasks.csproj", + "src\\Mvc\\Mvc.Testing\\src\\Microsoft.AspNetCore.Mvc.Testing.csproj", "src\\Mvc\\Mvc.ViewFeatures\\src\\Microsoft.AspNetCore.Mvc.ViewFeatures.csproj", "src\\Mvc\\Mvc.ViewFeatures\\test\\Microsoft.AspNetCore.Mvc.ViewFeatures.Test.csproj", + "src\\Mvc\\Mvc\\src\\Microsoft.AspNetCore.Mvc.csproj", + "src\\Mvc\\Mvc\\test\\Microsoft.AspNetCore.Mvc.Test.csproj", + "src\\Mvc\\benchmarks\\Microsoft.AspNetCore.Mvc.Performance.Views\\Microsoft.AspNetCore.Mvc.Performance.Views.csproj", + "src\\Mvc\\benchmarks\\Microsoft.AspNetCore.Mvc.Performance\\Microsoft.AspNetCore.Mvc.Performance.csproj", + "src\\Mvc\\samples\\MvcSandbox\\MvcSandbox.csproj", + "src\\Mvc\\shared\\Mvc.Core.TestCommon\\Microsoft.AspNetCore.Mvc.Core.TestCommon.csproj", + "src\\Mvc\\shared\\Mvc.TestDiagnosticListener\\Microsoft.AspNetCore.Mvc.TestDiagnosticListener.csproj", + "src\\Mvc\\shared\\Mvc.Views.TestCommon\\Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj", "src\\Mvc\\test\\Mvc.FunctionalTests\\Microsoft.AspNetCore.Mvc.FunctionalTests.csproj", "src\\Mvc\\test\\Mvc.IntegrationTests\\Microsoft.AspNetCore.Mvc.IntegrationTests.csproj", - "src\\Mvc\\shared\\Mvc.TestDiagnosticListener\\Microsoft.AspNetCore.Mvc.TestDiagnosticListener.csproj", - "src\\Mvc\\Mvc.Testing\\src\\Microsoft.AspNetCore.Mvc.Testing.csproj", - "src\\Mvc\\Mvc.Testing.Tasks\\src\\Microsoft.AspNetCore.Mvc.Testing.Tasks.csproj", - "src\\Mvc\\shared\\Mvc.Core.TestCommon\\Microsoft.AspNetCore.Mvc.Core.TestCommon.csproj", - "src\\Mvc\\Mvc.NewtonsoftJson\\src\\Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj", - "src\\Mvc\\Mvc.NewtonsoftJson\\test\\Microsoft.AspNetCore.Mvc.NewtonsoftJson.Test.csproj", - "src\\Mvc\\Mvc.Razor.RuntimeCompilation\\src\\Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation.csproj", - "src\\Mvc\\Mvc.Razor.RuntimeCompilation\\test\\Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation.Test.csproj", - "src\\Components\\Server\\src\\Microsoft.AspNetCore.Components.Server.csproj", - "src\\Components\\Web\\src\\Microsoft.AspNetCore.Components.Web.csproj", - "src\\Middleware\\WebSockets\\src\\Microsoft.AspNetCore.WebSockets.csproj", - "src\\SignalR\\common\\Http.Connections\\src\\Microsoft.AspNetCore.Http.Connections.csproj", + "src\\Mvc\\test\\WebSites\\ApiExplorerWebSite\\ApiExplorerWebSite.csproj", + "src\\Mvc\\test\\WebSites\\ApplicationModelWebSite\\ApplicationModelWebSite.csproj", + "src\\Mvc\\test\\WebSites\\BasicWebSite\\BasicWebSite.csproj", + "src\\Mvc\\test\\WebSites\\ControllersFromServicesClassLibrary\\ControllersFromServicesClassLibrary.csproj", + "src\\Mvc\\test\\WebSites\\ControllersFromServicesWebSite\\ControllersFromServicesWebSite.csproj", + "src\\Mvc\\test\\WebSites\\CorsWebSite\\CorsWebSite.csproj", + "src\\Mvc\\test\\WebSites\\ErrorPageMiddlewareWebSite\\ErrorPageMiddlewareWebSite.csproj", + "src\\Mvc\\test\\WebSites\\FilesWebSite\\FilesWebSite.csproj", + "src\\Mvc\\test\\WebSites\\FormatterWebSite\\FormatterWebSite.csproj", + "src\\Mvc\\test\\WebSites\\GenericHostWebSite\\GenericHostWebSite.csproj", + "src\\Mvc\\test\\WebSites\\HtmlGenerationWebSite\\HtmlGenerationWebSite.csproj", + "src\\Mvc\\test\\WebSites\\RazorBuildWebSite.PrecompiledViews\\RazorBuildWebSite.PrecompiledViews.csproj", + "src\\Mvc\\test\\WebSites\\RazorBuildWebSite.Views\\RazorBuildWebSite.Views.csproj", + "src\\Mvc\\test\\WebSites\\RazorBuildWebSite\\RazorBuildWebSite.csproj", + "src\\Mvc\\test\\WebSites\\RazorPagesClassLibrary\\RazorPagesClassLibrary.csproj", + "src\\Mvc\\test\\WebSites\\RazorPagesWebSite\\RazorPagesWebSite.csproj", + "src\\Mvc\\test\\WebSites\\RazorWebSite\\RazorWebSite.csproj", + "src\\Mvc\\test\\WebSites\\RoutingWebSite\\Mvc.RoutingWebSite.csproj", + "src\\Mvc\\test\\WebSites\\SecurityWebSite\\SecurityWebSite.csproj", + "src\\Mvc\\test\\WebSites\\SimpleWebSite\\SimpleWebSite.csproj", + "src\\Mvc\\test\\WebSites\\TagHelpersWebSite\\TagHelpersWebSite.csproj", + "src\\Mvc\\test\\WebSites\\VersioningWebSite\\VersioningWebSite.csproj", + "src\\Mvc\\test\\WebSites\\XmlFormattersWebSite\\XmlFormattersWebSite.csproj", + "src\\Razor\\Razor.Runtime\\src\\Microsoft.AspNetCore.Razor.Runtime.csproj", + "src\\Razor\\Razor\\src\\Microsoft.AspNetCore.Razor.csproj", + "src\\Security\\Authentication\\Cookies\\src\\Microsoft.AspNetCore.Authentication.Cookies.csproj", + "src\\Security\\Authentication\\Core\\src\\Microsoft.AspNetCore.Authentication.csproj", + "src\\Security\\Authentication\\JwtBearer\\src\\Microsoft.AspNetCore.Authentication.JwtBearer.csproj", + "src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj", + "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", + "src\\Security\\CookiePolicy\\src\\Microsoft.AspNetCore.CookiePolicy.csproj", + "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", + "src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj", + "src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj", + "src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj", + "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj", "src\\SignalR\\common\\Http.Connections.Common\\src\\Microsoft.AspNetCore.Http.Connections.Common.csproj", - "src\\SignalR\\server\\SignalR\\src\\Microsoft.AspNetCore.SignalR.csproj", - "src\\SignalR\\common\\SignalR.Common\\src\\Microsoft.AspNetCore.SignalR.Common.csproj", - "src\\SignalR\\server\\Core\\src\\Microsoft.AspNetCore.SignalR.Core.csproj", + "src\\SignalR\\common\\Http.Connections\\src\\Microsoft.AspNetCore.Http.Connections.csproj", + "src\\SignalR\\common\\Protocols.Json\\src\\Microsoft.AspNetCore.SignalR.Protocols.Json.csproj", "src\\SignalR\\common\\Protocols.MessagePack\\src\\Microsoft.AspNetCore.SignalR.Protocols.MessagePack.csproj", "src\\SignalR\\common\\Protocols.NewtonsoftJson\\src\\Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson.csproj", - "src\\Mvc\\test\\WebSites\\RazorBuildWebSite.PrecompiledViews\\RazorBuildWebSite.PrecompiledViews.csproj", - "src\\SignalR\\common\\Protocols.Json\\src\\Microsoft.AspNetCore.SignalR.Protocols.Json.csproj", - "src\\Http\\Metadata\\src\\Microsoft.AspNetCore.Metadata.csproj", - "src\\Components\\Authorization\\src\\Microsoft.AspNetCore.Components.Authorization.csproj", - "src\\Components\\Forms\\src\\Microsoft.AspNetCore.Components.Forms.csproj", - "src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj", - "src\\FileProviders\\Embedded\\src\\Microsoft.Extensions.FileProviders.Embedded.csproj", - "src\\Localization\\Abstractions\\src\\Microsoft.Extensions.Localization.Abstractions.csproj", - "src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj", - "src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj" + "src\\SignalR\\common\\SignalR.Common\\src\\Microsoft.AspNetCore.SignalR.Common.csproj", + "src\\SignalR\\server\\Core\\src\\Microsoft.AspNetCore.SignalR.Core.csproj", + "src\\SignalR\\server\\SignalR\\src\\Microsoft.AspNetCore.SignalR.csproj" ] } -} +} \ No newline at end of file From b0dd7b1645d4a49d638e2da5f62683a1684c8c65 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 8 Feb 2021 17:23:47 -0800 Subject: [PATCH 02/17] Apply PublicAPI changes --- src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt | 5 +++++ src/Http/Routing/src/PublicAPI.Unshipped.txt | 6 ++++++ src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt | 8 ++++---- src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt | 4 ++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 86c4ecf1bf30..bef0c3329e7f 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -4,6 +4,11 @@ *REMOVED*Microsoft.AspNetCore.Routing.RouteValueDictionary.TryAdd(string! key, object! value) -> bool *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, System.Type! middleware, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! +Microsoft.AspNetCore.Http.Api.IFromBodyMetadata +Microsoft.AspNetCore.Http.Api.IFromRouteMetadata +Microsoft.AspNetCore.Http.Api.IFromRouteMetadata.Name.get -> string! +Microsoft.AspNetCore.Http.Api.IResult +Microsoft.AspNetCore.Http.Api.IResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Http.Endpoint.Endpoint(Microsoft.AspNetCore.Http.RequestDelegate? requestDelegate, Microsoft.AspNetCore.Http.EndpointMetadataCollection? metadata, string? displayName) -> void Microsoft.AspNetCore.Http.Endpoint.RequestDelegate.get -> Microsoft.AspNetCore.Http.RequestDelegate? Microsoft.AspNetCore.Routing.RouteValueDictionary.TryAdd(string! key, object? value) -> bool diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index fac586f1b845..2fedcc98375c 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -5,9 +5,15 @@ *REMOVED*Microsoft.AspNetCore.Routing.IRouteNameMetadata.RouteName.get -> string! *REMOVED*Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteName.get -> string! *REMOVED*Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteNameMetadata(string! routeName) -> void +Microsoft.AspNetCore.Builder.MapActionEndpointRouteBuilderExtensions +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string? +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Order.get -> int? +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string? Microsoft.AspNetCore.Routing.DataTokensMetadata.DataTokens.get -> System.Collections.Generic.IReadOnlyDictionary! Microsoft.AspNetCore.Routing.DataTokensMetadata.DataTokensMetadata(System.Collections.Generic.IReadOnlyDictionary! dataTokens) -> void Microsoft.AspNetCore.Routing.IDataTokensMetadata.DataTokens.get -> System.Collections.Generic.IReadOnlyDictionary! Microsoft.AspNetCore.Routing.IRouteNameMetadata.RouteName.get -> string? Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteName.get -> string? Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteNameMetadata(string? routeName) -> void +static Microsoft.AspNetCore.Builder.MapActionEndpointRouteBuilderExtensions.MapAction(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, System.Delegate! action) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt index 21a3bca71b84..e65b04716e0c 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt @@ -777,8 +777,8 @@ Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Order.get -> int Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Order.set -> void Microsoft.AspNetCore.Mvc.Routing.IActionHttpMethodProvider -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Order.get -> int? +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider (forwarded, contained in Microsoft.AspNetCore.Routing) +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Order.get -> int? (forwarded, contained in Microsoft.AspNetCore.Routing) Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider Microsoft.AspNetCore.Mvc.Routing.IUrlHelperFactory Microsoft.AspNetCore.Mvc.Routing.KnownRouteValueConstraint @@ -1843,8 +1843,8 @@ virtual Microsoft.AspNetCore.Mvc.ModelBinding.Validation.ValidationVisitor.Visit ~Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Name.set -> void ~Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Template.get -> string ~Microsoft.AspNetCore.Mvc.Routing.IActionHttpMethodProvider.HttpMethods.get -> System.Collections.Generic.IEnumerable -~Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string -~Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string +~Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string (forwarded, contained in Microsoft.AspNetCore.Routing) +~Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string (forwarded, contained in Microsoft.AspNetCore.Routing) ~Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider.RouteKey.get -> string ~Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider.RouteValue.get -> string ~Microsoft.AspNetCore.Mvc.Routing.IUrlHelperFactory.GetUrlHelper(Microsoft.AspNetCore.Mvc.ActionContext context) -> Microsoft.AspNetCore.Mvc.IUrlHelper diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index 835a7ede6961..5f3df412a0a9 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -858,8 +858,8 @@ Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Name.get -> string? Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Name.set -> void Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Template.get -> string? Microsoft.AspNetCore.Mvc.Routing.IActionHttpMethodProvider.HttpMethods.get -> System.Collections.Generic.IEnumerable! -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string? -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string? +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string? (forwarded, contained in Microsoft.AspNetCore.Routing) +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string? (forwarded, contained in Microsoft.AspNetCore.Routing) Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider.RouteKey.get -> string! Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider.RouteValue.get -> string? Microsoft.AspNetCore.Mvc.Routing.IUrlHelperFactory.GetUrlHelper(Microsoft.AspNetCore.Mvc.ActionContext! context) -> Microsoft.AspNetCore.Mvc.IUrlHelper! From 1f2884ec7e2314bef3550e9c393d15d56bf5c2e5 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 8 Feb 2021 17:42:47 -0800 Subject: [PATCH 03/17] Add MapActionExpressionTreeBuilderTest --- ...MapActionEndpointRouteBuilderExtensions.cs | 1 - .../MapActionExpressionTreeBuilder.cs | 4 +-- .../MapActionExpressionTreeBuilderTest.cs | 30 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) rename src/Http/Routing/src/{MapAction => Builder}/MapActionEndpointRouteBuilderExtensions.cs (98%) rename src/Http/Routing/src/{MapAction => }/MapActionExpressionTreeBuilder.cs (99%) create mode 100644 src/Http/Routing/test/UnitTests/MapActionExpressionTreeBuilderTest.cs diff --git a/src/Http/Routing/src/MapAction/MapActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs similarity index 98% rename from src/Http/Routing/src/MapAction/MapActionEndpointRouteBuilderExtensions.cs rename to src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs index 1dd4b5d57395..2b3edefff066 100644 --- a/src/Http/Routing/src/MapAction/MapActionEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs @@ -7,7 +7,6 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.MapAction; namespace Microsoft.AspNetCore.Builder { diff --git a/src/Http/Routing/src/MapAction/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/MapActionExpressionTreeBuilder.cs similarity index 99% rename from src/Http/Routing/src/MapAction/MapActionExpressionTreeBuilder.cs rename to src/Http/Routing/src/MapActionExpressionTreeBuilder.cs index c332b8533fa2..998fd30b950d 100644 --- a/src/Http/Routing/src/MapAction/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/MapActionExpressionTreeBuilder.cs @@ -14,9 +14,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; -namespace Microsoft.AspNetCore.Routing.MapAction +namespace Microsoft.AspNetCore.Routing { - internal class MapActionExpressionTreeBuilder + internal static class MapActionExpressionTreeBuilder { private static readonly MethodInfo ChangeTypeMethodInfo = GetMethodInfo>((value, type) => Convert.ChangeType(value, type, CultureInfo.InvariantCulture)); private static readonly MethodInfo ExecuteTaskOfTMethodInfo = typeof(MapActionExpressionTreeBuilder).GetMethod(nameof(ExecuteTask), BindingFlags.NonPublic | BindingFlags.Static)!; diff --git a/src/Http/Routing/test/UnitTests/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/MapActionExpressionTreeBuilderTest.cs new file mode 100644 index 000000000000..0ea6e3b3291f --- /dev/null +++ b/src/Http/Routing/test/UnitTests/MapActionExpressionTreeBuilderTest.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class MapActionExpressionTreeBuilderTest + { + [Fact] + public async Task RequestDelegateInvokesAction() + { + var invoked = false; + void TestAction() + { + invoked = true; + }; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(null!); + + Assert.True(invoked); + } + } +} From d2a2177a942da4e35cab927efe05b07b361b668c Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 9 Feb 2021 10:15:51 -0800 Subject: [PATCH 04/17] Remove MapAction folder --- src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj index 3f6a759f09a9..67723bd1d175 100644 --- a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj +++ b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj @@ -38,8 +38,4 @@ Microsoft.AspNetCore.Routing.RouteCollection - - - - From cbec2d155a782a95bafeb58c47ead16c8931211f Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 9 Feb 2021 11:32:13 -0800 Subject: [PATCH 05/17] Move IRouteTemplateProvider back --- ...MapActionEndpointRouteBuilderExtensions.cs | 15 ++++++---- src/Http/Routing/src/IRouteOrderMetadata.cs | 19 +++++++++++++ src/Http/Routing/src/IRoutePatternMetadata.cs | 16 +++++++++++ src/Http/Routing/src/PublicAPI.Unshipped.txt | 8 +++--- .../test/FunctionalTests/MapActionTest.cs | 28 ++----------------- ....AspNetCore.Routing.FunctionalTests.csproj | 1 + ...ctionEndpointRouteBuilderExtensionsTest.cs | 28 ++++++++++--------- .../Mvc.Core/src/Properties/AssemblyInfo.cs | 2 -- src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt | 8 +++--- src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt | 4 +-- .../src/Routing}/IRouteTemplateProvider.cs | 13 ++++++++- 11 files changed, 84 insertions(+), 58 deletions(-) create mode 100644 src/Http/Routing/src/IRouteOrderMetadata.cs create mode 100644 src/Http/Routing/src/IRoutePatternMetadata.cs rename src/{Http/Routing/src => Mvc/Mvc.Core/src/Routing}/IRouteTemplateProvider.cs (74%) diff --git a/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs index 2b3edefff066..2468333ffa81 100644 --- a/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Builder @@ -37,19 +36,22 @@ public static IEndpointConventionBuilder MapAction( var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate(action); - var routeAttributes = action.Method.GetCustomAttributes().OfType(); + var routeAttributes = action.Method.GetCustomAttributes().OfType(); var conventionBuilders = new List(); const int defaultOrder = 0; foreach (var routeAttribute in routeAttributes) { - if (routeAttribute.Template is null) + if (routeAttribute.RoutePattern is not string pattern) { continue; } - var conventionBuilder = endpoints.Map(routeAttribute.Template, requestDelegate); + var routeName = (routeAttribute as IRouteNameMetadata)?.RouteName; + var routeOrder = (routeAttribute as IRouteOrderMetadata)?.RouteOrder; + + var conventionBuilder = endpoints.Map(pattern, requestDelegate); conventionBuilder.Add(endpointBuilder => { @@ -58,9 +60,10 @@ public static IEndpointConventionBuilder MapAction( endpointBuilder.Metadata.Add(attribute); } - endpointBuilder.DisplayName = routeAttribute.Name ?? routeAttribute.Template; - ((RouteEndpointBuilder)endpointBuilder).Order = routeAttribute.Order ?? defaultOrder; + endpointBuilder.DisplayName = routeName ?? pattern; + + ((RouteEndpointBuilder)endpointBuilder).Order = routeOrder ?? defaultOrder; }); conventionBuilders.Add(conventionBuilder); diff --git a/src/Http/Routing/src/IRouteOrderMetadata.cs b/src/Http/Routing/src/IRouteOrderMetadata.cs new file mode 100644 index 000000000000..4326b24e5625 --- /dev/null +++ b/src/Http/Routing/src/IRouteOrderMetadata.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Interface for attributes which can supply a route order for attribute routing. + /// + public interface IRouteOrderMetadata + { + /// + /// Gets the route order. The order determines the order of route execution. Routes with a lower + /// order value are tried first. When a route doesn't specify a value, it gets a default value of 0. + /// A null value for the Order property means that the user didn't specify an explicit order for the + /// route. + /// + int? RouteOrder { get; } + } +} diff --git a/src/Http/Routing/src/IRoutePatternMetadata.cs b/src/Http/Routing/src/IRoutePatternMetadata.cs new file mode 100644 index 000000000000..615a67abfbeb --- /dev/null +++ b/src/Http/Routing/src/IRoutePatternMetadata.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Interface for attributes which can supply a route pattern for attribute routing. + /// + public interface IRoutePatternMetadata + { + /// + /// The route pattern. May be . + /// + string? RoutePattern { get; } + } +} diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 2fedcc98375c..3e12f6cb1a05 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -6,14 +6,14 @@ *REMOVED*Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteName.get -> string! *REMOVED*Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteNameMetadata(string! routeName) -> void Microsoft.AspNetCore.Builder.MapActionEndpointRouteBuilderExtensions -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string? -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Order.get -> int? -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string? Microsoft.AspNetCore.Routing.DataTokensMetadata.DataTokens.get -> System.Collections.Generic.IReadOnlyDictionary! Microsoft.AspNetCore.Routing.DataTokensMetadata.DataTokensMetadata(System.Collections.Generic.IReadOnlyDictionary! dataTokens) -> void Microsoft.AspNetCore.Routing.IDataTokensMetadata.DataTokens.get -> System.Collections.Generic.IReadOnlyDictionary! Microsoft.AspNetCore.Routing.IRouteNameMetadata.RouteName.get -> string? +Microsoft.AspNetCore.Routing.IRouteOrderMetadata +Microsoft.AspNetCore.Routing.IRouteOrderMetadata.RouteOrder.get -> int? +Microsoft.AspNetCore.Routing.IRoutePatternMetadata +Microsoft.AspNetCore.Routing.IRoutePatternMetadata.RoutePattern.get -> string? Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteName.get -> string? Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteNameMetadata(string? routeName) -> void static Microsoft.AspNetCore.Builder.MapActionEndpointRouteBuilderExtensions.MapAction(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, System.Delegate! action) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! diff --git a/src/Http/Routing/test/FunctionalTests/MapActionTest.cs b/src/Http/Routing/test/FunctionalTests/MapActionTest.cs index dd4ca4f81adb..aed2835e4826 100644 --- a/src/Http/Routing/test/FunctionalTests/MapActionTest.cs +++ b/src/Http/Routing/test/FunctionalTests/MapActionTest.cs @@ -4,14 +4,12 @@ #nullable enable using System; -using System.Collections.Generic; using System.Net.Http.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Api; -using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -24,7 +22,7 @@ public class MapActionTest [Fact] public async Task MapAction_FromBodyWorksWithJsonPayload() { - [HttpMethods(new[] { "POST" }, "/EchoTodo")] + [HttpPost("/EchoTodo")] Todo EchoTodo([FromBody] Todo todo) => todo; using var host = new HostBuilder() @@ -40,7 +38,6 @@ public async Task MapAction_FromBodyWorksWithJsonPayload() }) .ConfigureServices(services => { - services.AddAuthorization(); services.AddRouting(); }) .Build(); @@ -68,26 +65,5 @@ private class Todo public string Name { get; set; } = "Todo"; public bool IsComplete { get; set; } } - - private class FromBodyAttribute : Attribute, IFromBodyMetadata { } - - private class HttpMethodsAttribute : Attribute, IRouteTemplateProvider, IHttpMethodMetadata - { - public HttpMethodsAttribute(string[] httpMethods, string? template) - { - HttpMethods = httpMethods; - Template = template; - } - - public string? Template { get; } - - public IReadOnlyList HttpMethods { get; } - - public int? Order => null; - - public string? Name => null; - - public bool AcceptCorsPreflight => false; - } } } diff --git a/src/Http/Routing/test/FunctionalTests/Microsoft.AspNetCore.Routing.FunctionalTests.csproj b/src/Http/Routing/test/FunctionalTests/Microsoft.AspNetCore.Routing.FunctionalTests.csproj index 0fd19466b6b2..0913e1a18394 100644 --- a/src/Http/Routing/test/FunctionalTests/Microsoft.AspNetCore.Routing.FunctionalTests.csproj +++ b/src/Http/Routing/test/FunctionalTests/Microsoft.AspNetCore.Routing.FunctionalTests.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs index 866d6a64910b..5146baa5ac3c 100644 --- a/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs @@ -6,8 +6,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Moq; using Xunit; @@ -29,17 +27,17 @@ private RouteEndpointBuilder GetRouteEndpointBuilder(IEndpointRouteBuilder endpo [Fact] public void MapAction_BuildsEndpointFromAttributes() { - const string customTemplate = "/CustomTemplate"; + const string customPattern = "/CustomTemplate"; const string customMethod = "CUSTOM_METHOD"; - [HttpMethods(Template = customTemplate, Methods = new[] { customMethod })] + [CustomRouteMetadata(Pattern = customPattern, Methods = new[] { customMethod })] void TestAction() { }; var builder = new DefaultEndpointRouteBuilder(Mock.Of()); _ = builder.MapAction((Action)TestAction); var routeEndpointBuilder = GetRouteEndpointBuilder(builder); - Assert.Equal(customTemplate, routeEndpointBuilder.RoutePattern.RawText); + Assert.Equal(customPattern, routeEndpointBuilder.RoutePattern.RawText); var dataSource = GetBuilderEndpointDataSource(builder); var endpoint = Assert.Single(dataSource.Endpoints); @@ -55,7 +53,7 @@ public void MapAction_BuildsEndpointWithRouteNameAndOrder() const string customName = "Custom Name"; const int customOrder = 1337; - [HttpMethods(Name = customName, Order = customOrder)] + [CustomRouteMetadata(Name = customName, Order = customOrder)] void TestAction() { }; var builder = new DefaultEndpointRouteBuilder(Mock.Of()); @@ -70,21 +68,25 @@ public void MapAction_BuildsEndpointWithRouteNameAndOrder() Assert.Equal(customOrder, routeEndpointBuilder.Order); } - private class HttpMethodsAttribute : Attribute, IHttpMethodMetadata, IRouteTemplateProvider + private class CustomRouteMetadataAttribute : Attribute, IRoutePatternMetadata, IHttpMethodMetadata, IRouteNameMetadata, IRouteOrderMetadata { - public string[] Methods { get; set; } = new[] { "GET" }; + public string Pattern { get; set; } = "/"; - public string Template { get; set; } = "/"; + public string? Name { get; set; } - public int Order { get; set; } + public int Order { get; set; } = 0; - public string? Name { get; set; } + public string[] Methods { get; set; } = new[] { "GET" }; + + string? IRoutePatternMetadata.RoutePattern => Pattern; + + string? IRouteNameMetadata.RouteName => Name; - public bool AcceptCorsPreflight => false; + int? IRouteOrderMetadata.RouteOrder => Order; IReadOnlyList IHttpMethodMetadata.HttpMethods => Methods; - int? IRouteTemplateProvider.Order => Order; + bool IHttpMethodMetadata.AcceptCorsPreflight => false; } } } diff --git a/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs b/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs index 0103378199b6..c217d6411aad 100644 --- a/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs +++ b/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs @@ -3,10 +3,8 @@ using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.AspNetCore.Mvc.Routing; [assembly: TypeForwardedTo(typeof(InputFormatterException))] -[assembly: TypeForwardedTo(typeof(IRouteTemplateProvider))] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt index e65b04716e0c..21a3bca71b84 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Shipped.txt @@ -777,8 +777,8 @@ Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Order.get -> int Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Order.set -> void Microsoft.AspNetCore.Mvc.Routing.IActionHttpMethodProvider -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider (forwarded, contained in Microsoft.AspNetCore.Routing) -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Order.get -> int? (forwarded, contained in Microsoft.AspNetCore.Routing) +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Order.get -> int? Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider Microsoft.AspNetCore.Mvc.Routing.IUrlHelperFactory Microsoft.AspNetCore.Mvc.Routing.KnownRouteValueConstraint @@ -1843,8 +1843,8 @@ virtual Microsoft.AspNetCore.Mvc.ModelBinding.Validation.ValidationVisitor.Visit ~Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Name.set -> void ~Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Template.get -> string ~Microsoft.AspNetCore.Mvc.Routing.IActionHttpMethodProvider.HttpMethods.get -> System.Collections.Generic.IEnumerable -~Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string (forwarded, contained in Microsoft.AspNetCore.Routing) -~Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string (forwarded, contained in Microsoft.AspNetCore.Routing) +~Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string +~Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string ~Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider.RouteKey.get -> string ~Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider.RouteValue.get -> string ~Microsoft.AspNetCore.Mvc.Routing.IUrlHelperFactory.GetUrlHelper(Microsoft.AspNetCore.Mvc.ActionContext context) -> Microsoft.AspNetCore.Mvc.IUrlHelper diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index 5f3df412a0a9..835a7ede6961 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -858,8 +858,8 @@ Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Name.get -> string? Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Name.set -> void Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute.Template.get -> string? Microsoft.AspNetCore.Mvc.Routing.IActionHttpMethodProvider.HttpMethods.get -> System.Collections.Generic.IEnumerable! -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string? (forwarded, contained in Microsoft.AspNetCore.Routing) -Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string? (forwarded, contained in Microsoft.AspNetCore.Routing) +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Name.get -> string? +Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider.Template.get -> string? Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider.RouteKey.get -> string! Microsoft.AspNetCore.Mvc.Routing.IRouteValueProvider.RouteValue.get -> string? Microsoft.AspNetCore.Mvc.Routing.IUrlHelperFactory.GetUrlHelper(Microsoft.AspNetCore.Mvc.ActionContext! context) -> Microsoft.AspNetCore.Mvc.IUrlHelper! diff --git a/src/Http/Routing/src/IRouteTemplateProvider.cs b/src/Mvc/Mvc.Core/src/Routing/IRouteTemplateProvider.cs similarity index 74% rename from src/Http/Routing/src/IRouteTemplateProvider.cs rename to src/Mvc/Mvc.Core/src/Routing/IRouteTemplateProvider.cs index 0934bad99f24..f15e76abc64c 100644 --- a/src/Http/Routing/src/IRouteTemplateProvider.cs +++ b/src/Mvc/Mvc.Core/src/Routing/IRouteTemplateProvider.cs @@ -3,12 +3,14 @@ #nullable enable +using Microsoft.AspNetCore.Routing; + namespace Microsoft.AspNetCore.Mvc.Routing { /// /// Interface for attributes which can supply a route template for attribute routing. /// - public interface IRouteTemplateProvider + public interface IRouteTemplateProvider : IRoutePatternMetadata, IRouteOrderMetadata, IRouteNameMetadata { /// /// The route template. May be . @@ -28,5 +30,14 @@ public interface IRouteTemplateProvider /// of relying on selection of a route based on the given set of route values. /// string? Name { get; } + + /// + string? IRoutePatternMetadata.RoutePattern => Template; + + /// + int? IRouteOrderMetadata.RouteOrder => Order; + + /// + string? IRouteNameMetadata.RouteName => Name; } } From 4865f2f7cb7c7f11521023b15c38f7a678fec90a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 9 Feb 2021 21:05:36 -0800 Subject: [PATCH 06/17] .Api -> .Metadata --- .../src/{Api => Metadata}/IFromBodyMetadata.cs | 2 +- .../src/{Api => Metadata}/IFromRouteMetadata.cs | 2 +- .../Http.Abstractions/src/{Api => Metadata}/IResult.cs | 2 +- src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt | 10 +++++----- src/Http/Routing/src/MapActionExpressionTreeBuilder.cs | 2 +- src/Mvc/Mvc.Core/src/FromBodyAttribute.cs | 2 +- src/Mvc/Mvc.Core/src/FromRouteAttribute.cs | 2 +- src/Mvc/Mvc.Core/src/JsonResult.cs | 2 +- src/Mvc/Mvc.Core/src/StatusCodeResult.cs | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) rename src/Http/Http.Abstractions/src/{Api => Metadata}/IFromBodyMetadata.cs (89%) rename src/Http/Http.Abstractions/src/{Api => Metadata}/IFromRouteMetadata.cs (92%) rename src/Http/Http.Abstractions/src/{Api => Metadata}/IResult.cs (93%) diff --git a/src/Http/Http.Abstractions/src/Api/IFromBodyMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs similarity index 89% rename from src/Http/Http.Abstractions/src/Api/IFromBodyMetadata.cs rename to src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs index bbccb04d8156..8269ac6cdd9a 100644 --- a/src/Http/Http.Abstractions/src/Api/IFromBodyMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -namespace Microsoft.AspNetCore.Http.Api +namespace Microsoft.AspNetCore.Http.Metadata { /// /// Interface marking attributes that specify a parameter or property should be bound using the request body. diff --git a/src/Http/Http.Abstractions/src/Api/IFromRouteMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs similarity index 92% rename from src/Http/Http.Abstractions/src/Api/IFromRouteMetadata.cs rename to src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs index 68576ba07452..abd4c0ca8df1 100644 --- a/src/Http/Http.Abstractions/src/Api/IFromRouteMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -namespace Microsoft.AspNetCore.Http.Api +namespace Microsoft.AspNetCore.Http.Metadata { /// /// Interface marking attributes that specify a parameter or property should be bound using route-data from the current request. diff --git a/src/Http/Http.Abstractions/src/Api/IResult.cs b/src/Http/Http.Abstractions/src/Metadata/IResult.cs similarity index 93% rename from src/Http/Http.Abstractions/src/Api/IResult.cs rename to src/Http/Http.Abstractions/src/Metadata/IResult.cs index 48da8c4ec453..c3fec8050dc7 100644 --- a/src/Http/Http.Abstractions/src/Api/IResult.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IResult.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; -namespace Microsoft.AspNetCore.Http.Api +namespace Microsoft.AspNetCore.Http.Metadata { /// /// Defines a contract that represents the result of an HTTP endpoint. diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index bef0c3329e7f..39f5becaff05 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -4,11 +4,11 @@ *REMOVED*Microsoft.AspNetCore.Routing.RouteValueDictionary.TryAdd(string! key, object! value) -> bool *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, System.Type! middleware, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! -Microsoft.AspNetCore.Http.Api.IFromBodyMetadata -Microsoft.AspNetCore.Http.Api.IFromRouteMetadata -Microsoft.AspNetCore.Http.Api.IFromRouteMetadata.Name.get -> string! -Microsoft.AspNetCore.Http.Api.IResult -Microsoft.AspNetCore.Http.Api.IResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Http.Metadata.IFromBodyMetadata +Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata +Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata.Name.get -> string! +Microsoft.AspNetCore.Http.Metadata.IResult +Microsoft.AspNetCore.Http.Metadata.IResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Http.Endpoint.Endpoint(Microsoft.AspNetCore.Http.RequestDelegate? requestDelegate, Microsoft.AspNetCore.Http.EndpointMetadataCollection? metadata, string? displayName) -> void Microsoft.AspNetCore.Http.Endpoint.RequestDelegate.get -> Microsoft.AspNetCore.Http.RequestDelegate? Microsoft.AspNetCore.Routing.RouteValueDictionary.TryAdd(string! key, object? value) -> bool diff --git a/src/Http/Routing/src/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/MapActionExpressionTreeBuilder.cs index 998fd30b950d..829b20102e5e 100644 --- a/src/Http/Routing/src/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/MapActionExpressionTreeBuilder.cs @@ -10,7 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Api; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; diff --git a/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs b/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs index 71c0f61c736b..9d1cca78846a 100644 --- a/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs @@ -3,7 +3,7 @@ using System; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Http.Api; +using Microsoft.AspNetCore.Http.Metadata; namespace Microsoft.AspNetCore.Mvc { diff --git a/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs b/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs index a0ea9151d7c1..a257d8ffa93e 100644 --- a/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs @@ -3,7 +3,7 @@ using System; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Api; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc diff --git a/src/Mvc/Mvc.Core/src/JsonResult.cs b/src/Mvc/Mvc.Core/src/JsonResult.cs index 8cb094489b63..515b2bba85ec 100644 --- a/src/Mvc/Mvc.Core/src/JsonResult.cs +++ b/src/Mvc/Mvc.Core/src/JsonResult.cs @@ -5,7 +5,7 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Api; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Mvc/Mvc.Core/src/StatusCodeResult.cs b/src/Mvc/Mvc.Core/src/StatusCodeResult.cs index c66340033d5f..fb027f21dc5e 100644 --- a/src/Mvc/Mvc.Core/src/StatusCodeResult.cs +++ b/src/Mvc/Mvc.Core/src/StatusCodeResult.cs @@ -4,7 +4,7 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Api; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; From bf7a263e60692681751c1fb3dbd3d3e623323246 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 10 Feb 2021 08:43:47 -0800 Subject: [PATCH 07/17] Add more tests! --- ...MapActionEndpointRouteBuilderExtensions.cs | 1 + .../MapActionExpressionTreeBuilder.cs | 2 +- ...ctionEndpointRouteBuilderExtensionsTest.cs | 22 +-- .../MapActionExpressionTreeBuilderTest.cs | 139 ++++++++++++++++++ .../MapActionExpressionTreeBuilderTest.cs | 30 ---- .../CustomRouteMetadataAttribute.cs | 31 ++++ 6 files changed, 173 insertions(+), 52 deletions(-) rename src/Http/Routing/src/{ => Internal}/MapActionExpressionTreeBuilder.cs (99%) create mode 100644 src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs delete mode 100644 src/Http/Routing/test/UnitTests/MapActionExpressionTreeBuilderTest.cs create mode 100644 src/Http/Routing/test/UnitTests/TestObjects/CustomRouteMetadataAttribute.cs diff --git a/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs index 2468333ffa81..dc29143d8cd9 100644 --- a/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Reflection; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Internal; namespace Microsoft.AspNetCore.Builder { diff --git a/src/Http/Routing/src/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs similarity index 99% rename from src/Http/Routing/src/MapActionExpressionTreeBuilder.cs rename to src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs index 829b20102e5e..4ff3e00b6dec 100644 --- a/src/Http/Routing/src/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; -namespace Microsoft.AspNetCore.Routing +namespace Microsoft.AspNetCore.Routing.Internal { internal static class MapActionExpressionTreeBuilder { diff --git a/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs index 5146baa5ac3c..8c6d3e291fdd 100644 --- a/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.TestObjects; using Moq; using Xunit; @@ -67,26 +68,5 @@ public void MapAction_BuildsEndpointWithRouteNameAndOrder() Assert.Equal(customName, routeEndpointBuilder.DisplayName); Assert.Equal(customOrder, routeEndpointBuilder.Order); } - - private class CustomRouteMetadataAttribute : Attribute, IRoutePatternMetadata, IHttpMethodMetadata, IRouteNameMetadata, IRouteOrderMetadata - { - public string Pattern { get; set; } = "/"; - - public string? Name { get; set; } - - public int Order { get; set; } = 0; - - public string[] Methods { get; set; } = new[] { "GET" }; - - string? IRoutePatternMetadata.RoutePattern => Pattern; - - string? IRouteNameMetadata.RouteName => Name; - - int? IRouteOrderMetadata.RouteOrder => Order; - - IReadOnlyList IHttpMethodMetadata.HttpMethods => Methods; - - bool IHttpMethodMetadata.AcceptCorsPreflight => false; - } } } diff --git a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs new file mode 100644 index 000000000000..86153f05b973 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs @@ -0,0 +1,139 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Internal +{ + public class MapActionExpressionTreeBuilderTest + { + [Fact] + public async Task RequestDelegateInvokesAction() + { + var invoked = false; + void TestAction() + { + invoked = true; + }; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(null!); + + Assert.True(invoked); + } + + [Fact] + public async Task RequestDelegatePopulatesFromBodyParameter() + { + Todo originalTodo = new() + { + Name = "Write more tests!" + }; + + Todo? deserializedRequestBody = null; + + void TestAction([FromBody] Todo todo) + { + deserializedRequestBody = todo; + }; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + + var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(originalTodo); + httpContext.Request.Body = new MemoryStream(requestBodyBytes); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.NotNull(deserializedRequestBody); + Assert.Equal(originalTodo.Name, deserializedRequestBody!.Name); + } + + [Fact] + public async Task RequestDelegateWritesComplexReturnValueAsJsonResponseBody() + { + Todo originalTodo = new() + { + Name = "Write even more tests!" + }; + + Todo TestAction() => originalTodo; + + var httpContext = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Func)TestAction); + + await requestDelegate(httpContext); + + var deserializedResponseBody = JsonSerializer.Deserialize(responseBodyStream.ToArray(), new JsonSerializerOptions + { + // TODO: the output is "{\"id\":0,\"name\":\"Write even more tests!\",\"isComplete\":false}" + // Verify that the camelCased property names are consistent with MVC and if so whether we should keep the behavior. + PropertyNameCaseInsensitive = true + }); + + Assert.NotNull(deserializedResponseBody); + Assert.Equal(originalTodo.Name, deserializedResponseBody!.Name); + } + + [Fact] + public async Task RequestDelegateUsesCustomIResult() + { + var resultString = "Still not enough tests!"; + + CustomResult TestAction() => new(resultString!); + + var httpContext = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Func)TestAction); + + await requestDelegate(httpContext); + + var decodedResponseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + + Assert.Equal(resultString, decodedResponseBody); + } + + private class Todo + { + public int Id { get; set; } + public string? Name { get; set; } = "Todo"; + public bool IsComplete { get; set; } + } + + private class FromBodyAttribute : Attribute, IFromBodyMetadata + { + } + + private class CustomResult : IResult + { + private readonly string _resultString; + + public CustomResult(string resultString) + { + _resultString = resultString; + } + + public Task ExecuteAsync(HttpContext httpContext) + { + return httpContext.Response.WriteAsync(_resultString); + } + } + } +} diff --git a/src/Http/Routing/test/UnitTests/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/MapActionExpressionTreeBuilderTest.cs deleted file mode 100644 index 0ea6e3b3291f..000000000000 --- a/src/Http/Routing/test/UnitTests/MapActionExpressionTreeBuilderTest.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -#nullable enable - -using System; -using System.Threading.Tasks; -using Xunit; - -namespace Microsoft.AspNetCore.Routing -{ - public class MapActionExpressionTreeBuilderTest - { - [Fact] - public async Task RequestDelegateInvokesAction() - { - var invoked = false; - void TestAction() - { - invoked = true; - }; - - var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); - - await requestDelegate(null!); - - Assert.True(invoked); - } - } -} diff --git a/src/Http/Routing/test/UnitTests/TestObjects/CustomRouteMetadataAttribute.cs b/src/Http/Routing/test/UnitTests/TestObjects/CustomRouteMetadataAttribute.cs new file mode 100644 index 000000000000..4a2b73ec748f --- /dev/null +++ b/src/Http/Routing/test/UnitTests/TestObjects/CustomRouteMetadataAttribute.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing.TestObjects +{ + internal class CustomRouteMetadataAttribute : Attribute, IRoutePatternMetadata, IHttpMethodMetadata, IRouteNameMetadata, IRouteOrderMetadata + { + public string Pattern { get; set; } = "/"; + + public string? Name { get; set; } + + public int Order { get; set; } = 0; + + public string[] Methods { get; set; } = new[] { "GET" }; + + string? IRoutePatternMetadata.RoutePattern => Pattern; + + string? IRouteNameMetadata.RouteName => Name; + + int? IRouteOrderMetadata.RouteOrder => Order; + + IReadOnlyList IHttpMethodMetadata.HttpMethods => Methods; + + bool IHttpMethodMetadata.AcceptCorsPreflight => false; + } +} From 3eb90276c1b6c4cbf436376a98abcf2394b355ef Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Feb 2021 08:12:12 -0800 Subject: [PATCH 08/17] Add more IFrom*Metadata interfaces and tests --- .../src/Metadata/IFromBodyMetadata.cs | 2 +- .../src/Metadata/IFromFormMetadata.cs | 16 ++ .../src/Metadata/IFromHeaderMetadata.cs | 16 ++ .../src/Metadata/IFromQueryMetadata.cs | 16 ++ .../src/Metadata/IFromRouteMetadata.cs | 4 +- .../src/Metadata/IFromServiceMetadata.cs | 12 + .../src/PublicAPI.Unshipped.txt | 9 +- src/Http/Http/src/QueryCollection.cs | 2 +- .../MapActionExpressionTreeBuilder.cs | 97 ++++--- .../MapActionExpressionTreeBuilderTest.cs | 266 +++++++++++++++++- src/Mvc/Mvc.Core/src/FromFormAttribute.cs | 3 +- src/Mvc/Mvc.Core/src/FromHeaderAttribute.cs | 3 +- src/Mvc/Mvc.Core/src/FromQueryAttribute.cs | 3 +- src/Mvc/Mvc.Core/src/FromServicesAttribute.cs | 3 +- 14 files changed, 398 insertions(+), 54 deletions(-) create mode 100644 src/Http/Http.Abstractions/src/Metadata/IFromFormMetadata.cs create mode 100644 src/Http/Http.Abstractions/src/Metadata/IFromHeaderMetadata.cs create mode 100644 src/Http/Http.Abstractions/src/Metadata/IFromQueryMetadata.cs create mode 100644 src/Http/Http.Abstractions/src/Metadata/IFromServiceMetadata.cs diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs index 8269ac6cdd9a..4df9596600d9 100644 --- a/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs @@ -4,7 +4,7 @@ namespace Microsoft.AspNetCore.Http.Metadata { /// - /// Interface marking attributes that specify a parameter or property should be bound using the request body. + /// Interface marking attributes that specify a parameter should be bound using the request body. /// public interface IFromBodyMetadata { diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromFormMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromFormMetadata.cs new file mode 100644 index 000000000000..054628d50069 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IFromFormMetadata.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Metadata +{ + /// + /// Interface marking attributes that specify a parameter should be bound using form-data in the request body. + /// + public interface IFromFormMetadata + { + /// + /// The form field name. + /// + string? Name { get; } + } +} diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromHeaderMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromHeaderMetadata.cs new file mode 100644 index 000000000000..474daf9ed5bd --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IFromHeaderMetadata.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Metadata +{ + /// + /// Interface marking attributes that specify a parameter should be bound using the request headers. + /// + public interface IFromHeaderMetadata + { + /// + /// The request header name. + /// + string? Name { get; } + } +} diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromQueryMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromQueryMetadata.cs new file mode 100644 index 000000000000..303f70c8ed3e --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IFromQueryMetadata.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Metadata +{ + /// + /// Interface marking attributes that specify a parameter should be bound using the request query string. + /// + public interface IFromQueryMetadata + { + /// + /// The name of the query string field. + /// + string? Name { get; } + } +} diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs index abd4c0ca8df1..66b24e5284e9 100644 --- a/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs @@ -4,13 +4,13 @@ namespace Microsoft.AspNetCore.Http.Metadata { /// - /// Interface marking attributes that specify a parameter or property should be bound using route-data from the current request. + /// Interface marking attributes that specify a parameter should be bound using route-data from the current request. /// public interface IFromRouteMetadata { /// /// The name. /// - string Name { get; } + string? Name { get; } } } diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromServiceMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromServiceMetadata.cs new file mode 100644 index 000000000000..92c1e2ad6758 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IFromServiceMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Metadata +{ + /// + /// Interface marking attributes that specify a parameter should be bound using request services. + /// + public interface IFromServiceMetadata + { + } +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 39f5becaff05..3e85b26eb958 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -5,8 +5,15 @@ *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, System.Type! middleware, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! Microsoft.AspNetCore.Http.Metadata.IFromBodyMetadata +Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata +Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string? +Microsoft.AspNetCore.Http.Metadata.IFromHeaderMetadata +Microsoft.AspNetCore.Http.Metadata.IFromHeaderMetadata.Name.get -> string? +Microsoft.AspNetCore.Http.Metadata.IFromQueryMetadata +Microsoft.AspNetCore.Http.Metadata.IFromQueryMetadata.Name.get -> string? Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata -Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata.Name.get -> string! +Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata.Name.get -> string? +Microsoft.AspNetCore.Http.Metadata.IFromServiceMetadata Microsoft.AspNetCore.Http.Metadata.IResult Microsoft.AspNetCore.Http.Metadata.IResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Http.Endpoint.Endpoint(Microsoft.AspNetCore.Http.RequestDelegate? requestDelegate, Microsoft.AspNetCore.Http.EndpointMetadataCollection? metadata, string? displayName) -> void diff --git a/src/Http/Http/src/QueryCollection.cs b/src/Http/Http/src/QueryCollection.cs index 75f1a47ea513..3753fd870bc7 100644 --- a/src/Http/Http/src/QueryCollection.cs +++ b/src/Http/Http/src/QueryCollection.cs @@ -24,7 +24,7 @@ public class QueryCollection : IQueryCollection private static readonly IEnumerator> EmptyIEnumeratorType = EmptyEnumerator; private static readonly IEnumerator EmptyIEnumerator = EmptyEnumerator; - private Dictionary? Store { get; set; } + private Dictionary? Store { get; } /// /// Initializes a new instance of . diff --git a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs index 4ff3e00b6dec..cf21a4c7d688 100644 --- a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Linq.Expressions; @@ -57,8 +58,8 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) var method = action.Method; - var needForm = false; - var needBody = false; + var consumeBodyDirectly = false; + var consumeBodyAsForm = false; Type? bodyType = null; // This argument represents the deserialized body returned from IHttpRequestReader @@ -70,59 +71,63 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) { Expression paramterExpression = Expression.Default(parameter.ParameterType); - //if (parameter.FromQuery != null) - //{ - // var queryProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.Query)); - // paramterExpression = BindParamenter(queryProperty, parameter, parameter.FromQuery); - //} - //else if (parameter.FromHeader != null) - //{ - // var headersProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.Headers)); - // paramterExpression = BindParamenter(headersProperty, parameter, parameter.FromHeader); - //} - //else if (parameter.FromRoute != null) - //{ - // var routeValuesProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.RouteValues)); - // paramterExpression = BindParamenter(routeValuesProperty, parameter, parameter.FromRoute); - //} - //else if (parameter.FromCookie != null) - //{ - // var cookiesProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.Cookies)); - // paramterExpression = BindParamenter(cookiesProperty, parameter, parameter.FromCookie); - //} - //else if (parameter.FromServices) - //{ - // paramterExpression = Expression.Call(GetRequiredServiceMethodInfo.MakeGenericMethod(parameter.ParameterType), requestServicesExpr); - //} - //else if (parameter.FromForm != null) - //{ - // needForm = true; - - // var formProperty = Expression.Property(httpRequestExpr, nameof(HttpRequest.Form)); - // paramterExpression = BindParamenter(formProperty, parameter, parameter.FromForm); - //} - //else if (parameter.FromBody) - if (parameter.CustomAttributes.Any(a => typeof(IFromBodyMetadata).IsAssignableFrom(a.AttributeType))) + if (parameter.GetCustomAttributes().OfType().FirstOrDefault() is { } routeAttribute) { - if (needBody) + var routeValuesProperty = Expression.Property(HttpRequestExpr, nameof(HttpRequest.RouteValues)); + paramterExpression = BindParamenter(routeValuesProperty, parameter, routeAttribute.Name); + } + else if (parameter.GetCustomAttributes().OfType().FirstOrDefault() is { } queryAttribute) + { + var queryProperty = Expression.Property(HttpRequestExpr, nameof(HttpRequest.Query)); + paramterExpression = BindParamenter(queryProperty, parameter, queryAttribute.Name); + } + else if (parameter.GetCustomAttributes().OfType().FirstOrDefault() is { } headerAttribute) + { + var headersProperty = Expression.Property(HttpRequestExpr, nameof(HttpRequest.Headers)); + paramterExpression = BindParamenter(headersProperty, parameter, headerAttribute.Name); + } + else if (parameter.CustomAttributes.Any(a => typeof(IFromBodyMetadata).IsAssignableFrom(a.AttributeType))) + { + if (consumeBodyDirectly) { throw new InvalidOperationException("Action cannot have more than one FromBody attribute."); } - if (needForm) + if (consumeBodyAsForm) { - throw new InvalidOperationException("Action cannot mix FromBody and FromForm on the same method."); + ThrowCannotReadBodyDirectlyAndAsForm(); } - needBody = true; + consumeBodyDirectly = true; bodyType = parameter.ParameterType; paramterExpression = Expression.Convert(DeserializedBodyArg, bodyType); } + else if (parameter.GetCustomAttributes().OfType().FirstOrDefault() is { } formAttribute) + { + if (consumeBodyDirectly) + { + ThrowCannotReadBodyDirectlyAndAsForm(); + } + + consumeBodyAsForm = true; + + var formProperty = Expression.Property(HttpRequestExpr, nameof(HttpRequest.Form)); + paramterExpression = BindParamenter(formProperty, parameter, parameter.Name); + } + else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType))) + { + paramterExpression = Expression.Call(GetRequiredServiceMethodInfo.MakeGenericMethod(parameter.ParameterType), RequestServicesExpr); + } else { if (parameter.ParameterType == typeof(IFormCollection)) { - needForm = true; + if (consumeBodyDirectly) + { + ThrowCannotReadBodyDirectlyAndAsForm(); + } + + consumeBodyAsForm = true; paramterExpression = Expression.Property(HttpRequestExpr, nameof(HttpRequest.Form)); } @@ -233,7 +238,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) Func? requestDelegate = null; - if (needBody) + if (consumeBodyDirectly) { // We need to generate the code for reading from the body before calling into the // delegate @@ -247,7 +252,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) await invoker(target, httpContext, bodyValue); }; } - else if (needForm) + else if (consumeBodyAsForm) { var lambda = Expression.Lambda>(body, TargetArg, HttpContextParameter); var invoker = lambda.Compile(); @@ -275,7 +280,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) }; } - private static Expression BindParamenter(Expression sourceExpression, ParameterInfo parameter, string name) + private static Expression BindParamenter(Expression sourceExpression, ParameterInfo parameter, string? name) { var key = name ?? parameter.Name; var type = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; @@ -372,6 +377,12 @@ private static async ValueTask ExecuteTaskResult(Task task, HttpContext ht await (await task).ExecuteAsync(httpContext); } + [StackTraceHidden] + private static void ThrowCannotReadBodyDirectlyAndAsForm() + { + throw new InvalidOperationException("Action cannot mix FromBody and FromForm on the same method."); + } + /// /// Equivalent to the IResult part of Microsoft.AspNetCore.Mvc.JsonResult /// diff --git a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs index 86153f05b973..8ddc33ebd92a 100644 --- a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs +++ b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs @@ -4,12 +4,16 @@ #nullable enable using System; +using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Text; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; using Xunit; namespace Microsoft.AspNetCore.Routing.Internal @@ -23,7 +27,7 @@ public async Task RequestDelegateInvokesAction() void TestAction() { invoked = true; - }; + } var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); @@ -32,6 +36,126 @@ void TestAction() Assert.True(invoked); } + [Fact] + public async Task RequestDelegatePopulatesFromRouteParameterBasedOnParameterName() + { + const string paramName = "value"; + const int originalRouteParam = 42; + + int? deserializedRouteParam = null; + + void TestAction([FromRoute] int value) + { + deserializedRouteParam = value; + } + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues[paramName] = originalRouteParam.ToString(NumberFormatInfo.InvariantInfo); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Equal(originalRouteParam, deserializedRouteParam); + } + + [Fact] + public async Task RequestDelegatePopulatesFromRouteParameterBasedOnAttributeNameProperty() + { + const string specifiedName = "value"; + const int originalRouteParam = 42; + + int? deserializedRouteParam = null; + + void TestAction([FromRoute(Name = specifiedName)] int foo) + { + deserializedRouteParam = foo; + } + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues[specifiedName] = originalRouteParam.ToString(NumberFormatInfo.InvariantInfo); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Equal(originalRouteParam, deserializedRouteParam); + } + + [Fact] + public async Task UsesDefaultValueIfNoMatchingRouteValue() + { + const string unmatchedName = "value"; + const int unmatchedRouteParam = 42; + + int? deserializedRouteParam = null; + + void TestAction([FromRoute] int foo) + { + deserializedRouteParam = foo; + } + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues[unmatchedName] = unmatchedRouteParam.ToString(NumberFormatInfo.InvariantInfo); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Equal(0, deserializedRouteParam); + } + + [Fact] + public async Task RequestDelegatePopulatesFromQueryParameterBasedOnParameterName() + { + const string paramName = "value"; + const int originalQueryParam = 42; + + int? deserializedRouteParam = null; + + void TestAction([FromQuery] int value) + { + deserializedRouteParam = value; + } + + var query = new QueryCollection(new Dictionary() + { + [paramName] = originalQueryParam.ToString(NumberFormatInfo.InvariantInfo) + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = query; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Equal(originalQueryParam, deserializedRouteParam); + } + + [Fact] + public async Task RequestDelegatePopulatesFromHeaderParameterBasedOnParameterName() + { + const string customHeaderName = "X-Custom-Header"; + const int originalHeaderParam = 42; + + int? deserializedRouteParam = null; + + void TestAction([FromHeader(Name = customHeaderName)] int value) + { + deserializedRouteParam = value; + } + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers[customHeaderName] = originalHeaderParam.ToString(NumberFormatInfo.InvariantInfo); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Equal(originalHeaderParam, deserializedRouteParam); + } + [Fact] public async Task RequestDelegatePopulatesFromBodyParameter() { @@ -45,7 +169,7 @@ public async Task RequestDelegatePopulatesFromBodyParameter() void TestAction([FromBody] Todo todo) { deserializedRequestBody = todo; - }; + } var httpContext = new DefaultHttpContext(); httpContext.Request.Headers["Content-Type"] = "application/json"; @@ -61,6 +185,116 @@ void TestAction([FromBody] Todo todo) Assert.Equal(originalTodo.Name, deserializedRequestBody!.Name); } + + [Fact] + public async Task RequestDelegatePopulatesFromFormParameterBasedOnParameterName() + { + const string paramName = "value"; + const int originalQueryParam = 42; + + int? deserializedRouteParam = null; + + void TestAction([FromForm] int value) + { + deserializedRouteParam = value; + } + + var form = new FormCollection(new Dictionary() + { + [paramName] = originalQueryParam.ToString(NumberFormatInfo.InvariantInfo) + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Form = form; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Equal(originalQueryParam, deserializedRouteParam); + } + + [Fact] + public void BuildRequestDelegateThrowsInvalidOperationExceptionGivenBothFromBodyAndFromFormOnDifferentParameters() + { + void TestAction([FromBody] int value1, [FromForm] int value2) { } + void TestActionWithFlippedParams([FromForm] int value1, [FromBody] int value2) { } + + Assert.Throws(() => MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction)); + Assert.Throws(() => MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestActionWithFlippedParams)); + } + + [Fact] + public void BuildRequestDelegateThrowsInvalidOperationExceptionGivenFromBodyOnMultipleParameters() + { + void TestAction([FromBody] int value1, [FromBody] int value2) { } + + Assert.Throws(() => MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction)); + } + + [Fact] + public async Task RequestDelegatePopulatesFromServiceParameterBasedOnParameterType() + { + var myOriginalService = new MyService(); + MyService? injectedService = null; + + void TestAction([FromService] MyService myService) + { + injectedService = myService; + } + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(myOriginalService); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Same(myOriginalService, injectedService); + } + + [Fact] + public async Task RequestDelegatePopulatesHttpContextParameterWithoutAttribute() + { + HttpContext? httpContextArgument = null; + + void TestAction(HttpContext httpContext) + { + httpContextArgument = httpContext; + } + + var httpContext = new DefaultHttpContext(); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Same(httpContext, httpContextArgument); + } + + [Fact] + public async Task RequestDelegatePopulatesIFormCollectionParameterWithoutAttribute() + { + IFormCollection? formCollectionArgument = null; + + void TestAction(IFormCollection httpContext) + { + formCollectionArgument = httpContext; + } + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Same(httpContext.Request.Form, formCollectionArgument); + } + [Fact] public async Task RequestDelegateWritesComplexReturnValueAsJsonResponseBody() { @@ -117,10 +351,38 @@ private class Todo public bool IsComplete { get; set; } } + private class FromRouteAttribute : Attribute, IFromRouteMetadata + { + public string? Name { get; set; } + } + + private class FromQueryAttribute : Attribute, IFromQueryMetadata + { + public string? Name { get; set; } + } + + private class FromHeaderAttribute : Attribute, IFromHeaderMetadata + { + public string? Name { get; set; } + } + private class FromBodyAttribute : Attribute, IFromBodyMetadata { } + private class FromFormAttribute : Attribute, IFromFormMetadata + { + public string? Name { get; set; } + } + + private class FromServiceAttribute : Attribute, IFromServiceMetadata + { + } + + private class MyService + { + } + private class CustomResult : IResult { private readonly string _resultString; diff --git a/src/Mvc/Mvc.Core/src/FromFormAttribute.cs b/src/Mvc/Mvc.Core/src/FromFormAttribute.cs index a42774f17f46..41e7823ea52b 100644 --- a/src/Mvc/Mvc.Core/src/FromFormAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromFormAttribute.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -10,7 +11,7 @@ namespace Microsoft.AspNetCore.Mvc /// Specifies that a parameter or property should be bound using form-data in the request body. /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] - public class FromFormAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider + public class FromFormAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromFormMetadata { /// public BindingSource BindingSource => BindingSource.Form; diff --git a/src/Mvc/Mvc.Core/src/FromHeaderAttribute.cs b/src/Mvc/Mvc.Core/src/FromHeaderAttribute.cs index 46809454f1cc..c4331287d84a 100644 --- a/src/Mvc/Mvc.Core/src/FromHeaderAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromHeaderAttribute.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -10,7 +11,7 @@ namespace Microsoft.AspNetCore.Mvc /// Specifies that a parameter or property should be bound using the request headers. /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] - public class FromHeaderAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider + public class FromHeaderAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromHeaderMetadata { /// public BindingSource BindingSource => BindingSource.Header; diff --git a/src/Mvc/Mvc.Core/src/FromQueryAttribute.cs b/src/Mvc/Mvc.Core/src/FromQueryAttribute.cs index df82f67bc322..9f594a27bd9d 100644 --- a/src/Mvc/Mvc.Core/src/FromQueryAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromQueryAttribute.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -10,7 +11,7 @@ namespace Microsoft.AspNetCore.Mvc /// Specifies that a parameter or property should be bound using the request query string. /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] - public class FromQueryAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider + public class FromQueryAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromQueryMetadata { /// public BindingSource BindingSource => BindingSource.Query; diff --git a/src/Mvc/Mvc.Core/src/FromServicesAttribute.cs b/src/Mvc/Mvc.Core/src/FromServicesAttribute.cs index 31e299de7373..41c993429383 100644 --- a/src/Mvc/Mvc.Core/src/FromServicesAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromServicesAttribute.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -23,7 +24,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] - public class FromServicesAttribute : Attribute, IBindingSourceMetadata + public class FromServicesAttribute : Attribute, IBindingSourceMetadata, IFromServiceMetadata { /// public BindingSource BindingSource => BindingSource.Services; From 629f09fc08c46e4b3058a13d9af99dc343d5ad2b Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Feb 2021 08:54:50 -0800 Subject: [PATCH 09/17] Add IFromBodyMetadata.AllowEmpty --- .../src/Metadata/IFromBodyMetadata.cs | 4 ++ .../src/PublicAPI.Unshipped.txt | 1 + .../MapActionExpressionTreeBuilder.cs | 21 +++++- .../MapActionExpressionTreeBuilderTest.cs | 66 +++++++++++++++++++ src/Mvc/Mvc.Core/src/FromBodyAttribute.cs | 4 ++ 5 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs index 4df9596600d9..878ec45dcc8a 100644 --- a/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IFromBodyMetadata.cs @@ -8,5 +8,9 @@ namespace Microsoft.AspNetCore.Http.Metadata /// public interface IFromBodyMetadata { + /// + /// Gets whether empty input should be rejected or treated as valid. + /// + bool AllowEmpty => false; } } diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 3e85b26eb958..9713f1c36563 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -5,6 +5,7 @@ *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, System.Type! middleware, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! Microsoft.AspNetCore.Http.Metadata.IFromBodyMetadata +Microsoft.AspNetCore.Http.Metadata.IFromBodyMetadata.AllowEmpty.get -> bool Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string? Microsoft.AspNetCore.Http.Metadata.IFromHeaderMetadata diff --git a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs index cf21a4c7d688..e18c2ddbb4d3 100644 --- a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs @@ -61,6 +61,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) var consumeBodyDirectly = false; var consumeBodyAsForm = false; Type? bodyType = null; + var allowEmptyBody = false; // This argument represents the deserialized body returned from IHttpRequestReader // when the method has a FromBody attribute declared @@ -86,7 +87,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) var headersProperty = Expression.Property(HttpRequestExpr, nameof(HttpRequest.Headers)); paramterExpression = BindParamenter(headersProperty, parameter, headerAttribute.Name); } - else if (parameter.CustomAttributes.Any(a => typeof(IFromBodyMetadata).IsAssignableFrom(a.AttributeType))) + else if (parameter.GetCustomAttributes().OfType().FirstOrDefault() is { } bodyAttribute) { if (consumeBodyDirectly) { @@ -99,6 +100,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) } consumeBodyDirectly = true; + allowEmptyBody = bodyAttribute.AllowEmpty; bodyType = parameter.ParameterType; paramterExpression = Expression.Convert(DeserializedBodyArg, bodyType); } @@ -244,10 +246,25 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) // delegate var lambda = Expression.Lambda>(body, TargetArg, HttpContextParameter, DeserializedBodyArg); var invoker = lambda.Compile(); + object? defaultBodyValue = null; + + if (allowEmptyBody && bodyType!.IsValueType) + { + defaultBodyValue = Activator.CreateInstance(bodyType); + } requestDelegate = async (target, httpContext) => { - var bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType!); + object? bodyValue; + + if (allowEmptyBody && httpContext.Request.ContentLength == 0) + { + bodyValue = defaultBodyValue; + } + else + { + bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType!); + } await invoker(target, httpContext, bodyValue); }; diff --git a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs index 8ddc33ebd92a..b17ec6386055 100644 --- a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs +++ b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs @@ -185,6 +185,66 @@ void TestAction([FromBody] Todo todo) Assert.Equal(originalTodo.Name, deserializedRequestBody!.Name); } + [Fact] + public async Task RequestDelegateRejectsEmptyBodyGivenDefaultFromBodyParameter() + { + void TestAction([FromBody] Todo todo) + { + } + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "0"; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await Assert.ThrowsAsync(() => requestDelegate(httpContext)); + } + + [Fact] + public async Task RequestDelegateAllowsEmptyBodyGivenCorrectyConfiguredFromBodyParameter() + { + var todoToBecomeNull = new Todo(); + + void TestAction([FromBody(AllowEmpty = true)] Todo todo) + { + todoToBecomeNull = todo; + } + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "0"; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Null(todoToBecomeNull); + } + + [Fact] + public async Task RequestDelegateAllowsEmptyBodyStructGivenCorrectyConfiguredFromBodyParameter() + { + var structToBeZeroed = new BodyStruct + { + Id = 42 + }; + + void TestAction([FromBody(AllowEmpty = true)] BodyStruct bodyStruct) + { + structToBeZeroed = bodyStruct; + } + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "0"; + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.Equal(default, structToBeZeroed); + } [Fact] public async Task RequestDelegatePopulatesFromFormParameterBasedOnParameterName() @@ -351,6 +411,11 @@ private class Todo public bool IsComplete { get; set; } } + private struct BodyStruct + { + public int Id { get; set; } + } + private class FromRouteAttribute : Attribute, IFromRouteMetadata { public string? Name { get; set; } @@ -368,6 +433,7 @@ private class FromHeaderAttribute : Attribute, IFromHeaderMetadata private class FromBodyAttribute : Attribute, IFromBodyMetadata { + public bool AllowEmpty { get; set; } } private class FromFormAttribute : Attribute, IFromFormMetadata diff --git a/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs b/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs index 9d1cca78846a..cf8f8228586c 100644 --- a/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs @@ -26,5 +26,9 @@ public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEm /// // REVIEW: What should we do about this? Type forward EmptyBodyBehavior? Write analyzers to warn against configuring this with MapAction? public EmptyBodyBehavior EmptyBodyBehavior { get; set; } + + // Since the default behavior is to reject empty bodies if MvcOptions.AllowEmptyInputInBodyModelBinding is not configured, + // we'll consider EmptyBodyBehavior.Default the same as EmptyBodyBehavior.Disallow. + bool IFromBodyMetadata.AllowEmpty => EmptyBodyBehavior == EmptyBodyBehavior.Allow; } } From e05c4c9528ce234bc0d767c8871755e2b9b6dee1 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Feb 2021 09:14:55 -0800 Subject: [PATCH 10/17] Cleanup --- .../MapActionExpressionTreeBuilder.cs | 27 +++---------------- .../test/FunctionalTests/MapActionTest.cs | 12 +++++---- ...ctionEndpointRouteBuilderExtensionsTest.cs | 1 - src/Mvc/Mvc.Core/src/FromBodyAttribute.cs | 1 - 4 files changed, 11 insertions(+), 30 deletions(-) diff --git a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs index e18c2ddbb4d3..dae0112a6be7 100644 --- a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs @@ -242,8 +242,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) if (consumeBodyDirectly) { - // We need to generate the code for reading from the body before calling into the - // delegate + // We need to generate the code for reading from the body before calling into the delegate var lambda = Expression.Lambda>(body, TargetArg, HttpContextParameter, DeserializedBodyArg); var invoker = lambda.Compile(); object? defaultBodyValue = null; @@ -356,19 +355,19 @@ private static MemberInfo GetMemberInfo(Expression expr) private static async ValueTask ExecuteTask(Task task, HttpContext httpContext) { - await new JsonResult(await task).ExecuteAsync(httpContext); + await httpContext.Response.WriteAsJsonAsync(await task); } private static Task ExecuteValueTask(ValueTask task, HttpContext httpContext) { static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext) { - await new JsonResult(await task).ExecuteAsync(httpContext); + await httpContext.Response.WriteAsJsonAsync(await task); } if (task.IsCompletedSuccessfully) { - return new JsonResult(task.GetAwaiter().GetResult()).ExecuteAsync(httpContext); + return httpContext.Response.WriteAsJsonAsync(task.GetAwaiter().GetResult()); } return ExecuteAwaited(task, httpContext); @@ -399,23 +398,5 @@ private static void ThrowCannotReadBodyDirectlyAndAsForm() { throw new InvalidOperationException("Action cannot mix FromBody and FromForm on the same method."); } - - /// - /// Equivalent to the IResult part of Microsoft.AspNetCore.Mvc.JsonResult - /// - private class JsonResult : IResult - { - public object? Value { get; } - - public JsonResult(object? value) - { - Value = value; - } - - public Task ExecuteAsync(HttpContext httpContext) - { - return httpContext.Response.WriteAsJsonAsync(Value); - } - } } } diff --git a/src/Http/Routing/test/FunctionalTests/MapActionTest.cs b/src/Http/Routing/test/FunctionalTests/MapActionTest.cs index aed2835e4826..f48d45ee1b3d 100644 --- a/src/Http/Routing/test/FunctionalTests/MapActionTest.cs +++ b/src/Http/Routing/test/FunctionalTests/MapActionTest.cs @@ -22,8 +22,8 @@ public class MapActionTest [Fact] public async Task MapAction_FromBodyWorksWithJsonPayload() { - [HttpPost("/EchoTodo")] - Todo EchoTodo([FromBody] Todo todo) => todo; + [HttpPost("/EchoTodo/{id}")] + Todo EchoTodo([FromRoute] int id, [FromBody] Todo todo) => todo with { Id = id }; using var host = new HostBuilder() .ConfigureWebHost(webHostBuilder => @@ -32,7 +32,7 @@ public async Task MapAction_FromBodyWorksWithJsonPayload() .Configure(app => { app.UseRouting(); - app.UseEndpoints(b => b.MapAction((Func)EchoTodo)); + app.UseEndpoints(b => b.MapAction((Func)EchoTodo)); }) .UseTestServer(); }) @@ -51,15 +51,17 @@ public async Task MapAction_FromBodyWorksWithJsonPayload() Name = "Write tests!" }; - var response = await client.PostAsJsonAsync("/EchoTodo", todo); + var response = await client.PostAsJsonAsync("/EchoTodo/42", todo); response.EnsureSuccessStatusCode(); var echoedTodo = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(echoedTodo); Assert.Equal(todo.Name, echoedTodo?.Name); + Assert.Equal(42, echoedTodo?.Id); } - private class Todo + private record Todo { public int Id { get; set; } public string Name { get; set; } = "Todo"; diff --git a/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs index 8c6d3e291fdd..7ead738d18d0 100644 --- a/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/MapActionEndpointRouteBuilderExtensionsTest.cs @@ -4,7 +4,6 @@ #nullable enable using System; -using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.TestObjects; diff --git a/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs b/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs index cf8f8228586c..c3d1b46f3ddc 100644 --- a/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromBodyAttribute.cs @@ -24,7 +24,6 @@ public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEm /// The default behavior is to use framework defaults as configured by . /// Specifying or will override the framework defaults. /// - // REVIEW: What should we do about this? Type forward EmptyBodyBehavior? Write analyzers to warn against configuring this with MapAction? public EmptyBodyBehavior EmptyBodyBehavior { get; set; } // Since the default behavior is to reject empty bodies if MvcOptions.AllowEmptyInputInBodyModelBinding is not configured, From 284d1a120550b8fdf7c8a55ad0c605b7ade09f76 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Feb 2021 09:41:33 -0800 Subject: [PATCH 11/17] Add MapActionEndpointConventionBuilder --- .../MapActionEndpointConventionBuilder.cs | 33 +++++++++++++++++++ ...MapActionEndpointRouteBuilderExtensions.cs | 25 ++------------ src/Http/Routing/src/PublicAPI.Unshipped.txt | 4 ++- 3 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 src/Http/Routing/src/Builder/MapActionEndpointConventionBuilder.cs diff --git a/src/Http/Routing/src/Builder/MapActionEndpointConventionBuilder.cs b/src/Http/Routing/src/Builder/MapActionEndpointConventionBuilder.cs new file mode 100644 index 000000000000..4491b0305739 --- /dev/null +++ b/src/Http/Routing/src/Builder/MapActionEndpointConventionBuilder.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Builds conventions that will be used for customization of MapAction instances. + /// + public sealed class MapActionEndpointConventionBuilder : IEndpointConventionBuilder + { + private readonly List _endpointConventionBuilders; + + internal MapActionEndpointConventionBuilder(List endpointConventionBuilders) + { + _endpointConventionBuilders = endpointConventionBuilders; + } + + /// + /// Adds the specified convention to the builder. Conventions are used to customize instances. + /// + /// The convention to add to the builder. + public void Add(Action convention) + { + foreach (var endpointConventionBuilder in _endpointConventionBuilders) + { + endpointConventionBuilder.Add(convention); + } + } + } +} diff --git a/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs index dc29143d8cd9..e132e673ea32 100644 --- a/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/MapActionEndpointRouteBuilderExtensions.cs @@ -20,8 +20,8 @@ public static class MapActionEndpointRouteBuilderExtensions /// /// The to add the route to. /// The delegate executed when the endpoint is matched. - /// A that can be used to further customize the endpoint. - public static IEndpointConventionBuilder MapAction( + /// An that can be used to further customize the endpoint. + public static MapActionEndpointConventionBuilder MapAction( this IEndpointRouteBuilder endpoints, Delegate action) { @@ -61,7 +61,6 @@ public static IEndpointConventionBuilder MapAction( endpointBuilder.Metadata.Add(attribute); } - endpointBuilder.DisplayName = routeName ?? pattern; ((RouteEndpointBuilder)endpointBuilder).Order = routeOrder ?? defaultOrder; @@ -75,25 +74,7 @@ public static IEndpointConventionBuilder MapAction( throw new InvalidOperationException("Action must have a pattern. Is it missing a Route attribute?"); } - return new CompositeEndpointConventionBuilder(conventionBuilders); - } - - private class CompositeEndpointConventionBuilder : IEndpointConventionBuilder - { - private readonly List _endpointConventionBuilders; - - public CompositeEndpointConventionBuilder(List endpointConventionBuilders) - { - _endpointConventionBuilders = endpointConventionBuilders; - } - - public void Add(Action convention) - { - foreach (var endpointConventionBuilder in _endpointConventionBuilders) - { - endpointConventionBuilder.Add(convention); - } - } + return new MapActionEndpointConventionBuilder(conventionBuilders); } } } diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 3e12f6cb1a05..adacdfd54759 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -5,6 +5,8 @@ *REMOVED*Microsoft.AspNetCore.Routing.IRouteNameMetadata.RouteName.get -> string! *REMOVED*Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteName.get -> string! *REMOVED*Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteNameMetadata(string! routeName) -> void +Microsoft.AspNetCore.Builder.MapActionEndpointConventionBuilder +Microsoft.AspNetCore.Builder.MapActionEndpointConventionBuilder.Add(System.Action! convention) -> void Microsoft.AspNetCore.Builder.MapActionEndpointRouteBuilderExtensions Microsoft.AspNetCore.Routing.DataTokensMetadata.DataTokens.get -> System.Collections.Generic.IReadOnlyDictionary! Microsoft.AspNetCore.Routing.DataTokensMetadata.DataTokensMetadata(System.Collections.Generic.IReadOnlyDictionary! dataTokens) -> void @@ -16,4 +18,4 @@ Microsoft.AspNetCore.Routing.IRoutePatternMetadata Microsoft.AspNetCore.Routing.IRoutePatternMetadata.RoutePattern.get -> string? Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteName.get -> string? Microsoft.AspNetCore.Routing.RouteNameMetadata.RouteNameMetadata(string? routeName) -> void -static Microsoft.AspNetCore.Builder.MapActionEndpointRouteBuilderExtensions.MapAction(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, System.Delegate! action) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +static Microsoft.AspNetCore.Builder.MapActionEndpointRouteBuilderExtensions.MapAction(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MapActionEndpointConventionBuilder! From dac8375ccf8a5d51fc3c7cd92489433a2752cb92 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Feb 2021 12:09:40 -0800 Subject: [PATCH 12/17] Fix GetDescriptors_ActionWithMultipleHttpMethods_LastHttpMethodMetadata --- .../ControllerActionDescriptorProviderTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/ControllerActionDescriptorProviderTests.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/ControllerActionDescriptorProviderTests.cs index f7f9eba93b8a..1b4b93e2686f 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/ControllerActionDescriptorProviderTests.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/ControllerActionDescriptorProviderTests.cs @@ -304,7 +304,7 @@ public void GetDescriptors_ActionWithHttpMethods_AddedToEndpointMetadata() } [Fact] - public void GetDescriptors_ActionWithMultipleHttpMethods_SingleHttpMethodMetadata() + public void GetDescriptors_ActionWithMultipleHttpMethods_LastHttpMethodMetadata() { // Arrange & Act var descriptors = GetDescriptors( @@ -329,9 +329,9 @@ Action InspectElement(string httpMethod) var httpMethodAttribute = Assert.Single(descriptor.EndpointMetadata.OfType()); Assert.Equal(httpMethod, httpMethodAttribute.HttpMethods.Single(), ignoreCase: true); - var httpMethodMetadata = Assert.Single(descriptor.EndpointMetadata.OfType()); - Assert.Equal(httpMethod, httpMethodMetadata.HttpMethods.Single(), ignoreCase: true); - Assert.False(httpMethodMetadata.AcceptCorsPreflight); + var lastHttpMethodMetadata = descriptor.EndpointMetadata.OfType().Last(); + Assert.Equal(httpMethod, lastHttpMethodMetadata.HttpMethods.Single(), ignoreCase: true); + Assert.False(lastHttpMethodMetadata.AcceptCorsPreflight); }; } } From c6bc2cfc5954a4cea23c8298475c05ab1ddbe592 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Feb 2021 17:10:27 -0800 Subject: [PATCH 13/17] Lower severity of logs caused by request body IOExceptions --- .../MapActionExpressionTreeBuilder.cs | 49 ++++++- .../MapActionExpressionTreeBuilderTest.cs | 138 ++++++++++++++++++ 2 files changed, 185 insertions(+), 2 deletions(-) diff --git a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs index dae0112a6be7..88ae5f53ac33 100644 --- a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Routing.Internal { @@ -262,7 +264,16 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) } else { - bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType!); + try + { + bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType!); + } + catch (IOException ex) + { + LogRequestBodyIOException(httpContext, ex); + httpContext.Abort(); + return; + } } await invoker(target, httpContext, bodyValue); @@ -277,7 +288,16 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) { // Generating async code would just be insane so if the method needs the form populate it here // so the within the method it's cached - await httpContext.Request.ReadFormAsync(); + try + { + await httpContext.Request.ReadFormAsync(); + } + catch (IOException ex) + { + LogRequestBodyIOException(httpContext, ex); + httpContext.Abort(); + return; + } await invoker(target, httpContext); }; @@ -296,6 +316,18 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) }; } + private static void LogRequestBodyIOException(HttpContext httpContext, IOException exception) + { + var loggerFactory = httpContext.RequestServices.GetRequiredService(); + + // REVIEW: I'm not sure how we feel about using an internal type as a category name, but this is what was done in other + // internal classes like DfaMather and DefaultLinkGenerator. My main concern is if we want to use the same category for + // other MapAction implementations (e.g. Roslyn source generators) + var logger = loggerFactory.CreateLogger(typeof(MapActionExpressionTreeBuilder).FullName!); + + Log.RequestBodyIOException(logger, exception); + } + private static Expression BindParamenter(Expression sourceExpression, ParameterInfo parameter, string? name) { var key = name ?? parameter.Name; @@ -398,5 +430,18 @@ private static void ThrowCannotReadBodyDirectlyAndAsForm() { throw new InvalidOperationException("Action cannot mix FromBody and FromForm on the same method."); } + + private static class Log + { + private static readonly Action _requestBodyIOException = LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, "RequestBodyIOException"), + "Reading the request body failed with an IOException."); + + public static void RequestBodyIOException(ILogger logger, IOException exception) + { + _requestBodyIOException(logger, exception); + } + } } } diff --git a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs index b17ec6386055..cc5e991272ca 100644 --- a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs +++ b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs @@ -9,10 +9,14 @@ using System.IO; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Primitives; using Xunit; @@ -24,6 +28,7 @@ public class MapActionExpressionTreeBuilderTest public async Task RequestDelegateInvokesAction() { var invoked = false; + void TestAction() { invoked = true; @@ -87,6 +92,10 @@ public async Task UsesDefaultValueIfNoMatchingRouteValue() { const string unmatchedName = "value"; const int unmatchedRouteParam = 42; + var structToBeZeroed = new BodyStruct + { + Id = 42 + }; int? deserializedRouteParam = null; @@ -246,6 +255,42 @@ void TestAction([FromBody(AllowEmpty = true)] BodyStruct bodyStruct) Assert.Equal(default, structToBeZeroed); } + [Fact] + public async Task RequestDelegateLogsFromBodyIOExceptionsAsDebug() + { + var invoked = false; + + var sink = new TestSink(context => context.LoggerName == typeof(MapActionExpressionTreeBuilder).FullName); + var testLoggerFactory = new TestLoggerFactory(sink, enabled: true); + + void TestAction([FromBody] Todo todo) + { + invoked = true; + } + + var ioException = new IOException(); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(testLoggerFactory); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Body = new IOExceptionThrowingRequestBodyStream(ioException); + httpContext.Features.Set(new TestHttpRequestLifetimeFeature()); + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.True(httpContext.RequestAborted.IsCancellationRequested); + + var logMessage = Assert.Single(sink.Writes); + Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Same(ioException, logMessage.Exception); + } + [Fact] public async Task RequestDelegatePopulatesFromFormParameterBasedOnParameterName() { @@ -274,6 +319,42 @@ void TestAction([FromForm] int value) Assert.Equal(originalQueryParam, deserializedRouteParam); } + [Fact] + public async Task RequestDelegateLogsFromFormIOExceptionsAsDebug() + { + var invoked = false; + + var sink = new TestSink(context => context.LoggerName == typeof(MapActionExpressionTreeBuilder).FullName); + var testLoggerFactory = new TestLoggerFactory(sink, enabled: true); + + void TestAction([FromForm] int value) + { + invoked = true; + } + + var ioException = new IOException(); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(testLoggerFactory); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Body = new IOExceptionThrowingRequestBodyStream(ioException); + httpContext.Features.Set(new TestHttpRequestLifetimeFeature()); + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.True(httpContext.RequestAborted.IsCancellationRequested); + + var logMessage = Assert.Single(sink.Writes); + Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Same(ioException, logMessage.Exception); + } + [Fact] public void BuildRequestDelegateThrowsInvalidOperationExceptionGivenBothFromBodyAndFromFormOnDifferentParameters() { @@ -463,5 +544,62 @@ public Task ExecuteAsync(HttpContext httpContext) return httpContext.Response.WriteAsync(_resultString); } } + + private class IOExceptionThrowingRequestBodyStream : Stream + { + private readonly Exception _exceptionToThrow; + + public IOExceptionThrowingRequestBodyStream(Exception exceptionToThrow) + { + _exceptionToThrow = exceptionToThrow; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotImplementedException(); + + public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public override void Flush() + { + throw new NotImplementedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw _exceptionToThrow; + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotImplementedException(); + } + + public override void SetLength(long value) + { + throw new NotImplementedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + } + + private class TestHttpRequestLifetimeFeature : IHttpRequestLifetimeFeature + { + private readonly CancellationTokenSource _requestAbortedCts = new CancellationTokenSource(); + + public CancellationToken RequestAborted { get => _requestAbortedCts.Token; set => throw new NotImplementedException(); } + + public void Abort() + { + _requestAbortedCts.Cancel(); + } + } } } From d055ba4b1167b84a03ce37efbc5172375a8a369e Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Feb 2021 17:20:24 -0800 Subject: [PATCH 14/17] cleanup --- .../UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs index cc5e991272ca..6b0337a957c1 100644 --- a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs +++ b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs @@ -92,10 +92,6 @@ public async Task UsesDefaultValueIfNoMatchingRouteValue() { const string unmatchedName = "value"; const int unmatchedRouteParam = 42; - var structToBeZeroed = new BodyStruct - { - Id = 42 - }; int? deserializedRouteParam = null; From e167639c81acfe0134eaa77cf5243ef41c662090 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Feb 2021 17:28:13 -0800 Subject: [PATCH 15/17] Use MapActionEndpointRouteBuilderExtensions for logger category --- .../src/Internal/MapActionExpressionTreeBuilder.cs | 8 ++------ .../Internal/MapActionExpressionTreeBuilderTest.cs | 5 +++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs index 88ae5f53ac33..058c0fb86b2e 100644 --- a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs @@ -11,6 +11,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; @@ -319,12 +320,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) private static void LogRequestBodyIOException(HttpContext httpContext, IOException exception) { var loggerFactory = httpContext.RequestServices.GetRequiredService(); - - // REVIEW: I'm not sure how we feel about using an internal type as a category name, but this is what was done in other - // internal classes like DfaMather and DefaultLinkGenerator. My main concern is if we want to use the same category for - // other MapAction implementations (e.g. Roslyn source generators) - var logger = loggerFactory.CreateLogger(typeof(MapActionExpressionTreeBuilder).FullName!); - + var logger = loggerFactory.CreateLogger(typeof(MapActionEndpointRouteBuilderExtensions).FullName!); Log.RequestBodyIOException(logger, exception); } diff --git a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs index 6b0337a957c1..21ba508edf96 100644 --- a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs +++ b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Metadata; @@ -256,7 +257,7 @@ public async Task RequestDelegateLogsFromBodyIOExceptionsAsDebug() { var invoked = false; - var sink = new TestSink(context => context.LoggerName == typeof(MapActionExpressionTreeBuilder).FullName); + var sink = new TestSink(context => context.LoggerName == typeof(MapActionEndpointRouteBuilderExtensions).FullName); var testLoggerFactory = new TestLoggerFactory(sink, enabled: true); void TestAction([FromBody] Todo todo) @@ -320,7 +321,7 @@ public async Task RequestDelegateLogsFromFormIOExceptionsAsDebug() { var invoked = false; - var sink = new TestSink(context => context.LoggerName == typeof(MapActionExpressionTreeBuilder).FullName); + var sink = new TestSink(context => context.LoggerName == typeof(MapActionEndpointRouteBuilderExtensions).FullName); var testLoggerFactory = new TestLoggerFactory(sink, enabled: true); void TestAction([FromForm] int value) From dae72ad2b2cedddeee15a80a31b85bc0a84e99fe Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 18 Feb 2021 10:10:45 -0800 Subject: [PATCH 16/17] Address PR feedback --- src/Http/Http.Abstractions/src/{Metadata => }/IResult.cs | 2 +- src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt | 4 ++-- .../Routing/src/Internal/MapActionExpressionTreeBuilder.cs | 3 +-- .../UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs | 5 ++--- src/Mvc/Mvc.Core/src/JsonResult.cs | 1 - src/Mvc/Mvc.Core/src/StatusCodeResult.cs | 1 - 6 files changed, 6 insertions(+), 10 deletions(-) rename src/Http/Http.Abstractions/src/{Metadata => }/IResult.cs (93%) diff --git a/src/Http/Http.Abstractions/src/Metadata/IResult.cs b/src/Http/Http.Abstractions/src/IResult.cs similarity index 93% rename from src/Http/Http.Abstractions/src/Metadata/IResult.cs rename to src/Http/Http.Abstractions/src/IResult.cs index c3fec8050dc7..a6c20a6ebded 100644 --- a/src/Http/Http.Abstractions/src/Metadata/IResult.cs +++ b/src/Http/Http.Abstractions/src/IResult.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; -namespace Microsoft.AspNetCore.Http.Metadata +namespace Microsoft.AspNetCore.Http { /// /// Defines a contract that represents the result of an HTTP endpoint. diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 9713f1c36563..28f8d0317d61 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -4,6 +4,8 @@ *REMOVED*Microsoft.AspNetCore.Routing.RouteValueDictionary.TryAdd(string! key, object! value) -> bool *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, System.Type! middleware, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! *REMOVED*static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, params object![]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! +Microsoft.AspNetCore.Http.IResult +Microsoft.AspNetCore.Http.IResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Http.Metadata.IFromBodyMetadata Microsoft.AspNetCore.Http.Metadata.IFromBodyMetadata.AllowEmpty.get -> bool Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata @@ -15,8 +17,6 @@ Microsoft.AspNetCore.Http.Metadata.IFromQueryMetadata.Name.get -> string? Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata.Name.get -> string? Microsoft.AspNetCore.Http.Metadata.IFromServiceMetadata -Microsoft.AspNetCore.Http.Metadata.IResult -Microsoft.AspNetCore.Http.Metadata.IResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Http.Endpoint.Endpoint(Microsoft.AspNetCore.Http.RequestDelegate? requestDelegate, Microsoft.AspNetCore.Http.EndpointMetadataCollection? metadata, string? displayName) -> void Microsoft.AspNetCore.Http.Endpoint.RequestDelegate.get -> Microsoft.AspNetCore.Http.RequestDelegate? Microsoft.AspNetCore.Routing.RouteValueDictionary.TryAdd(string! key, object? value) -> bool diff --git a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs index 058c0fb86b2e..b6f9f07c5e04 100644 --- a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs @@ -11,7 +11,6 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; @@ -320,7 +319,7 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) private static void LogRequestBodyIOException(HttpContext httpContext, IOException exception) { var loggerFactory = httpContext.RequestServices.GetRequiredService(); - var logger = loggerFactory.CreateLogger(typeof(MapActionEndpointRouteBuilderExtensions).FullName!); + var logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Routing.MapAction"); Log.RequestBodyIOException(logger, exception); } diff --git a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs index 21ba508edf96..012de8455cb0 100644 --- a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs +++ b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Metadata; @@ -257,7 +256,7 @@ public async Task RequestDelegateLogsFromBodyIOExceptionsAsDebug() { var invoked = false; - var sink = new TestSink(context => context.LoggerName == typeof(MapActionEndpointRouteBuilderExtensions).FullName); + var sink = new TestSink(context => context.LoggerName == "Microsoft.AspNetCore.Routing.MapAction"); var testLoggerFactory = new TestLoggerFactory(sink, enabled: true); void TestAction([FromBody] Todo todo) @@ -321,7 +320,7 @@ public async Task RequestDelegateLogsFromFormIOExceptionsAsDebug() { var invoked = false; - var sink = new TestSink(context => context.LoggerName == typeof(MapActionEndpointRouteBuilderExtensions).FullName); + var sink = new TestSink(context => context.LoggerName == "Microsoft.AspNetCore.Routing.MapAction"); var testLoggerFactory = new TestLoggerFactory(sink, enabled: true); void TestAction([FromForm] int value) diff --git a/src/Mvc/Mvc.Core/src/JsonResult.cs b/src/Mvc/Mvc.Core/src/JsonResult.cs index 515b2bba85ec..d4b23097e969 100644 --- a/src/Mvc/Mvc.Core/src/JsonResult.cs +++ b/src/Mvc/Mvc.Core/src/JsonResult.cs @@ -5,7 +5,6 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Mvc/Mvc.Core/src/StatusCodeResult.cs b/src/Mvc/Mvc.Core/src/StatusCodeResult.cs index fb027f21dc5e..30d9d84fbf1e 100644 --- a/src/Mvc/Mvc.Core/src/StatusCodeResult.cs +++ b/src/Mvc/Mvc.Core/src/StatusCodeResult.cs @@ -4,7 +4,6 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; From 15f2e6d0589013aceb0b3e8a29ff10ea8e0b3a45 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 18 Feb 2021 13:33:25 -0800 Subject: [PATCH 17/17] Catch and log InvalidDataExceptions --- .../MapActionExpressionTreeBuilder.cs | 31 ++++++-- .../MapActionExpressionTreeBuilderTest.cs | 78 ++++++++++++++++++- 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs index b6f9f07c5e04..10be2443b5e7 100644 --- a/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs +++ b/src/Http/Routing/src/Internal/MapActionExpressionTreeBuilder.cs @@ -270,10 +270,16 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) } catch (IOException ex) { - LogRequestBodyIOException(httpContext, ex); + Log.RequestBodyIOException(GetLogger(httpContext), ex); httpContext.Abort(); return; } + catch (InvalidDataException ex) + { + Log.RequestBodyInvalidDataException(GetLogger(httpContext), ex); + httpContext.Response.StatusCode = 400; + return; + } } await invoker(target, httpContext, bodyValue); @@ -294,10 +300,16 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) } catch (IOException ex) { - LogRequestBodyIOException(httpContext, ex); + Log.RequestBodyIOException(GetLogger(httpContext), ex); httpContext.Abort(); return; } + catch (InvalidDataException ex) + { + Log.RequestBodyInvalidDataException(GetLogger(httpContext), ex); + httpContext.Response.StatusCode = 400; + return; + } await invoker(target, httpContext); }; @@ -316,11 +328,10 @@ public static RequestDelegate BuildRequestDelegate(Delegate action) }; } - private static void LogRequestBodyIOException(HttpContext httpContext, IOException exception) + private static ILogger GetLogger(HttpContext httpContext) { var loggerFactory = httpContext.RequestServices.GetRequiredService(); - var logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Routing.MapAction"); - Log.RequestBodyIOException(logger, exception); + return loggerFactory.CreateLogger("Microsoft.AspNetCore.Routing.MapAction"); } private static Expression BindParamenter(Expression sourceExpression, ParameterInfo parameter, string? name) @@ -433,10 +444,20 @@ private static class Log new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + private static readonly Action _requestBodyInvalidDataException = LoggerMessage.Define( + LogLevel.Debug, + new EventId(2, "RequestBodyInvalidDataException"), + "Reading the request body failed with an InvalidDataException."); + public static void RequestBodyIOException(ILogger logger, IOException exception) { _requestBodyIOException(logger, exception); } + + public static void RequestBodyInvalidDataException(ILogger logger, InvalidDataException exception) + { + _requestBodyInvalidDataException(logger, exception); + } } } } diff --git a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs index 012de8455cb0..ba06ca795f16 100644 --- a/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs +++ b/src/Http/Routing/test/UnitTests/Internal/MapActionExpressionTreeBuilderTest.cs @@ -252,7 +252,7 @@ void TestAction([FromBody(AllowEmpty = true)] BodyStruct bodyStruct) } [Fact] - public async Task RequestDelegateLogsFromBodyIOExceptionsAsDebug() + public async Task RequestDelegateLogsFromBodyIOExceptionsAsDebugAndAborts() { var invoked = false; @@ -287,6 +287,43 @@ void TestAction([FromBody] Todo todo) Assert.Same(ioException, logMessage.Exception); } + [Fact] + public async Task RequestDelegateLogsFromBodyInvalidDataExceptionsAsDebugAndSets400Response() + { + var invoked = false; + + var sink = new TestSink(context => context.LoggerName == "Microsoft.AspNetCore.Routing.MapAction"); + var testLoggerFactory = new TestLoggerFactory(sink, enabled: true); + + void TestAction([FromBody] Todo todo) + { + invoked = true; + } + + var invalidDataException = new InvalidDataException(); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(testLoggerFactory); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Body = new IOExceptionThrowingRequestBodyStream(invalidDataException); + httpContext.Features.Set(new TestHttpRequestLifetimeFeature()); + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + + var logMessage = Assert.Single(sink.Writes); + Assert.Equal(new EventId(2, "RequestBodyInvalidDataException"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Same(invalidDataException, logMessage.Exception); + } + [Fact] public async Task RequestDelegatePopulatesFromFormParameterBasedOnParameterName() { @@ -316,7 +353,7 @@ void TestAction([FromForm] int value) } [Fact] - public async Task RequestDelegateLogsFromFormIOExceptionsAsDebug() + public async Task RequestDelegateLogsFromFormIOExceptionsAsDebugAndAborts() { var invoked = false; @@ -351,6 +388,43 @@ void TestAction([FromForm] int value) Assert.Same(ioException, logMessage.Exception); } + [Fact] + public async Task RequestDelegateLogsFromFormInvalidDataExceptionsAsDebugAndSets400Response() + { + var invoked = false; + + var sink = new TestSink(context => context.LoggerName == "Microsoft.AspNetCore.Routing.MapAction"); + var testLoggerFactory = new TestLoggerFactory(sink, enabled: true); + + void TestAction([FromForm] int value) + { + invoked = true; + } + + var invalidDataException = new InvalidDataException(); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(testLoggerFactory); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Body = new IOExceptionThrowingRequestBodyStream(invalidDataException); + httpContext.Features.Set(new TestHttpRequestLifetimeFeature()); + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + var requestDelegate = MapActionExpressionTreeBuilder.BuildRequestDelegate((Action)TestAction); + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + + var logMessage = Assert.Single(sink.Writes); + Assert.Equal(new EventId(2, "RequestBodyInvalidDataException"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Same(invalidDataException, logMessage.Exception); + } + [Fact] public void BuildRequestDelegateThrowsInvalidOperationExceptionGivenBothFromBodyAndFromFormOnDifferentParameters() {