[Mvc] Add support for order in dynamic controller routes (#25073)

* Order defaults to 1 same as conventional routes
* An incremental order is applied to dynamic routes as they are defined.
This commit is contained in:
Javier Calvarro Nelson 2020-08-25 08:53:42 +02:00 committed by Kevin Pilch
parent 340ee72715
commit a17842a2e4
13 changed files with 425 additions and 48 deletions

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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;
@ -506,18 +506,10 @@ namespace Microsoft.AspNetCore.Builder
EnsureControllerServices(endpoints);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
endpoints.Map(
pattern,
context =>
{
throw new InvalidOperationException("This endpoint is not expected to be executed directly.");
})
.Add(b =>
{
b.Metadata.Add(new DynamicControllerRouteValueTransformerMetadata(typeof(TTransformer), state));
});
var controllerDataSource = GetOrCreateDataSource(endpoints);
// The data source is just used to share the common order with conventionally routed actions.
controllerDataSource.AddDynamicControllerEndpoint(endpoints, pattern, typeof(TTransformer), state);
}
private static DynamicControllerMetadata CreateDynamicControllerMetadata(string action, string controller, string area)

View File

@ -269,6 +269,7 @@ namespace Microsoft.Extensions.DependencyInjection
//
// Endpoint Routing / Endpoints
//
services.TryAddSingleton<OrderedEndpointsSequenceProvider>();
services.TryAddSingleton<ControllerActionEndpointDataSource>();
services.TryAddSingleton<ActionEndpointFactory>();
services.TryAddSingleton<DynamicControllerEndpointSelector>();

View File

