diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index a05c8006992a..a50771cf7905 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; @@ -10,6 +11,39 @@ namespace Microsoft.AspNetCore.Builder; +internal class MetadataOnlyEndpointMetadata +{ + +} + +internal class MetadataOnlyEndpoint : Endpoint +{ + public static readonly RequestDelegate NoOpRequestDelegate = (ctx) => Task.CompletedTask; + + public MetadataOnlyEndpoint(Endpoint endpoint) + : base(null, endpoint.Metadata, GetDisplayName(endpoint)) + { + + } + + public MetadataOnlyEndpoint(Endpoint endpoint, IReadOnlyList metadata) + : base(null, new(endpoint.Metadata.Union(metadata)), GetDisplayName(endpoint)) + { + + } + + public static bool IsMetadataOnlyEndpoint(Endpoint endpoint) => + ReferenceEquals(endpoint.RequestDelegate, NoOpRequestDelegate); + + private static string GetDisplayName(Endpoint endpoint) + { + var suffix = $"[{nameof(MetadataOnlyEndpoint)}]"; + return !string.IsNullOrEmpty(endpoint.DisplayName) + ? endpoint.DisplayName + " " + suffix + : suffix; + } +} + /// /// Provides extension methods for to add endpoints. /// @@ -154,6 +188,23 @@ public static IEndpointConventionBuilder MapMethods( return endpoints.Map(RoutePatternFactory.Parse(pattern), requestDelegate, httpMethods); } + /// + /// Adds a to the that adds the provided metadata items to + /// any mapped to HTTP requests for the specified pattern. + /// + /// The builder. + /// The route pattern. + /// A collection of metadata items. + /// An that can be used to further customize the endpoint. + public static IEndpointConventionBuilder MapMetadata(this IEndpointRouteBuilder endpoints, + [StringSyntax("Route")] string pattern, + params object[] items) + { + return endpoints.Map(pattern, MetadataOnlyEndpoint.NoOpRequestDelegate) + .WithMetadata(new MetadataOnlyEndpointMetadata()) + .WithMetadata(items); + } + /// /// Adds a to the that matches HTTP requests /// for the specified pattern. diff --git a/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs b/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs index 6c912748bca5..119915078c21 100644 --- a/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs +++ b/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs @@ -2,6 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.ObjectModel; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Internal; using Microsoft.AspNetCore.Routing.Matching; @@ -88,6 +93,8 @@ public static IServiceCollection AddRouting(this IServiceCollection services) services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + // TODO: Make this 1st class instead + services.TryAddEnumerable(ServiceDescriptor.Singleton()); // // Misc infrastructure @@ -120,3 +127,139 @@ public static IServiceCollection AddRouting( return services; } } + +internal class EndpointMetadataDecoratorMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy +{ + private readonly ConditionalWeakTable _endpointsCache = new(); + + public override int Order { get; } + + public bool AppliesToEndpoints(IReadOnlyList endpoints) + { + return endpoints.Any(e => MetadataOnlyEndpoint.IsMetadataOnlyEndpoint(e) + && e.Metadata.GetMetadata() is not null); + } + + public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) + { + // Try to find cache entry for single candidate + var firstCandidate = candidates[0]; + Endpoint? cachedEndpoint; + if (candidates.Count == 1 && _endpointsCache.TryGetValue(firstCandidate.Endpoint, out cachedEndpoint)) + { + // Only use the current request's route values if the candidate match is an actual endpoint + var values = !MetadataOnlyEndpoint.IsMetadataOnlyEndpoint(firstCandidate.Endpoint) + ? firstCandidate.Values + : null; + candidates.ReplaceEndpoint(0, cachedEndpoint, values); + return Task.CompletedTask; + } + + // Fallback to looping through all candiates + Endpoint? firstMetadataOnlyEndpoint = null; + // PERF: Use a list type optimized for small item counts instead + List? metadataOnlyEndpoints = null; + var replacementCandidateIndex = -1; + var realEndpointCandidateCount = 0; + + for (int i = 0; i < candidates.Count; i++) + { + var candidate = candidates[i]; + + if (MetadataOnlyEndpoint.IsMetadataOnlyEndpoint(candidate.Endpoint)) + { + if (firstMetadataOnlyEndpoint is null) + { + firstMetadataOnlyEndpoint = candidate.Endpoint; + } + else + { + if (metadataOnlyEndpoints is null) + { + metadataOnlyEndpoints = new List + { + firstMetadataOnlyEndpoint + }; + } + metadataOnlyEndpoints.Add(candidate.Endpoint); + } + if (realEndpointCandidateCount == 0 && replacementCandidateIndex == -1) + { + // Only capture index of first metadata only endpoint as candidate replacement + replacementCandidateIndex = i; + } + } + else + { + realEndpointCandidateCount++; + if (realEndpointCandidateCount == 1) + { + // Only first real endpoint is a candidate + replacementCandidateIndex = i; + } + } + } + + Debug.Assert(firstMetadataOnlyEndpoint is not null); + Debug.Assert(metadataOnlyEndpoints?.Count >= 1 || firstMetadataOnlyEndpoint is not null); + Debug.Assert(replacementCandidateIndex >= 0); + + var activeCandidate = candidates[replacementCandidateIndex]; + var activeEndpoint = (RouteEndpoint)activeCandidate.Endpoint; + + // TODO: Review what the correct behavior is if there is more than 1 real endpoint candidate. + + if (realEndpointCandidateCount is 0 or 1 && activeEndpoint is not null) + { + Endpoint? replacementEndpoint = null; + + // Check cache for replacement endpoint + if (!_endpointsCache.TryGetValue(activeEndpoint, out replacementEndpoint)) + { + // Not found in cache so build up the replacement endpoint + IReadOnlyList decoratedMetadata = metadataOnlyEndpoints is not null + ? metadataOnlyEndpoints.SelectMany(e => e.Metadata).ToList() + : firstMetadataOnlyEndpoint.Metadata; + + if (realEndpointCandidateCount == 1) + { + var routeEndpointBuilder = new RouteEndpointBuilder(activeEndpoint.RequestDelegate!, activeEndpoint.RoutePattern, activeEndpoint.Order); + + routeEndpointBuilder.DisplayName = activeEndpoint.DisplayName; + + // Add metadata from metadata-only endpoint candidates + foreach (var metadata in decoratedMetadata) + { + routeEndpointBuilder.Metadata.Add(metadata); + } + + // Add metadata from active endpoint + if (realEndpointCandidateCount > 0) + { + foreach (var metadata in activeEndpoint.Metadata) + { + if (metadata is not null) + { + routeEndpointBuilder.Metadata.Add(metadata); + } + } + } + + replacementEndpoint = routeEndpointBuilder.Build(); + } + else + { + replacementEndpoint = new MetadataOnlyEndpoint(activeEndpoint, decoratedMetadata); + } + + _endpointsCache.Add(activeEndpoint, replacementEndpoint); + } + var values = realEndpointCandidateCount == 1 ? activeCandidate.Values : null; + + // Replace the endpoint + candidates.ReplaceEndpoint(replacementCandidateIndex, replacementEndpoint, values); + } + + return Task.CompletedTask; + } +} diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index a552550b5e1c..a9c7df4366fe 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ #nullable enable Microsoft.AspNetCore.Routing.RouteHandlerServices +static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapMetadata(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, params object![]! items) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! static Microsoft.AspNetCore.Routing.RouteHandlerServices.Map(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! handler, System.Collections.Generic.IEnumerable! httpMethods, System.Func! populateMetadata, System.Func! createRequestDelegate) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! diff --git a/src/Http/Routing/test/FunctionalTests/EndpointRoutingIntegrationTest.cs b/src/Http/Routing/test/FunctionalTests/EndpointRoutingIntegrationTest.cs index fddf3b5e5f00..bb90c64e265e 100644 --- a/src/Http/Routing/test/FunctionalTests/EndpointRoutingIntegrationTest.cs +++ b/src/Http/Routing/test/FunctionalTests/EndpointRoutingIntegrationTest.cs @@ -343,4 +343,149 @@ public async Task CorsMiddleware_ConfiguredBeforeRouting_Throws() var ex = await Assert.ThrowsAsync(() => server.CreateRequest("/").SendAsync("GET")); Assert.Equal(CORSErrorMessage, ex.Message); } + + private class CustomMetadata + { + public string Whatever { get; set; } + } + + private class CustomMetadata2 + { + public string Whatever { get; set; } + } + + [Fact] + public async Task CanAddMetadataOnlyToEndpoints() + { + // Arrange + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(b => + { + b.MapMetadata("/{**subpath}").WithMetadata(new CustomMetadata { Whatever = "This is on every endpoint now!" }); + b.Map("/test/sub", + (HttpContext context) => + { + Assert.Equal("This is on every endpoint now!", context.GetEndpoint()?.Metadata.GetMetadata().Whatever); + return "Success!"; + }); + }); + }) + .UseTestServer(); + }) + .ConfigureServices(services => + { + services.AddRouting(); + }) + .Build(); + + using var server = host.GetTestServer(); + + await host.StartAsync(); + + var response = await server.CreateRequest("/test/sub").SendAsync("GET"); + + response.EnsureSuccessStatusCode(); + Assert.Equal($"Success!", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanNestMetadataOnlyEndpoints() + { + // Arrange + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(b => + { + b.MapMetadata("/{**subpath}").WithMetadata(new CustomMetadata { Whatever = "This is on every endpoint now!" }); + b.MapMetadata("/sub/{**subpath}").WithMetadata(new CustomMetadata2 { Whatever = "Nested!" }); + b.Map("/test/notsub", + (HttpContext context) => + { + Assert.Equal("This is on every endpoint now!", context.GetEndpoint()?.Metadata.GetMetadata().Whatever); + Assert.Null(context.GetEndpoint()?.Metadata.GetMetadata()); + return "Success!"; + }); + b.Map("/sub/nested", + (HttpContext context) => + { + Assert.Equal("This is on every endpoint now!", context.GetEndpoint()?.Metadata.GetMetadata().Whatever); + Assert.Equal("Nested!", context.GetEndpoint()?.Metadata.GetMetadata().Whatever); + return "Success!"; + }); + }); + }) + .UseTestServer(); + }) + .ConfigureServices(services => + { + services.AddRouting(); + }) + .Build(); + + using var server = host.GetTestServer(); + + await host.StartAsync(); + + var response = await server.CreateRequest("/test/notsub").SendAsync("GET"); + response.EnsureSuccessStatusCode(); + Assert.Equal($"Success!", await response.Content.ReadAsStringAsync()); + + response = await server.CreateRequest("/sub/nested").SendAsync("GET"); + response.EnsureSuccessStatusCode(); + Assert.Equal($"Success!", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CanAddMetadataWithAuthZ() + { + // Arrange + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .Configure(app => + { + app.UseRouting(); + app.UseAuthorization(); + app.UseEndpoints(b => + { + b.MapMetadata("/{**subpath}").WithMetadata(new CustomMetadata { Whatever = "This is on every endpoint now!" }); + b.Map("/test/sub", + (HttpContext context) => + { + Assert.Equal("This is on every endpoint now!", context.GetEndpoint()?.Metadata.GetMetadata().Whatever); + Assert.NotNull(context.GetEndpoint()?.Metadata.GetMetadata()); + return "Success!"; + }).RequireAuthorization(); + }); + }) + .UseTestServer(); + }) + .ConfigureServices(services => + { + services.AddAuthorization(o => o.DefaultPolicy = new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build()); + services.AddRouting(); + }) + .Build(); + + using var server = host.GetTestServer(); + + await host.StartAsync(); + + var response = await server.CreateRequest("/test/sub").SendAsync("GET"); + + response.EnsureSuccessStatusCode(); + Assert.Equal($"Success!", await response.Content.ReadAsStringAsync()); + } } diff --git a/src/Http/Routing/test/FunctionalTests/MetadataOnlySampleTest.cs b/src/Http/Routing/test/FunctionalTests/MetadataOnlySampleTest.cs new file mode 100644 index 000000000000..92fb0fcba4ff --- /dev/null +++ b/src/Http/Routing/test/FunctionalTests/MetadataOnlySampleTest.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using RoutingWebSite; + +namespace Microsoft.AspNetCore.Routing.FunctionalTests; + +public class MetadataOnlySampleTest : IDisposable +{ + private readonly HttpClient _client; + private readonly IHost _host; + private readonly TestServer _testServer; + + public MetadataOnlySampleTest() + { + var hostBuilder = Program.GetHostBuilder(new[] { Program.MetadataOnlyScenario, }); + _host = hostBuilder.Build(); + + _testServer = _host.GetTestServer(); + _host.Start(); + + _client = _testServer.CreateClient(); + _client.BaseAddress = new Uri("http://localhost"); + } + + [Fact] + public async Task EndpointCombinesMetadata() + { + // Arrange & Act + var response = await _client.GetAsync("/printmeta"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var actualContent = await response.Content.ReadAsStringAsync(); + Assert.Contains("This is on every endpoint now!", actualContent); + Assert.Contains("This is only on this single endpoint", actualContent); + } + + public void Dispose() + { + _testServer.Dispose(); + _client.Dispose(); + _host.Dispose(); + } +} diff --git a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index dfcd66ea30dd..d5540b867d56 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -425,6 +425,24 @@ public void MapDelete_ExplicitFromBody_BuildsEndpointWithCorrectMethod() Assert.Equal("/", routeEndpointBuilder.RoutePattern.RawText); } + [Fact] + public void MapMetadataAddsMetadata() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + _ = builder.MapMetadata("/{**subpath}").WithMetadata("This is on every endpoint now!"); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + + var endpoint = dataSource.Endpoints.First(); + Assert.NotNull(endpoint); + + Assert.Equal("This is on every endpoint now!", endpoint.Metadata.GetMetadata()); + + var routeEndpointBuilder = GetRouteEndpointBuilder(builder); + Assert.Equal("/{**subpath}", routeEndpointBuilder.RoutePattern.RawText); + } + [Fact] public void MapPatch_ExplicitFromBody_BuildsEndpointWithCorrectMethod() { diff --git a/src/Http/Routing/test/testassets/RoutingWebSite/Program.cs b/src/Http/Routing/test/testassets/RoutingWebSite/Program.cs index 4f355670c15d..49af8a3a3f11 100644 --- a/src/Http/Routing/test/testassets/RoutingWebSite/Program.cs +++ b/src/Http/Routing/test/testassets/RoutingWebSite/Program.cs @@ -9,6 +9,7 @@ public class Program { public const string EndpointRoutingScenario = "endpointrouting"; public const string RouterScenario = "router"; + public const string MetadataOnlyScenario = "metadataonly"; public static Task Main(string[] args) { @@ -25,6 +26,7 @@ public static IHostBuilder GetHostBuilder(string[] args) Console.WriteLine("Choose a sample to run:"); Console.WriteLine($"1. {EndpointRoutingScenario}"); Console.WriteLine($"2. {RouterScenario}"); + Console.WriteLine($"3. {MetadataOnlyScenario}"); Console.WriteLine(); scenario = Console.ReadLine(); @@ -47,6 +49,11 @@ public static IHostBuilder GetHostBuilder(string[] args) startupType = typeof(UseRouterStartup); break; + case "3": + case MetadataOnlyScenario: + startupType = typeof(UseMetadataOnlyStartup); + break; + default: Console.WriteLine($"unknown scenario {scenario}"); Console.WriteLine($"usage: dotnet run -- ({EndpointRoutingScenario}|{RouterScenario})"); diff --git a/src/Http/Routing/test/testassets/RoutingWebSite/UseMetadataOnlyStartup.cs b/src/Http/Routing/test/testassets/RoutingWebSite/UseMetadataOnlyStartup.cs new file mode 100644 index 000000000000..5a00435d390a --- /dev/null +++ b/src/Http/Routing/test/testassets/RoutingWebSite/UseMetadataOnlyStartup.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace RoutingWebSite; + +public class UseMetadataOnlyStartup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddTransient(); + + services.AddRouting(options => + { + options.ConstraintMap.Add("endsWith", typeof(EndsWithStringRouteConstraint)); + }); + } + + public void Configure(IApplicationBuilder app) + { + app.UseStaticFiles(); + + app.UseRouting(); + + // Imagine some more stuff here... + + app.UseEndpoints(endpoints => + { + endpoints.MapMetadata("/{**subpath}").WithMetadata(new { Whatever = "This is on every endpoint now!" }); + endpoints.MapGet("/printmeta", (HttpContext context) => context.GetEndpoint()?.Metadata + .Select(m => new { TypeName = m.GetType().FullName, Value = m.ToString() })) + .WithMetadata(new { Value = "This is only on this single endpoint" }); + }); + } +}