diff --git a/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs b/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs index 6c14cca67f..d2ce072066 100644 --- a/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs +++ b/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs @@ -5,10 +5,10 @@ using System; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Constraints; -using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Builder @@ -213,7 +213,9 @@ namespace Microsoft.AspNetCore.Builder EnsureControllerServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; + var dataSource = GetOrCreateDataSource(endpoints); + dataSource.CreateInertEndpoints = true; + RegisterInCache(endpoints.ServiceProvider, dataSource); // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -222,6 +224,7 @@ namespace Microsoft.AspNetCore.Builder { // MVC registers a policy that looks for this metadata. b.Metadata.Add(CreateDynamicControllerMetadata(action, controller, area: null)); + b.Metadata.Add(new ControllerEndpointDataSourceIdMetadata(dataSource.DataSourceId)); }); return builder; } @@ -289,7 +292,9 @@ namespace Microsoft.AspNetCore.Builder EnsureControllerServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; + var dataSource = GetOrCreateDataSource(endpoints); + dataSource.CreateInertEndpoints = true; + RegisterInCache(endpoints.ServiceProvider, dataSource); // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -298,6 +303,7 @@ namespace Microsoft.AspNetCore.Builder { // MVC registers a policy that looks for this metadata. b.Metadata.Add(CreateDynamicControllerMetadata(action, controller, area: null)); + b.Metadata.Add(new ControllerEndpointDataSourceIdMetadata(dataSource.DataSourceId)); }); return builder; } @@ -357,7 +363,9 @@ namespace Microsoft.AspNetCore.Builder EnsureControllerServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; + var dataSource = GetOrCreateDataSource(endpoints); + dataSource.CreateInertEndpoints = true; + RegisterInCache(endpoints.ServiceProvider, dataSource); // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -366,6 +374,7 @@ namespace Microsoft.AspNetCore.Builder { // MVC registers a policy that looks for this metadata. b.Metadata.Add(CreateDynamicControllerMetadata(action, controller, area)); + b.Metadata.Add(new ControllerEndpointDataSourceIdMetadata(dataSource.DataSourceId)); }); return builder; } @@ -435,7 +444,9 @@ namespace Microsoft.AspNetCore.Builder EnsureControllerServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; + var dataSource = GetOrCreateDataSource(endpoints); + dataSource.CreateInertEndpoints = true; + RegisterInCache(endpoints.ServiceProvider, dataSource); // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -444,6 +455,7 @@ namespace Microsoft.AspNetCore.Builder { // MVC registers a policy that looks for this metadata. b.Metadata.Add(CreateDynamicControllerMetadata(action, controller, area)); + b.Metadata.Add(new ControllerEndpointDataSourceIdMetadata(dataSource.DataSourceId)); }); return builder; } @@ -507,11 +519,50 @@ namespace Microsoft.AspNetCore.Builder // Called for side-effect to make sure that the data source is registered. var controllerDataSource = GetOrCreateDataSource(endpoints); - + RegisterInCache(endpoints.ServiceProvider, controllerDataSource); + // The data source is just used to share the common order with conventionally routed actions. controllerDataSource.AddDynamicControllerEndpoint(endpoints, pattern, typeof(TTransformer), state); } + /// + /// Adds a specialized to the that will + /// attempt to select a controller action using the route values produced by . + /// + /// The to add the route to. + /// The URL pattern of the route. + /// A state object to provide to the instance. + /// The matching order for the dynamic route. + /// The type of a . + /// + /// + /// This method allows the registration of a and + /// that combine to dynamically select a controller action using custom logic. + /// + /// + /// The instance of will be retrieved from the dependency injection container. + /// Register as transient in ConfigureServices. Using the transient lifetime + /// is required when using . + /// + /// + public static void MapDynamicControllerRoute(this IEndpointRouteBuilder endpoints, string pattern, object state, int order) + where TTransformer : DynamicRouteValueTransformer + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + EnsureControllerServices(endpoints); + + // Called for side-effect to make sure that the data source is registered. + var controllerDataSource = GetOrCreateDataSource(endpoints); + RegisterInCache(endpoints.ServiceProvider, controllerDataSource); + + // The data source is just used to share the common order with conventionally routed actions. + controllerDataSource.AddDynamicControllerEndpoint(endpoints, pattern, typeof(TTransformer), state, order); + } + private static DynamicControllerMetadata CreateDynamicControllerMetadata(string action, string controller, string area) { return new DynamicControllerMetadata(new RouteValueDictionary() @@ -539,11 +590,19 @@ namespace Microsoft.AspNetCore.Builder var dataSource = endpoints.DataSources.OfType().FirstOrDefault(); if (dataSource == null) { - dataSource = endpoints.ServiceProvider.GetRequiredService(); + var orderProvider = endpoints.ServiceProvider.GetRequiredService(); + var factory = endpoints.ServiceProvider.GetRequiredService(); + dataSource = factory.Create(orderProvider.GetOrCreateOrderedEndpointsSequenceProvider(endpoints)); endpoints.DataSources.Add(dataSource); } return dataSource; } + + private static void RegisterInCache(IServiceProvider serviceProvider, ControllerActionEndpointDataSource dataSource) + { + var cache = serviceProvider.GetRequiredService(); + cache.AddDataSource(dataSource); + } } } diff --git a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs index 4f5593e86a..3c9233433f 100644 --- a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs +++ b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs @@ -269,10 +269,11 @@ namespace Microsoft.Extensions.DependencyInjection // // Endpoint Routing / Endpoints // - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); // diff --git a/src/Mvc/Mvc.Core/src/DependencyInjection/OrderedEndpointsSequenceProvider.cs b/src/Mvc/Mvc.Core/src/Infrastructure/OrderedEndpointsSequenceProvider.cs similarity index 83% rename from src/Mvc/Mvc.Core/src/DependencyInjection/OrderedEndpointsSequenceProvider.cs rename to src/Mvc/Mvc.Core/src/Infrastructure/OrderedEndpointsSequenceProvider.cs index 132f7dfb6d..701a5ff2ef 100644 --- a/src/Mvc/Mvc.Core/src/DependencyInjection/OrderedEndpointsSequenceProvider.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/OrderedEndpointsSequenceProvider.cs @@ -1,26 +1,23 @@ // 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; + 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; + private int _current = 0; public int GetNext() { - lock (Lock) - { - return _current++; - } + return Interlocked.Increment(ref _current); } } } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/OrderedEndpointsSequenceProviderCache.cs b/src/Mvc/Mvc.Core/src/Infrastructure/OrderedEndpointsSequenceProviderCache.cs new file mode 100644 index 0000000000..e3795c4bdc --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Infrastructure/OrderedEndpointsSequenceProviderCache.cs @@ -0,0 +1,18 @@ +// 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.Collections.Concurrent; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal class OrderedEndpointsSequenceProviderCache + { + private readonly ConcurrentDictionary _sequenceProviderCache = new(); + + public OrderedEndpointsSequenceProvider GetOrCreateOrderedEndpointsSequenceProvider(IEndpointRouteBuilder endpoints) + { + return _sequenceProviderCache.GetOrAdd(endpoints, new OrderedEndpointsSequenceProvider()); + } + } +} diff --git a/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSource.cs b/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSource.cs index fb9288b9cf..93a05e2455 100644 --- a/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSource.cs +++ b/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSource.cs @@ -19,13 +19,17 @@ namespace Microsoft.AspNetCore.Mvc.Routing private readonly List _routes; public ControllerActionEndpointDataSource( + ControllerActionEndpointDataSourceIdProvider dataSourceIdProvider, IActionDescriptorCollectionProvider actions, ActionEndpointFactory endpointFactory, OrderedEndpointsSequenceProvider orderSequence) : base(actions) { _endpointFactory = endpointFactory; + + DataSourceId = dataSourceIdProvider.CreateId(); _orderSequence = orderSequence; + _routes = new List(); DefaultBuilder = new ControllerActionEndpointConventionBuilder(Lock, Conventions); @@ -35,6 +39,8 @@ namespace Microsoft.AspNetCore.Mvc.Routing Subscribe(); } + public int DataSourceId { get; } + public ControllerActionEndpointConventionBuilder DefaultBuilder { get; } // Used to control whether we create 'inert' (non-routable) endpoints for use in dynamic @@ -101,12 +107,12 @@ namespace Microsoft.AspNetCore.Mvc.Routing return endpoints; } - internal void AddDynamicControllerEndpoint(IEndpointRouteBuilder endpoints, string pattern, Type transformerType, object state) + internal void AddDynamicControllerEndpoint(IEndpointRouteBuilder endpoints, string pattern, Type transformerType, object state, int? order = null) { CreateInertEndpoints = true; lock (Lock) { - var order = _orderSequence.GetNext(); + order ??= _orderSequence.GetNext(); endpoints.Map( pattern, @@ -116,8 +122,9 @@ namespace Microsoft.AspNetCore.Mvc.Routing }) .Add(b => { - ((RouteEndpointBuilder)b).Order = order; + ((RouteEndpointBuilder)b).Order = order.Value; b.Metadata.Add(new DynamicControllerRouteValueTransformerMetadata(transformerType, state)); + b.Metadata.Add(new ControllerEndpointDataSourceIdMetadata(DataSourceId)); }); } } diff --git a/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSourceFactory.cs b/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSourceFactory.cs new file mode 100644 index 0000000000..0bb0d591a5 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSourceFactory.cs @@ -0,0 +1,29 @@ +// 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 Microsoft.AspNetCore.Mvc.Routing; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal class ControllerActionEndpointDataSourceFactory + { + private readonly ControllerActionEndpointDataSourceIdProvider _dataSourceIdProvider; + private readonly IActionDescriptorCollectionProvider _actions; + private readonly ActionEndpointFactory _factory; + + public ControllerActionEndpointDataSourceFactory( + ControllerActionEndpointDataSourceIdProvider dataSourceIdProvider, + IActionDescriptorCollectionProvider actions, + ActionEndpointFactory factory) + { + _dataSourceIdProvider = dataSourceIdProvider; + _actions = actions; + _factory = factory; + } + + public ControllerActionEndpointDataSource Create(OrderedEndpointsSequenceProvider orderProvider) + { + return new ControllerActionEndpointDataSource(_dataSourceIdProvider, _actions, _factory, orderProvider); + } + } +} diff --git a/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSourceIdProvider.cs b/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSourceIdProvider.cs new file mode 100644 index 0000000000..0c9ec72b0d --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSourceIdProvider.cs @@ -0,0 +1,17 @@ +// 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; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + internal class ControllerActionEndpointDataSourceIdProvider + { + private int _nextId = 1; + + internal int CreateId() + { + return Interlocked.Increment(ref _nextId); + } + } +} diff --git a/src/Mvc/Mvc.Core/src/Routing/ControllerEndpointDataSourceIdMetadata.cs b/src/Mvc/Mvc.Core/src/Routing/ControllerEndpointDataSourceIdMetadata.cs new file mode 100644 index 0000000000..ab83ea4155 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Routing/ControllerEndpointDataSourceIdMetadata.cs @@ -0,0 +1,15 @@ +// 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.Routing +{ + internal class ControllerEndpointDataSourceIdMetadata + { + public ControllerEndpointDataSourceIdMetadata(int id) + { + Id = id; + } + + public int Id { get; } + } +} diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs index 5f30a03eb7..bd9425ae5c 100644 --- a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs +++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -15,14 +16,14 @@ namespace Microsoft.AspNetCore.Mvc.Routing { internal class DynamicControllerEndpointMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy { - private readonly DynamicControllerEndpointSelector _selector; + private readonly DynamicControllerEndpointSelectorCache _selectorCache; private readonly EndpointMetadataComparer _comparer; - public DynamicControllerEndpointMatcherPolicy(DynamicControllerEndpointSelector selector, EndpointMetadataComparer comparer) + public DynamicControllerEndpointMatcherPolicy(DynamicControllerEndpointSelectorCache selectorCache, EndpointMetadataComparer comparer) { - if (selector == null) + if (selectorCache == null) { - throw new ArgumentNullException(nameof(selector)); + throw new ArgumentNullException(nameof(selectorCache)); } if (comparer == null) @@ -30,7 +31,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing throw new ArgumentNullException(nameof(comparer)); } - _selector = selector; + _selectorCache = selectorCache; _comparer = comparer; } @@ -79,6 +80,9 @@ namespace Microsoft.AspNetCore.Mvc.Routing throw new ArgumentNullException(nameof(candidates)); } + // The per-route selector, must be the same for all the endpoints we are dealing with. + DynamicControllerEndpointSelector selector = null; + // There's no real benefit here from trying to avoid the async state machine. // We only execute on nodes that contain a dynamic policy, and thus always have // to await something. @@ -127,7 +131,9 @@ namespace Microsoft.AspNetCore.Mvc.Routing continue; } - var endpoints = _selector.SelectEndpoints(dynamicValues); + selector = ResolveSelector(selector, endpoint); + + var endpoints = selector.SelectEndpoints(dynamicValues); if (endpoints.Count == 0 && dynamicControllerMetadata != null) { // Naving no match for a fallback is a configuration error. We can't really check @@ -172,5 +178,14 @@ namespace Microsoft.AspNetCore.Mvc.Routing candidates.ExpandEndpoint(i, endpoints, _comparer); } } + + private DynamicControllerEndpointSelector ResolveSelector(DynamicControllerEndpointSelector currentSelector, Endpoint endpoint) + { + var selector = _selectorCache.GetEndpointSelector(endpoint); + + Debug.Assert(currentSelector == null || ReferenceEquals(currentSelector, selector)); + + return selector; + } } } diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs index f07b22b94b..6281fc4bc2 100644 --- a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs +++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs @@ -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; @@ -11,25 +11,15 @@ namespace Microsoft.AspNetCore.Mvc.Routing { internal class DynamicControllerEndpointSelector : IDisposable { - private readonly EndpointDataSource _dataSource; private readonly DataSourceDependentCache> _cache; - public DynamicControllerEndpointSelector(ControllerActionEndpointDataSource dataSource) - : this((EndpointDataSource)dataSource) - { - } - - // Exposed for tests. We need to accept a more specific type in the constructor for DI - // to work. - protected DynamicControllerEndpointSelector(EndpointDataSource dataSource) + public DynamicControllerEndpointSelector(EndpointDataSource dataSource) { if (dataSource == null) { throw new ArgumentNullException(nameof(dataSource)); } - _dataSource = dataSource; - _cache = new DataSourceDependentCache>(dataSource, Initialize); } diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelectorCache.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelectorCache.cs new file mode 100644 index 0000000000..9d14984312 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelectorCache.cs @@ -0,0 +1,46 @@ +// 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.Concurrent; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + internal class DynamicControllerEndpointSelectorCache + { + private readonly ConcurrentDictionary _dataSourceCache = new(); + private readonly ConcurrentDictionary _endpointSelectorCache = new(); + + public void AddDataSource(ControllerActionEndpointDataSource dataSource) + { + _dataSourceCache.GetOrAdd(dataSource.DataSourceId, dataSource); + } + + // For testing purposes only + internal void AddDataSource(EndpointDataSource dataSource, int key) => + _dataSourceCache.GetOrAdd(key, dataSource); + + public DynamicControllerEndpointSelector GetEndpointSelector(Endpoint endpoint) + { + if (endpoint?.Metadata == null) + { + return null; + } + + var dataSourceId = endpoint.Metadata.GetMetadata(); + return _endpointSelectorCache.GetOrAdd(dataSourceId.Id, key => EnsureDataSource(key)); + } + + private DynamicControllerEndpointSelector EnsureDataSource(int key) + { + if (!_dataSourceCache.TryGetValue(key, out var dataSource)) + { + throw new InvalidOperationException($"Data source with key '{key}' not registered."); + } + + return new DynamicControllerEndpointSelector(dataSource); + } + } +} diff --git a/src/Mvc/Mvc.Core/test/Routing/ControllerActionEndpointDataSourceTest.cs b/src/Mvc/Mvc.Core/test/Routing/ControllerActionEndpointDataSourceTest.cs index 6001dab415..8ee740f2c1 100644 --- a/src/Mvc/Mvc.Core/test/Routing/ControllerActionEndpointDataSourceTest.cs +++ b/src/Mvc/Mvc.Core/test/Routing/ControllerActionEndpointDataSourceTest.cs @@ -245,8 +245,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing var dataSource = (ControllerActionEndpointDataSource)CreateDataSource(mockDescriptorProvider.Object); dataSource.AddRoute("1", "/1/{controller}/{action}/{id?}", null, null, null); dataSource.AddRoute("2", "/2/{controller}/{action}/{id?}", null, null, null); - - + dataSource.DefaultBuilder.Add(b => { if (b.Metadata.OfType().FirstOrDefault()?.AttributeRouteInfo != null) @@ -385,7 +384,11 @@ namespace Microsoft.AspNetCore.Mvc.Routing private protected override ActionEndpointDataSourceBase CreateDataSource(IActionDescriptorCollectionProvider actions, ActionEndpointFactory endpointFactory) { - return new ControllerActionEndpointDataSource(actions, endpointFactory, new OrderedEndpointsSequenceProvider()); + return new ControllerActionEndpointDataSource( + new ControllerActionEndpointDataSourceIdProvider(), + actions, + endpointFactory, + new OrderedEndpointsSequenceProvider()); } protected override ActionDescriptor CreateActionDescriptor( diff --git a/src/Mvc/Mvc.Core/test/Routing/DynamicControllerEndpointMatcherPolicyTest.cs b/src/Mvc/Mvc.Core/test/Routing/DynamicControllerEndpointMatcherPolicyTest.cs index 03dbeab793..44a8232b07 100644 --- a/src/Mvc/Mvc.Core/test/Routing/DynamicControllerEndpointMatcherPolicyTest.cs +++ b/src/Mvc/Mvc.Core/test/Routing/DynamicControllerEndpointMatcherPolicyTest.cs @@ -19,6 +19,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing { public DynamicControllerEndpointMatcherPolicyTest() { + var dataSourceKey = new ControllerEndpointDataSourceIdMetadata(1); var actions = new ActionDescriptor[] { new ControllerActionDescriptor() @@ -59,12 +60,13 @@ namespace Microsoft.AspNetCore.Mvc.Routing new EndpointMetadataCollection(new object[] { new DynamicControllerRouteValueTransformerMetadata(typeof(CustomTransformer), State), + dataSourceKey }), "dynamic"); DataSource = new DefaultEndpointDataSource(ControllerEndpoints); - Selector = new TestDynamicControllerEndpointSelector(DataSource); + SelectorCache = new TestDynamicControllerEndpointSelectorCache(DataSource, 1); var services = new ServiceCollection(); services.AddRouting(); @@ -88,7 +90,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing private Endpoint DynamicEndpoint { get; } - private DynamicControllerEndpointSelector Selector { get; } + private DynamicControllerEndpointSelectorCache SelectorCache { get; } private IServiceProvider Services { get; } @@ -102,7 +104,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing public async Task ApplyAsync_NoMatch() { // Arrange - var policy = new DynamicControllerEndpointMatcherPolicy(Selector, Comparer); + var policy = new DynamicControllerEndpointMatcherPolicy(SelectorCache, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { null, }; @@ -132,7 +134,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing public async Task ApplyAsync_HasMatchNoEndpointFound() { // Arrange - var policy = new DynamicControllerEndpointMatcherPolicy(Selector, Comparer); + var policy = new DynamicControllerEndpointMatcherPolicy(SelectorCache, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { null, }; @@ -163,7 +165,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing public async Task ApplyAsync_HasMatchFindsEndpoint_WithoutRouteValues() { // Arrange - var policy = new DynamicControllerEndpointMatcherPolicy(Selector, Comparer); + var policy = new DynamicControllerEndpointMatcherPolicy(SelectorCache, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { null, }; @@ -209,7 +211,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing public async Task ApplyAsync_ThrowsForTransformerWithInvalidLifetime() { // Arrange - var policy = new DynamicControllerEndpointMatcherPolicy(Selector, Comparer); + var policy = new DynamicControllerEndpointMatcherPolicy(SelectorCache, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -240,7 +242,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing public async Task ApplyAsync_HasMatchFindsEndpoint_WithRouteValues() { // Arrange - var policy = new DynamicControllerEndpointMatcherPolicy(Selector, Comparer); + var policy = new DynamicControllerEndpointMatcherPolicy(SelectorCache, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -297,7 +299,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing public async Task ApplyAsync_CanDiscardFoundEndpoints() { // Arrange - var policy = new DynamicControllerEndpointMatcherPolicy(Selector, Comparer); + var policy = new DynamicControllerEndpointMatcherPolicy(SelectorCache, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -336,7 +338,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing public async Task ApplyAsync_CanReplaceFoundEndpoints() { // Arrange - var policy = new DynamicControllerEndpointMatcherPolicy(Selector, Comparer); + var policy = new DynamicControllerEndpointMatcherPolicy(SelectorCache, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -398,7 +400,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing public async Task ApplyAsync_CanExpandTheListOfFoundEndpoints() { // Arrange - var policy = new DynamicControllerEndpointMatcherPolicy(Selector, Comparer); + var policy = new DynamicControllerEndpointMatcherPolicy(SelectorCache, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -437,11 +439,11 @@ namespace Microsoft.AspNetCore.Mvc.Routing Assert.Same(ControllerEndpoints[2], candidates[1].Endpoint); } - private class TestDynamicControllerEndpointSelector : DynamicControllerEndpointSelector + private class TestDynamicControllerEndpointSelectorCache : DynamicControllerEndpointSelectorCache { - public TestDynamicControllerEndpointSelector(EndpointDataSource dataSource) - : base(dataSource) + public TestDynamicControllerEndpointSelectorCache(EndpointDataSource dataSource, int key) { + AddDataSource(dataSource, key); } } diff --git a/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs b/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs index bd0c27186b..f8258196b8 100644 --- a/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs +++ b/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; @@ -75,7 +76,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 pageDataSource = GetOrCreateDataSource(endpoints); + pageDataSource.CreateInertEndpoints = true; + RegisterInCache(endpoints.ServiceProvider, pageDataSource); // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -84,6 +87,7 @@ namespace Microsoft.AspNetCore.Builder { // MVC registers a policy that looks for this metadata. b.Metadata.Add(CreateDynamicPageMetadata(page, area: null)); + b.Metadata.Add(new PageEndpointDataSourceIdMetadata(pageDataSource.DataSourceId)); }); return builder; } @@ -141,7 +145,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 pageDataSource = GetOrCreateDataSource(endpoints); + pageDataSource.CreateInertEndpoints = true; + RegisterInCache(endpoints.ServiceProvider, pageDataSource); // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -150,6 +156,7 @@ namespace Microsoft.AspNetCore.Builder { // MVC registers a policy that looks for this metadata. b.Metadata.Add(CreateDynamicPageMetadata(page, area: null)); + b.Metadata.Add(new PageEndpointDataSourceIdMetadata(pageDataSource.DataSourceId)); }); return builder; } @@ -199,7 +206,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 pageDataSource = GetOrCreateDataSource(endpoints); + pageDataSource.CreateInertEndpoints = true; + RegisterInCache(endpoints.ServiceProvider, pageDataSource); // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -208,6 +217,7 @@ namespace Microsoft.AspNetCore.Builder { // MVC registers a policy that looks for this metadata. b.Metadata.Add(CreateDynamicPageMetadata(page, area)); + b.Metadata.Add(new PageEndpointDataSourceIdMetadata(pageDataSource.DataSourceId)); }); return builder; } @@ -267,7 +277,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 pageDataSource = GetOrCreateDataSource(endpoints); + pageDataSource.CreateInertEndpoints = true; + RegisterInCache(endpoints.ServiceProvider, pageDataSource); // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -276,6 +288,7 @@ namespace Microsoft.AspNetCore.Builder { // MVC registers a policy that looks for this metadata. b.Metadata.Add(CreateDynamicPageMetadata(page, area)); + b.Metadata.Add(new PageEndpointDataSourceIdMetadata(pageDataSource.DataSourceId)); }); return builder; } @@ -337,9 +350,51 @@ namespace Microsoft.AspNetCore.Builder EnsureRazorPagesServices(endpoints); // Called for side-effect to make sure that the data source is registered. - var dataSource = GetOrCreateDataSource(endpoints); + var pageDataSource = GetOrCreateDataSource(endpoints); + RegisterInCache(endpoints.ServiceProvider, pageDataSource); - dataSource.AddDynamicPageEndpoint(endpoints, pattern, typeof(TTransformer), state); + pageDataSource.AddDynamicPageEndpoint(endpoints, pattern, typeof(TTransformer), state); + } + + /// + /// Adds a specialized to the that will + /// attempt to select a page using the route values produced by . + /// + /// The to add the route to. + /// The URL pattern of the route. + /// A state object to provide to the instance. + /// The matching order for the dynamic route. + /// The type of a . + /// + /// + /// This method allows the registration of a and + /// that combine to dynamically select a page using custom logic. + /// + /// + /// The instance of will be retrieved from the dependency injection container. + /// Register with the desired service lifetime in ConfigureServices. + /// + /// + public static void MapDynamicPageRoute(this IEndpointRouteBuilder endpoints, string pattern, object state, int order) + where TTransformer : DynamicRouteValueTransformer + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + EnsureRazorPagesServices(endpoints); + + // Called for side-effect to make sure that the data source is registered. + var pageDataSource = GetOrCreateDataSource(endpoints); + RegisterInCache(endpoints.ServiceProvider, pageDataSource); + + pageDataSource.AddDynamicPageEndpoint(endpoints, pattern, typeof(TTransformer), state, order); } private static DynamicPageMetadata CreateDynamicPageMetadata(string page, string area) @@ -353,7 +408,7 @@ namespace Microsoft.AspNetCore.Builder private static void EnsureRazorPagesServices(IEndpointRouteBuilder endpoints) { - var marker = endpoints.ServiceProvider.GetService(); + var marker = endpoints.ServiceProvider.GetService(); if (marker == null) { throw new InvalidOperationException(Mvc.Core.Resources.FormatUnableToFindServices( @@ -368,11 +423,19 @@ namespace Microsoft.AspNetCore.Builder var dataSource = endpoints.DataSources.OfType().FirstOrDefault(); if (dataSource == null) { - dataSource = endpoints.ServiceProvider.GetRequiredService(); + var orderProviderCache = endpoints.ServiceProvider.GetRequiredService(); + var factory = endpoints.ServiceProvider.GetRequiredService(); + dataSource = factory.Create(orderProviderCache.GetOrCreateOrderedEndpointsSequenceProvider(endpoints)); endpoints.DataSources.Add(dataSource); } return dataSource; } + + private static void RegisterInCache(IServiceProvider serviceProvider, PageActionEndpointDataSource dataSource) + { + var cache = serviceProvider.GetRequiredService(); + cache.AddDataSource(dataSource); + } } } diff --git a/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs b/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs index c17c80927a..81e7fbbb30 100644 --- a/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs +++ b/src/Mvc/Mvc.RazorPages/src/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs @@ -89,15 +89,15 @@ namespace Microsoft.Extensions.DependencyInjection // Routing services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); // Action description and invocation services.TryAddEnumerable( ServiceDescriptor.Singleton()); services.TryAddEnumerable( ServiceDescriptor.Singleton()); - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable( diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs index 337fe3e5f0..eb01da5205 100644 --- a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -15,15 +16,15 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { internal class DynamicPageEndpointMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy { - private readonly DynamicPageEndpointSelector _selector; + private readonly DynamicPageEndpointSelectorCache _selectorCache; private readonly PageLoader _loader; private readonly EndpointMetadataComparer _comparer; - public DynamicPageEndpointMatcherPolicy(DynamicPageEndpointSelector selector, PageLoader loader, EndpointMetadataComparer comparer) + public DynamicPageEndpointMatcherPolicy(DynamicPageEndpointSelectorCache selectorCache, PageLoader loader, EndpointMetadataComparer comparer) { - if (selector == null) + if (selectorCache == null) { - throw new ArgumentNullException(nameof(selector)); + throw new ArgumentNullException(nameof(selectorCache)); } if (loader == null) @@ -36,7 +37,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure throw new ArgumentNullException(nameof(comparer)); } - _selector = selector; + _selectorCache = selectorCache; _loader = loader; _comparer = comparer; } @@ -86,6 +87,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure throw new ArgumentNullException(nameof(candidates)); } + DynamicPageEndpointSelector selector = null; + // There's no real benefit here from trying to avoid the async state machine. // We only execute on nodes that contain a dynamic policy, and thus always have // to await something. @@ -132,7 +135,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure continue; } - var endpoints = _selector.SelectEndpoints(dynamicValues); + selector = ResolveSelector(selector, endpoint); + var endpoints = selector.SelectEndpoints(dynamicValues); if (endpoints.Count == 0 && dynamicPageMetadata != null) { // Having no match for a fallback is a configuration error. We can't really check @@ -196,5 +200,15 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure candidates.ExpandEndpoint(i, loadedEndpoints, _comparer); } } + + private DynamicPageEndpointSelector ResolveSelector(DynamicPageEndpointSelector currentSelector, Endpoint endpoint) + { + var selector = _selectorCache.GetEndpointSelector(endpoint); + + Debug.Assert(currentSelector == null || ReferenceEquals(currentSelector, selector)); + + return selector; + } + } } diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs index b333f2947b..ac8c9af69c 100644 --- a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs @@ -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; @@ -14,14 +14,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure private readonly EndpointDataSource _dataSource; private readonly DataSourceDependentCache> _cache; - public DynamicPageEndpointSelector(PageActionEndpointDataSource dataSource) - : this((EndpointDataSource)dataSource) - { - } - - // Exposed for tests. We need to accept a more specific type in the constructor for DI - // to work. - protected DynamicPageEndpointSelector(EndpointDataSource dataSource) + public DynamicPageEndpointSelector(EndpointDataSource dataSource) { if (dataSource == null) { diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelectorCache.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelectorCache.cs new file mode 100644 index 0000000000..5eb8c9ab1f --- /dev/null +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelectorCache.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure +{ + internal class DynamicPageEndpointSelectorCache + { + private readonly ConcurrentDictionary _dataSourceCache = new(); + private readonly ConcurrentDictionary _endpointSelectorCache = new(); + + public void AddDataSource(PageActionEndpointDataSource dataSource) + { + _dataSourceCache.GetOrAdd(dataSource.DataSourceId, dataSource); + } + + // For testing purposes only + internal void AddDataSource(EndpointDataSource dataSource, int key) => + _dataSourceCache.GetOrAdd(key, dataSource); + + public DynamicPageEndpointSelector GetEndpointSelector(Endpoint endpoint) + { + if (endpoint?.Metadata == null) + { + return null; + } + + var dataSourceId = endpoint.Metadata.GetMetadata(); + return _endpointSelectorCache.GetOrAdd(dataSourceId.Id, key => EnsureDataSource(key)); + } + + private DynamicPageEndpointSelector EnsureDataSource(int key) + { + if (!_dataSourceCache.TryGetValue(key, out var dataSource)) + { + throw new InvalidOperationException($"Data source with key '{key}' not registered."); + } + + return new DynamicPageEndpointSelector(dataSource); + } + } +} diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSource.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSource.cs index cefb410c73..f3ae54a251 100644 --- a/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSource.cs +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSource.cs @@ -18,11 +18,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure private readonly OrderedEndpointsSequenceProvider _orderSequence; public PageActionEndpointDataSource( + PageActionEndpointDataSourceIdProvider dataSourceIdProvider, IActionDescriptorCollectionProvider actions, ActionEndpointFactory endpointFactory, OrderedEndpointsSequenceProvider orderedEndpoints) : base(actions) { + DataSourceId = dataSourceIdProvider.CreateId(); _endpointFactory = endpointFactory; _orderSequence = orderedEndpoints; DefaultBuilder = new PageActionEndpointConventionBuilder(Lock, Conventions); @@ -32,6 +34,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure Subscribe(); } + public int DataSourceId { get; } + public PageActionEndpointConventionBuilder DefaultBuilder { get; } // Used to control whether we create 'inert' (non-routable) endpoints for use in dynamic @@ -53,12 +57,12 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure return endpoints; } - internal void AddDynamicPageEndpoint(IEndpointRouteBuilder endpoints, string pattern, Type transformerType, object state) + internal void AddDynamicPageEndpoint(IEndpointRouteBuilder endpoints, string pattern, Type transformerType, object state, int? order = null) { CreateInertEndpoints = true; lock (Lock) { - var order = _orderSequence.GetNext(); + order ??= _orderSequence.GetNext(); endpoints.Map( pattern, @@ -68,11 +72,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure }) .Add(b => { - ((RouteEndpointBuilder)b).Order = order; + ((RouteEndpointBuilder)b).Order = order.Value; b.Metadata.Add(new DynamicPageRouteValueTransformerMetadata(transformerType, state)); + b.Metadata.Add(new PageEndpointDataSourceIdMetadata(DataSourceId)); }); } } } } - diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSourceFactory.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSourceFactory.cs new file mode 100644 index 0000000000..275961d192 --- /dev/null +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSourceFactory.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. + +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure +{ + internal class PageActionEndpointDataSourceFactory + { + private readonly PageActionEndpointDataSourceIdProvider _dataSourceIdProvider; + private readonly IActionDescriptorCollectionProvider _actions; + private readonly ActionEndpointFactory _endpointFactory; + + public PageActionEndpointDataSourceFactory( + PageActionEndpointDataSourceIdProvider dataSourceIdProvider, + IActionDescriptorCollectionProvider actions, + ActionEndpointFactory endpointFactory) + { + _dataSourceIdProvider = dataSourceIdProvider; + _actions = actions; + _endpointFactory = endpointFactory; + } + + public PageActionEndpointDataSource Create(OrderedEndpointsSequenceProvider orderProvider) + { + return new PageActionEndpointDataSource(_dataSourceIdProvider, _actions, _endpointFactory, orderProvider); + } + } +} diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSourceIdProvider.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSourceIdProvider.cs new file mode 100644 index 0000000000..23aed3fae1 --- /dev/null +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSourceIdProvider.cs @@ -0,0 +1,17 @@ +// 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; + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure +{ + internal class PageActionEndpointDataSourceIdProvider + { + private int _nextId = 1; + + internal int CreateId() + { + return Interlocked.Increment(ref _nextId); + } + } +} diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageEndpointDataSourceIdMetadata.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageEndpointDataSourceIdMetadata.cs new file mode 100644 index 0000000000..11353b6932 --- /dev/null +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageEndpointDataSourceIdMetadata.cs @@ -0,0 +1,15 @@ +// 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.RazorPages.Infrastructure +{ + internal class PageEndpointDataSourceIdMetadata + { + public PageEndpointDataSourceIdMetadata(int id) + { + Id = id; + } + + public int Id { get; } + } +} diff --git a/src/Mvc/Mvc.RazorPages/test/Infrastructure/DynamicPageEndpointMatcherPolicyTest.cs b/src/Mvc/Mvc.RazorPages/test/Infrastructure/DynamicPageEndpointMatcherPolicyTest.cs index 7781158e04..8273635588 100644 --- a/src/Mvc/Mvc.RazorPages/test/Infrastructure/DynamicPageEndpointMatcherPolicyTest.cs +++ b/src/Mvc/Mvc.RazorPages/test/Infrastructure/DynamicPageEndpointMatcherPolicyTest.cs @@ -51,12 +51,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure new EndpointMetadataCollection(new object[] { new DynamicPageRouteValueTransformerMetadata(typeof(CustomTransformer), State), + new PageEndpointDataSourceIdMetadata(1), }), "dynamic"); DataSource = new DefaultEndpointDataSource(PageEndpoints); - Selector = new TestDynamicPageEndpointSelector(DataSource); + SelectorCache = new TestDynamicPageEndpointSelectorCache(DataSource); var services = new ServiceCollection(); services.AddRouting(); @@ -106,7 +107,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure private PageLoader Loader { get; } - private DynamicPageEndpointSelector Selector { get; } + private DynamicPageEndpointSelectorCache SelectorCache { get; } private object State { get; } @@ -120,7 +121,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure public async Task ApplyAsync_NoMatch() { // Arrange - var policy = new DynamicPageEndpointMatcherPolicy(Selector, Loader, Comparer); + var policy = new DynamicPageEndpointMatcherPolicy(SelectorCache, Loader, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { null, }; @@ -150,7 +151,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure public async Task ApplyAsync_HasMatchNoEndpointFound() { // Arrange - var policy = new DynamicPageEndpointMatcherPolicy(Selector, Loader, Comparer); + var policy = new DynamicPageEndpointMatcherPolicy(SelectorCache, Loader, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { null, }; @@ -181,7 +182,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure public async Task ApplyAsync_HasMatchFindsEndpoint_WithoutRouteValues() { // Arrange - var policy = new DynamicPageEndpointMatcherPolicy(Selector, Loader, Comparer); + var policy = new DynamicPageEndpointMatcherPolicy(SelectorCache, Loader, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { null, }; @@ -221,7 +222,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure public async Task ApplyAsync_HasMatchFindsEndpoint_WithRouteValues() { // Arrange - var policy = new DynamicPageEndpointMatcherPolicy(Selector, Loader, Comparer); + var policy = new DynamicPageEndpointMatcherPolicy(SelectorCache, Loader, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -272,7 +273,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure public async Task ApplyAsync_Throws_ForTransformersWithInvalidLifetime() { // Arrange - var policy = new DynamicPageEndpointMatcherPolicy(Selector, Loader, Comparer); + var policy = new DynamicPageEndpointMatcherPolicy(SelectorCache, Loader, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -302,7 +303,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure public async Task ApplyAsync_CanDiscardFoundEndpoints() { // Arrange - var policy = new DynamicPageEndpointMatcherPolicy(Selector, Loader, Comparer); + var policy = new DynamicPageEndpointMatcherPolicy(SelectorCache, Loader, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -340,7 +341,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure public async Task ApplyAsync_CanReplaceFoundEndpoints() { // Arrange - var policy = new DynamicPageEndpointMatcherPolicy(Selector, Loader, Comparer); + var policy = new DynamicPageEndpointMatcherPolicy(SelectorCache, Loader, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -400,7 +401,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure public async Task ApplyAsync_CanExpandTheListOfFoundEndpoints() { // Arrange - var policy = new DynamicPageEndpointMatcherPolicy(Selector, Loader, Comparer); + var policy = new DynamicPageEndpointMatcherPolicy(SelectorCache, Loader, Comparer); var endpoints = new[] { DynamicEndpoint, }; var values = new RouteValueDictionary[] { new RouteValueDictionary(new { slug = "test", }), }; @@ -438,11 +439,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure Assert.Same(LoadedEndpoints[1], candidates[1].Endpoint); } - private class TestDynamicPageEndpointSelector : DynamicPageEndpointSelector + private class TestDynamicPageEndpointSelectorCache : DynamicPageEndpointSelectorCache { - public TestDynamicPageEndpointSelector(EndpointDataSource dataSource) - : base(dataSource) + public TestDynamicPageEndpointSelectorCache(EndpointDataSource dataSource) { + AddDataSource(dataSource, 1); } } diff --git a/src/Mvc/Mvc.RazorPages/test/Infrastructure/PageActionEndpointDataSourceTest.cs b/src/Mvc/Mvc.RazorPages/test/Infrastructure/PageActionEndpointDataSourceTest.cs index 291b3a801e..2e3ef71e05 100644 --- a/src/Mvc/Mvc.RazorPages/test/Infrastructure/PageActionEndpointDataSourceTest.cs +++ b/src/Mvc/Mvc.RazorPages/test/Infrastructure/PageActionEndpointDataSourceTest.cs @@ -92,7 +92,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure private protected override ActionEndpointDataSourceBase CreateDataSource(IActionDescriptorCollectionProvider actions, ActionEndpointFactory endpointFactory) { - return new PageActionEndpointDataSource(actions, endpointFactory, new OrderedEndpointsSequenceProvider()); + return new PageActionEndpointDataSource(new PageActionEndpointDataSourceIdProvider(), actions, endpointFactory, new OrderedEndpointsSequenceProvider()); } protected override ActionDescriptor CreateActionDescriptor( diff --git a/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ControllerActionEndpointDatasourceBenchmark.cs b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ControllerActionEndpointDatasourceBenchmark.cs index 8b96502295..fc06826ccc 100644 --- a/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ControllerActionEndpointDatasourceBenchmark.cs +++ b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ControllerActionEndpointDatasourceBenchmark.cs @@ -109,6 +109,7 @@ namespace Microsoft.AspNetCore.Mvc.Performance private ControllerActionEndpointDataSource CreateDataSource(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) { var dataSource = new ControllerActionEndpointDataSource( + new ControllerActionEndpointDataSourceIdProvider(), actionDescriptorCollectionProvider, new ActionEndpointFactory(new MockRoutePatternTransformer()), new OrderedEndpointsSequenceProvider()); diff --git a/src/Mvc/test/Mvc.FunctionalTests/RoutingAcrossPipelineBranchesTest.cs b/src/Mvc/test/Mvc.FunctionalTests/RoutingAcrossPipelineBranchesTest.cs new file mode 100644 index 0000000000..431622df9f --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/RoutingAcrossPipelineBranchesTest.cs @@ -0,0 +1,172 @@ +// 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.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 RoutingAcrossPipelineBranchesTests : IClassFixture> + { + public RoutingAcrossPipelineBranchesTests(MvcTestFixture fixture) + { + Factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => builder.UseStartup(); + + public WebApplicationFactory Factory { get; } + + [Fact] + public async Task MatchesConventionalRoutesInTheirBranches() + { + var client = Factory.CreateClient(); + + // Arrange + var subdirRequest = new HttpRequestMessage(HttpMethod.Get, "subdir/literal/Branches/Index/s"); + var commonRequest = new HttpRequestMessage(HttpMethod.Get, "common/Branches/Index/c/literal"); + var defaultRequest = new HttpRequestMessage(HttpMethod.Get, "Branches/literal/Index/d"); + + // Act + var subdirResponse = await client.SendAsync(subdirRequest); + var subdirContent = await subdirResponse.Content.ReadFromJsonAsync(); + + var commonResponse = await client.SendAsync(commonRequest); + var commonContent = await commonResponse.Content.ReadFromJsonAsync(); + + var defaultResponse = await client.SendAsync(defaultRequest); + var defaultContent = await defaultResponse.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, subdirResponse.StatusCode); + Assert.True(subdirContent.RouteValues.TryGetValue("subdir", out var subdir)); + Assert.Equal("s", subdir); + + Assert.Equal(HttpStatusCode.OK, commonResponse.StatusCode); + Assert.True(commonContent.RouteValues.TryGetValue("common", out var common)); + Assert.Equal("c", common); + + Assert.Equal(HttpStatusCode.OK, defaultResponse.StatusCode); + Assert.True(defaultContent.RouteValues.TryGetValue("default", out var @default)); + Assert.Equal("d", @default); + } + + [Fact] + public async Task LinkGenerationWorksOnEachBranch() + { + var client = Factory.CreateClient(); + var linkQuery = "?link"; + + // Arrange + var subdirRequest = new HttpRequestMessage(HttpMethod.Get, "subdir/literal/Branches/Index/s" + linkQuery); + var commonRequest = new HttpRequestMessage(HttpMethod.Get, "common/Branches/Index/c/literal" + linkQuery); + var defaultRequest = new HttpRequestMessage(HttpMethod.Get, "Branches/literal/Index/d" + linkQuery); + + // Act + var subdirResponse = await client.SendAsync(subdirRequest); + var subdirContent = await subdirResponse.Content.ReadFromJsonAsync(); + + var commonResponse = await client.SendAsync(commonRequest); + var commonContent = await commonResponse.Content.ReadFromJsonAsync(); + + var defaultResponse = await client.SendAsync(defaultRequest); + var defaultContent = await defaultResponse.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, subdirResponse.StatusCode); + Assert.Equal("/subdir/literal/Branches/Index/s", subdirContent.Link); + + Assert.Equal(HttpStatusCode.OK, commonResponse.StatusCode); + Assert.Equal("/common/Branches/Index/c/literal", commonContent.Link); + + Assert.Equal(HttpStatusCode.OK, defaultResponse.StatusCode); + Assert.Equal("/Branches/literal/Index/d", defaultContent.Link); + } + + // This still works because even though each middleware now gets its own data source, + // those data sources still get added to a global collection in IOptions>.DataSources + [Fact] + public async Task LinkGenerationStillWorksAcrossBranches() + { + var client = Factory.CreateClient(); + var linkQuery = "?link"; + + // Arrange + var subdirRequest = new HttpRequestMessage(HttpMethod.Get, "subdir/literal/Branches/Index/s" + linkQuery + "&link_common=c&link_subdir"); + var defaultRequest = new HttpRequestMessage(HttpMethod.Get, "Branches/literal/Index/d" + linkQuery + "&link_subdir=s"); + + // Act + var subdirResponse = await client.SendAsync(subdirRequest); + var subdirContent = await subdirResponse.Content.ReadFromJsonAsync(); + + var defaultResponse = await client.SendAsync(defaultRequest); + var defaultContent = await defaultResponse.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, subdirResponse.StatusCode); + // Note that this link and the one below don't account for the path base being in a different branch. + // The recommendation for customers doing link generation across branches will be to always generate absolute + // URIs by explicitly passing the path base to the link generator. + // In the future there are improvements we might be able to do in most common cases to lift this limitation if we receive + // feedback about it. + Assert.Equal("/subdir/Branches/Index/c/literal", subdirContent.Link); + + Assert.Equal(HttpStatusCode.OK, defaultResponse.StatusCode); + Assert.Equal("/literal/Branches/Index/s", defaultContent.Link); + } + + [Fact] + public async Task DoesNotMatchConventionalRoutesDefinedInOtherBranches() + { + var client = Factory.CreateClient(); + + // Arrange + var commonRequest = new HttpRequestMessage(HttpMethod.Get, "common/literal/Branches/Index/s"); + var subdirRequest = new HttpRequestMessage(HttpMethod.Get, "subdir/Branches/Index/c/literal"); + var defaultRequest = new HttpRequestMessage(HttpMethod.Get, "common/Branches/literal/Index/d"); + + // Act + var commonResponse = await client.SendAsync(commonRequest); + + var subdirResponse = await client.SendAsync(subdirRequest); + + var defaultResponse = await client.SendAsync(defaultRequest); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, commonResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, subdirResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, defaultResponse.StatusCode); + } + + [Fact] + public async Task ConventionalAndDynamicRouteOrdersAreScopedPerBranch() + { + var client = Factory.CreateClient(); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "dynamicattributeorder/dynamic/route/rest"); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(content.RouteValues.TryGetValue("action", out var action)); + + // The dynamic route wins because it has Order 1 (scope to that router) and + // has higher precedence. + Assert.Equal("Index", action); + } + + private record RouteInfo(string RouteName, IDictionary RouteValues, string Link); + } +} diff --git a/src/Mvc/test/WebSites/Common/TestResponseGenerator.cs b/src/Mvc/test/WebSites/Common/TestResponseGenerator.cs index e2492fbfec..21d97865c5 100644 --- a/src/Mvc/test/WebSites/Common/TestResponseGenerator.cs +++ b/src/Mvc/test/WebSites/Common/TestResponseGenerator.cs @@ -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; diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Controllers/BranchesController.cs b/src/Mvc/test/WebSites/RoutingWebSite/Controllers/BranchesController.cs new file mode 100644 index 0000000000..847d30dad0 --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/Controllers/BranchesController.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Mvc.RoutingWebSite.Controllers +{ + public class BranchesController : Controller + { + private readonly TestResponseGenerator _generator; + + public BranchesController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult Index() + { + return _generator.Generate(); + } + + [HttpGet("dynamicattributeorder/{some}/{value}/{**slug}", Order = 1)] + public IActionResult Attribute() + { + return _generator.Generate(); + } + } +} diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Program.cs b/src/Mvc/test/WebSites/RoutingWebSite/Program.cs index 83b2991269..a5d1a83121 100644 --- a/src/Mvc/test/WebSites/RoutingWebSite/Program.cs +++ b/src/Mvc/test/WebSites/RoutingWebSite/Program.cs @@ -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.IO; diff --git a/src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs b/src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs index 7b22a48dce..92d5522051 100644 --- a/src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs +++ b/src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs @@ -7,6 +7,7 @@ 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.DependencyInjection; @@ -24,6 +25,8 @@ namespace RoutingWebSite .SetCompatibilityVersion(CompatibilityVersion.Latest); services.AddTransient(); + services.AddScoped(); + services.AddSingleton(); // Used by some controllers defined in this project. services.Configure(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer)); diff --git a/src/Mvc/test/WebSites/RoutingWebSite/StartupRoutingDifferentBranches.cs b/src/Mvc/test/WebSites/RoutingWebSite/StartupRoutingDifferentBranches.cs new file mode 100644 index 0000000000..d118d7d12a --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/StartupRoutingDifferentBranches.cs @@ -0,0 +1,104 @@ +// 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; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace RoutingWebSite +{ + public class StartupRoutingDifferentBranches + { + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + var pageRouteTransformerConvention = new PageRouteTransformerConvention(new SlugifyParameterTransformer()); + + services + .AddMvc(ConfigureMvcOptions) + .AddNewtonsoftJson() + .AddRazorPagesOptions(options => + { + options.Conventions.AddPageRoute("/PageRouteTransformer/PageWithConfiguredRoute", "/PageRouteTransformer/NewConventionRoute/{id?}"); + options.Conventions.AddFolderRouteModelConvention("/PageRouteTransformer", model => + { + pageRouteTransformerConvention.Apply(model); + }); + }) + .SetCompatibilityVersion(CompatibilityVersion.Latest); + + ConfigureRoutingServices(services); + + services.AddScoped(); + // This is used by test response generator + services.AddSingleton(); + services.AddSingleton(); + } + + public virtual void Configure(IApplicationBuilder app) + { + app.Map("/subdir", branch => + { + branch.UseRouting(); + + branch.UseEndpoints(endpoints => + { + endpoints.MapRazorPages(); + endpoints.MapControllerRoute(null, "literal/{controller}/{action}/{subdir}"); + endpoints.MapDynamicControllerRoute("literal/dynamic/controller/{**slug}"); + }); + }); + + app.Map("/common", branch => + { + branch.UseRouting(); + + branch.UseEndpoints(endpoints => + { + endpoints.MapControllerRoute(null, "{controller}/{action}/{common}/literal"); + endpoints.MapDynamicControllerRoute("dynamic/controller/literal/{**slug}"); + }); + }); + + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapDynamicControllerRoute("dynamicattributeorder/dynamic/route/{**slug}"); + endpoints.MapControllerRoute(null, "{controller}/literal/{action}/{default}"); + }); + + app.Run(c => + { + return c.Response.WriteAsync("Hello from middleware after routing"); + }); + } + + protected virtual void ConfigureMvcOptions(MvcOptions options) + { + // Add route token transformer to one controller + options.Conventions.Add(new ControllerRouteTokenTransformerConvention( + typeof(ParameterTransformerController), + new SlugifyParameterTransformer())); + } + + protected virtual void ConfigureRoutingServices(IServiceCollection services) + { + services.AddRouting(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer)); + } + } + + public class BranchesTransformer : DynamicRouteValueTransformer + { + public override ValueTask TransformAsync(HttpContext httpContext, RouteValueDictionary values) + { + return new ValueTask(new RouteValueDictionary(new { controller = "Branches", action = "Index" })); + } + } +}