@ -0,0 +1,26 @@
// 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.Mvc.Infrastructure
{
internal class OrderedEndpointsSequenceProvider
{
private object Lock = new object();
// In traditional conventional routing setup, the routes defined by a user have a order
// defined by how they are added into the list. We would like to maintain the same order when building
// up the endpoints too.
//
// Start with an order of '1' for conventional routes as attribute routes have a default order of '0'.
// This is for scenarios dealing with migrating existing Router based code to Endpoint Routing world.
private int _current = 1;
public int GetNext()
{
lock (Lock)
{
return _current++;
}
}
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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;
@ -15,27 +15,19 @@ namespace Microsoft.AspNetCore.Mvc.Routing
internal class ControllerActionEndpointDataSource : ActionEndpointDataSourceBase
{
private readonly ActionEndpointFactory _endpointFactory;
private readonly OrderedEndpointsSequenceProvider _orderSequence;
private readonly List<ConventionalRouteEntry> _routes;
private int _order;
public ControllerActionEndpointDataSource(
IActionDescriptorCollectionProvider actions,
ActionEndpointFactory endpointFactory)
ActionEndpointFactory endpointFactory,
OrderedEndpointsSequenceProvider orderSequence)
: base(actions)
{
_endpointFactory = endpointFactory;
_orderSequence = orderSequence;
_routes = new List<ConventionalRouteEntry>();
// In traditional conventional routing setup, the routes defined by a user have a order
// defined by how they are added into the list. We would like to maintain the same order when building
// up the endpoints too.
//
// Start with an order of '1' for conventional routes as attribute routes have a default order of '0'.
// This is for scenarios dealing with migrating existing Router based code to Endpoint Routing world.
_order = 1;
DefaultBuilder = new ControllerActionEndpointConventionBuilder(Lock, Conventions);
// IMPORTANT: this needs to be the last thing we do in the constructor.
@ -59,7 +51,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
lock (Lock)
{
var conventions = new List<Action<EndpointBuilder>>();
_routes.Add(new ConventionalRouteEntry(routeName, pattern, defaults, constraints, dataTokens, _order++, conventions));
_routes.Add(new ConventionalRouteEntry(routeName, pattern, defaults, constraints, dataTokens, _orderSequence.GetNext(), conventions));
return new ControllerActionEndpointConventionBuilder(Lock, conventions);
}
}
@ -108,6 +100,27 @@ namespace Microsoft.AspNetCore.Mvc.Routing
return endpoints;
}
internal void AddDynamicControllerEndpoint(IEndpointRouteBuilder endpoints, string pattern, Type transformerType, object state)
{
CreateInertEndpoints = true;
lock (Lock)
{
var order = _orderSequence.GetNext();
endpoints.Map(
pattern,
context =>
{
throw new InvalidOperationException("This endpoint is not expected to be executed directly.");
})
.Add(b =>
{
((RouteEndpointBuilder)b).Order = order;
b.Metadata.Add(new DynamicControllerRouteValueTransformerMetadata(transformerType, state));
});
}
}
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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;
@ -385,7 +385,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
private protected override ActionEndpointDataSourceBase CreateDataSource(IActionDescriptorCollectionProvider actions, ActionEndpointFactory endpointFactory)
{
return new ControllerActionEndpointDataSource(actions, endpointFactory);
return new ControllerActionEndpointDataSource(actions, endpointFactory, new OrderedEndpointsSequenceProvider());
}
protected override ActionDescriptor CreateActionDescriptor(

View File

@ -337,18 +337,9 @@ namespace Microsoft.AspNetCore.Builder
EnsureRazorPagesServices(endpoints);
// Called for side-effect to make sure that the data source is registered.
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
var dataSource = GetOrCreateDataSource(endpoints);
endpoints.Map(
pattern,
context =>
{
throw new InvalidOperationException("This endpoint is not expected to be executed directly.");
})
.Add(b =>
{
b.Metadata.Add(new DynamicPageRouteValueTransformerMetadata(typeof(TTransformer), state));
});
dataSource.AddDynamicPageEndpoint(endpoints, pattern, typeof(TTransformer), state);
}
private static DynamicPageMetadata CreateDynamicPageMetadata(string page, string area)

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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;
@ -8,18 +8,23 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
{
internal class PageActionEndpointDataSource : ActionEndpointDataSourceBase
{
private readonly ActionEndpointFactory _endpointFactory;
private readonly OrderedEndpointsSequenceProvider _orderSequence;
public PageActionEndpointDataSource(IActionDescriptorCollectionProvider actions, ActionEndpointFactory endpointFactory)
public PageActionEndpointDataSource(
IActionDescriptorCollectionProvider actions,
ActionEndpointFactory endpointFactory,
OrderedEndpointsSequenceProvider orderedEndpoints)
: base(actions)
{
_endpointFactory = endpointFactory;
_orderSequence = orderedEndpoints;
DefaultBuilder = new PageActionEndpointConventionBuilder(Lock, Conventions);
// IMPORTANT: this needs to be the last thing we do in the constructor.
@ -47,6 +52,27 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
return endpoints;
}
internal void AddDynamicPageEndpoint(IEndpointRouteBuilder endpoints, string pattern, Type transformerType, object state)
{
CreateInertEndpoints = true;
lock (Lock)
{
var order = _orderSequence.GetNext();
endpoints.Map(
pattern,
context =>
{
throw new InvalidOperationException("This endpoint is not expected to be executed directly.");
})
.Add(b =>
{
((RouteEndpointBuilder)b).Order = order;
b.Metadata.Add(new DynamicPageRouteValueTransformerMetadata(transformerType, state));
});
}
}
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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;
@ -92,7 +92,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
private protected override ActionEndpointDataSourceBase CreateDataSource(IActionDescriptorCollectionProvider actions, ActionEndpointFactory endpointFactory)
{
return new PageActionEndpointDataSource(actions, endpointFactory);
return new PageActionEndpointDataSource(actions, endpointFactory, new OrderedEndpointsSequenceProvider());
}
protected override ActionDescriptor CreateActionDescriptor(

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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;
@ -110,7 +110,8 @@ namespace Microsoft.AspNetCore.Mvc.Performance
{
var dataSource = new ControllerActionEndpointDataSource(
actionDescriptorCollectionProvider,
new ActionEndpointFactory(new MockRoutePatternTransformer()));
new ActionEndpointFactory(new MockRoutePatternTransformer()),
new OrderedEndpointsSequenceProvider());
return dataSource;
}

View File

@ -0,0 +1,165 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using RoutingWebSite;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public class RoutingDynamicOrderTest : IClassFixture<MvcTestFixture<RoutingWebSite.StartupForDynamic>>
{
public RoutingDynamicOrderTest(MvcTestFixture<RoutingWebSite.StartupForDynamic> fixture)
{
Factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
}
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => builder.UseStartup<RoutingWebSite.StartupForDynamicOrder>();
public WebApplicationFactory<StartupForDynamic> Factory { get; }
[Fact]
public async Task PrefersAttributeRoutesOverDynamicControllerRoutes()
{
var factory = Factory
.WithWebHostBuilder(b => b.UseSetting("Scenario", RoutingWebSite.StartupForDynamicOrder.DynamicOrderScenarios.AttributeRouteDynamicRoute));
var client = factory.CreateClient();
// Arrange
var url = "http://localhost/attribute-dynamic-order/Controller=Home,Action=Index";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await client.SendAsync(request);
var content = await response.Content.ReadFromJsonAsync<RouteInfo>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("AttributeRouteSlug", content.RouteName);
}
[Fact]
public async Task DynamicRoutesAreMatchedInDefinitionOrderOverPrecedence()
{
AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", isEnabled: true);
var factory = Factory
.WithWebHostBuilder(b => b.UseSetting("Scenario", RoutingWebSite.StartupForDynamicOrder.DynamicOrderScenarios.MultipleDynamicRoute));
var client = factory.CreateClient();
// Arrange
var url = "http://localhost/dynamic-order/specific/Controller=Home,Action=Index";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await client.SendAsync(request);
var content = await response.Content.ReadFromJsonAsync<RouteInfo>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(content.RouteValues.TryGetValue("identifier", out var identifier));
Assert.Equal("slug", identifier);
}
[Fact]
public async Task ConventionalRoutesDefinedEarlierWinOverDynamicControllerRoutes()
{
AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", isEnabled: true);
var factory = Factory
.WithWebHostBuilder(b => b.UseSetting("Scenario", RoutingWebSite.StartupForDynamicOrder.DynamicOrderScenarios.ConventionalRouteDynamicRoute));
var client = factory.CreateClient();
// Arrange
var url = "http://localhost/conventional-dynamic-order-before";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await client.SendAsync(request);
var content = await response.Content.ReadFromJsonAsync<RouteInfo>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(content.RouteValues.TryGetValue("identifier", out var identifier));
}
[Fact]
public async Task ConventionalRoutesDefinedLaterLooseToDynamicControllerRoutes()
{
AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", isEnabled: true);
var factory = Factory
.WithWebHostBuilder(b => b.UseSetting("Scenario", RoutingWebSite.StartupForDynamicOrder.DynamicOrderScenarios.ConventionalRouteDynamicRoute));
var client = factory.CreateClient();
// Arrange
var url = "http://localhost/conventional-dynamic-order-after";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await client.SendAsync(request);
var content = await response.Content.ReadFromJsonAsync<RouteInfo>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(content.RouteValues.TryGetValue("identifier", out var identifier));
Assert.Equal("slug", identifier);
}
[Fact]
public async Task DynamicPagesDefinedEarlierWinOverDynamicControllers()
{
AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", isEnabled: true);
var factory = Factory
.WithWebHostBuilder(b => b.UseSetting("Scenario", RoutingWebSite.StartupForDynamicOrder.DynamicOrderScenarios.DynamicControllerAndPages));
var client = factory.CreateClient();
// Arrange
var url = "http://localhost/dynamic-order-page-controller-before";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Hello from dynamic page: /DynamicPagebefore", content);
}
[Fact]
public async Task DynamicPagesDefinedLaterLooseOverDynamicControllers()
{
AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", isEnabled: true);
var factory = Factory
.WithWebHostBuilder(b => b.UseSetting("Scenario", RoutingWebSite.StartupForDynamicOrder.DynamicOrderScenarios.DynamicControllerAndPages));
var client = factory.CreateClient();
// Arrange
var url = "http://localhost/dynamic-order-page-controller-after";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Act
var response = await client.SendAsync(request);
var content = await response.Content.ReadFromJsonAsync<RouteInfo>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(content.RouteValues.TryGetValue("identifier", out var identifier));
Assert.Equal("controller", identifier);
}
private record RouteInfo(string RouteName, IDictionary<string,string> RouteValues);
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace Mvc.RoutingWebSite.Controllers
{
public class DynamicOrderController : Controller
{
private readonly TestResponseGenerator _generator;
public DynamicOrderController(TestResponseGenerator generator)
{
_generator = generator;
}
[HttpGet("attribute-dynamic-order/{**slug}", Name = "AttributeRouteSlug")]
public IActionResult Get(string slug)
{
return _generator.Generate(Url.RouteUrl("AttributeRouteSlug", new { slug }));
}
[HttpGet]
public IActionResult Index()
{
return _generator.Generate(Url.RouteUrl(null, new { controller = "DynamicOrder", action = "Index" }));
}
}
}

View File

@ -1,3 +1,3 @@
@page
@model RoutingWebSite.Pages.DynamicPageModel
Hello from dynamic page: @Url.Page("")
Hello from dynamic page: @Url.Page("")@RouteData.Values["identifier"]

View File

@ -0,0 +1,132 @@
// 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.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace RoutingWebSite
{
// For by tests for dynamic routing to pages/controllers
public class StartupForDynamicOrder
{
public static class DynamicOrderScenarios
{
public const string AttributeRouteDynamicRoute = nameof(AttributeRouteDynamicRoute);
public const string MultipleDynamicRoute = nameof(MultipleDynamicRoute);
public const string ConventionalRouteDynamicRoute = nameof(ConventionalRouteDynamicRoute);
public const string DynamicControllerAndPages = nameof(DynamicControllerAndPages);
}
public IConfiguration Configuration { get; }
public StartupForDynamicOrder(IConfiguration configuration)
{
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
services
.AddMvc()
.AddNewtonsoftJson()
.SetCompatibilityVersion(CompatibilityVersion.Latest);
services.AddTransient<Transformer>();
services.AddScoped<TestResponseGenerator>();
services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
// Used by some controllers defined in this project.
services.Configure<RouteOptions>(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer));
}
public void Configure(IApplicationBuilder app)
{
var scenario = Configuration.GetValue<string>("Scenario");
app.UseRouting();
app.UseEndpoints(endpoints =>
{
// Route order definition is important for all these routes:
switch (scenario)
{
case DynamicOrderScenarios.AttributeRouteDynamicRoute:
endpoints.MapDynamicControllerRoute<Transformer>("attribute-dynamic-order/{**slug}", new TransformerState() { Identifier = "slug" });
endpoints.MapControllers();
break;
case DynamicOrderScenarios.ConventionalRouteDynamicRoute:
endpoints.MapControllerRoute(null, "{**conventional-dynamic-order-before:regex(^((?!conventional\\-dynamic\\-order\\-after).)*$)}", new { controller = "DynamicOrder", action = "Index" });
endpoints.MapDynamicControllerRoute<Transformer>("{conventional-dynamic-order}", new TransformerState() { Identifier = "slug" });
endpoints.MapControllerRoute(null, "conventional-dynamic-order-after", new { controller = "DynamicOrder", action = "Index" });
break;
case DynamicOrderScenarios.MultipleDynamicRoute:
endpoints.MapDynamicControllerRoute<Transformer>("dynamic-order/{**slug}", new TransformerState() { Identifier = "slug" });
endpoints.MapDynamicControllerRoute<Transformer>("dynamic-order/specific/{**slug}", new TransformerState() { Identifier = "specific" });
break;
case DynamicOrderScenarios.DynamicControllerAndPages:
endpoints.MapDynamicPageRoute<Transformer>("{**dynamic-order-page-controller-before:regex(^((?!dynamic\\-order\\-page\\-controller\\-after).)*$)}", new TransformerState() { Identifier = "before", ForPages = true });
endpoints.MapDynamicControllerRoute<Transformer>("{dynamic-order-page-controller}", new TransformerState() { Identifier = "controller" });
endpoints.MapDynamicPageRoute<Transformer>("dynamic-order-page-controller-after", new TransformerState() { Identifier = "after", ForPages = true });
break;
default:
throw new InvalidOperationException("Invalid scenario configuration.");
}
});
app.Map("/afterrouting", b => b.Run(c =>
{
return c.Response.WriteAsync("Hello from middleware after routing");
}));
}
private class TransformerState
{
public string Identifier { get; set; }
public bool ForPages { get; set; }
}
private class Transformer : DynamicRouteValueTransformer
{
// Turns a format like `controller=Home,action=Index` into an RVD
public override ValueTask<RouteValueDictionary> TransformAsync(HttpContext httpContext, RouteValueDictionary values)
{
var kvps = ((string)values?["slug"])?.Split("/")?.LastOrDefault()?.Split(",") ?? Array.Empty<string>();
// Go to index by default if the route doesn't follow the slug pattern, we want to make sure always match to
// test the order is applied
var state = (TransformerState)State;
var results = new RouteValueDictionary();
if (!state.ForPages)
{
results["controller"] = "Home";
results["action"] = "Index";
}
else
{
results["Page"] = "/DynamicPage";
}
foreach (var kvp in kvps)
{
var split = kvp.Split("=");
if (split.Length == 2)
{
results[split[0]] = split[1];
}
}
results["identifier"] = ((TransformerState)State).Identifier;
return new ValueTask<RouteValueDictionary>(results);
}
}
}
}