diff --git a/src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs b/src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs index ea1989e477..b6389ab76a 100644 --- a/src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs +++ b/src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs @@ -113,8 +113,8 @@ namespace Microsoft.AspNetCore.Routing.Matching { // We do this check first for consistency with how 405 is implemented for the graph version // of this code. We still want to know if any endpoints in this set require an HTTP method - // even if those endpoints are already invalid. - var metadata = candidates[i].Endpoint.Metadata.GetMetadata(); + // even if those endpoints are already invalid - hence the null-check. + var metadata = candidates[i].Endpoint?.Metadata.GetMetadata(); if (metadata == null || metadata.HttpMethods.Count == 0) { // Can match any method. diff --git a/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs b/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs index bf33019b86..1c0ac938e5 100644 --- a/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs +++ b/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs @@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Builder public static Microsoft.AspNetCore.Builder.ControllerActionEndpointConventionBuilder MapControllerRoute(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string name, string pattern, object defaults = null, object constraints = null, object dataTokens = null) { throw null; } public static Microsoft.AspNetCore.Builder.ControllerActionEndpointConventionBuilder MapControllers(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints) { throw null; } public static Microsoft.AspNetCore.Builder.ControllerActionEndpointConventionBuilder MapDefaultControllerRoute(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints) { throw null; } + public static void MapDynamicControllerRoute(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern) where TTransformer : Microsoft.AspNetCore.Mvc.Routing.DynamicRouteValueTransformer { } public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToAreaController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string action, string controller, string area) { throw null; } public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToAreaController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, string action, string controller, string area) { throw null; } public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string action, string controller) { throw null; } @@ -2909,6 +2910,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation } namespace Microsoft.AspNetCore.Mvc.Routing { + public abstract partial class DynamicRouteValueTransformer + { + protected DynamicRouteValueTransformer() { } + public abstract System.Threading.Tasks.Task TransformAsync(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.Routing.RouteValueDictionary values); + } [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=true, Inherited=true)] public abstract partial class HttpMethodAttribute : System.Attribute, Microsoft.AspNetCore.Mvc.Routing.IActionHttpMethodProvider, Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider { diff --git a/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs b/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs index ae1a408f36..3d59157867 100644 --- a/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs +++ b/src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Core; 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 @@ -212,7 +213,7 @@ namespace Microsoft.AspNetCore.Builder EnsureControllerServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints); + GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -288,7 +289,7 @@ namespace Microsoft.AspNetCore.Builder EnsureControllerServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints); + GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -356,7 +357,7 @@ namespace Microsoft.AspNetCore.Builder EnsureControllerServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints); + GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -434,7 +435,7 @@ namespace Microsoft.AspNetCore.Builder EnsureControllerServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints); + GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -447,6 +448,48 @@ namespace Microsoft.AspNetCore.Builder return builder; } + /// + /// 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. + /// 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 with the desired service lifetime in ConfigureServices. + /// + /// + public static void MapDynamicControllerRoute(this IEndpointRouteBuilder endpoints, string pattern) + 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. + 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))); + }); + } + private static DynamicControllerMetadata CreateDynamicControllerMetadata(string action, string controller, string area) { return new DynamicControllerMetadata(new RouteValueDictionary() diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs index 2abc8e6e71..c028fcf6ed 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs @@ -74,23 +74,23 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure }); } - public static ActionSelectionTable Create(IEnumerable endpoints) + public static ActionSelectionTable Create(IEnumerable endpoints) { - return CreateCore( + return CreateCore( // we don't use version for endpoints version: 0, - // Only include RouteEndpoints and only those that aren't suppressed. - items: endpoints.OfType().Where(e => + // Exclude RouteEndpoints - we only process inert endpoints here. + items: endpoints.Where(e => { - return e.Metadata.GetMetadata()?.SuppressMatching != true; + return e.GetType() == typeof(Endpoint); }), - getRouteKeys: e => e.RoutePattern.RequiredValues.Keys, + getRouteKeys: e => e.Metadata.GetMetadata().RouteValues.Keys, getRouteValue: (e, key) => { - e.RoutePattern.RequiredValues.TryGetValue(key, out var value); + e.Metadata.GetMetadata().RouteValues.TryGetValue(key, out var value); return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; }); } diff --git a/src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs b/src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs index bbbd974d83..8f3b4dd74b 100644 --- a/src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs +++ b/src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs @@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing internal class ActionEndpointFactory { private readonly RoutePatternTransformer _routePatternTransformer; + private readonly RequestDelegate _requestDelegate; public ActionEndpointFactory(RoutePatternTransformer routePatternTransformer) { @@ -29,6 +30,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing } _routePatternTransformer = routePatternTransformer; + _requestDelegate = CreateRequestDelegate(); } public void AddEndpoints( @@ -36,7 +38,8 @@ namespace Microsoft.AspNetCore.Mvc.Routing HashSet routeNames, ActionDescriptor action, IReadOnlyList routes, - IReadOnlyList> conventions) + IReadOnlyList> conventions, + bool createInertEndpoints) { if (endpoints == null) { @@ -63,6 +66,26 @@ namespace Microsoft.AspNetCore.Mvc.Routing throw new ArgumentNullException(nameof(conventions)); } + if (createInertEndpoints) + { + var builder = new InertEndpointBuilder() + { + DisplayName = action.DisplayName, + RequestDelegate = _requestDelegate, + }; + AddActionDataToBuilder( + builder, + routeNames, + action, + routeName: null, + dataTokens: null, + suppressLinkGeneration: false, + suppressPathMatching: false, + conventions, + Array.Empty>()); + endpoints.Add(builder.Build()); + } + if (action.AttributeRouteInfo == null) { // Check each of the conventional patterns to see if the action would be reachable. @@ -81,18 +104,21 @@ namespace Microsoft.AspNetCore.Mvc.Routing // We suppress link generation for each conventionally routed endpoint. We generate a single endpoint per-route // to handle link generation. - var builder = CreateEndpoint( + var builder = new RouteEndpointBuilder(_requestDelegate, updatedRoutePattern, route.Order) + { + DisplayName = action.DisplayName, + }; + AddActionDataToBuilder( + builder, routeNames, action, - updatedRoutePattern, route.RouteName, - route.Order, route.DataTokens, suppressLinkGeneration: true, suppressPathMatching: false, conventions, route.Conventions); - endpoints.Add(builder); + endpoints.Add(builder.Build()); } } else @@ -109,18 +135,21 @@ namespace Microsoft.AspNetCore.Mvc.Routing throw new InvalidOperationException("Failed to update route pattern with required values."); } - var endpoint = CreateEndpoint( + var builder = new RouteEndpointBuilder(_requestDelegate, updatedRoutePattern, action.AttributeRouteInfo.Order) + { + DisplayName = action.DisplayName, + }; + AddActionDataToBuilder( + builder, routeNames, action, - updatedRoutePattern, action.AttributeRouteInfo.Name, - action.AttributeRouteInfo.Order, dataTokens: null, action.AttributeRouteInfo.SuppressLinkGeneration, action.AttributeRouteInfo.SuppressPathMatching, conventions, perRouteConventions: Array.Empty>()); - endpoints.Add(endpoint); + endpoints.Add(builder.Build()); } } @@ -262,49 +291,17 @@ namespace Microsoft.AspNetCore.Mvc.Routing return (attributeRoutePattern, resolvedRequiredValues ?? action.RouteValues); } - private RouteEndpoint CreateEndpoint( + private void AddActionDataToBuilder( + EndpointBuilder builder, HashSet routeNames, ActionDescriptor action, - RoutePattern routePattern, string routeName, - int order, RouteValueDictionary dataTokens, bool suppressLinkGeneration, bool suppressPathMatching, IReadOnlyList> conventions, IReadOnlyList> perRouteConventions) { - - // We don't want to close over the retrieve the Invoker Factory in ActionEndpointFactory as - // that creates cycles in DI. Since we're creating this delegate at startup time - // we don't want to create all of the things we use at runtime until the action - // actually matches. - // - // The request delegate is already a closure here because we close over - // the action descriptor. - IActionInvokerFactory invokerFactory = null; - - RequestDelegate requestDelegate = (context) => - { - var routeData = new RouteData(); - routeData.PushState(router: null, context.Request.RouteValues, dataTokens); - - var actionContext = new ActionContext(context, routeData, action); - - if (invokerFactory == null) - { - invokerFactory = context.RequestServices.GetRequiredService(); - } - - var invoker = invokerFactory.CreateInvoker(actionContext); - return invoker.InvokeAsync(); - }; - - var builder = new RouteEndpointBuilder(requestDelegate, routePattern, order) - { - DisplayName = action.DisplayName, - }; - // Add action metadata first so it has a low precedence if (action.EndpointMetadata != null) { @@ -399,8 +396,47 @@ namespace Microsoft.AspNetCore.Mvc.Routing { perRouteConventions[i](builder); } + } - return (RouteEndpoint)builder.Build(); + private static RequestDelegate CreateRequestDelegate() + { + // We don't want to close over the Invoker Factory in ActionEndpointFactory as + // that creates cycles in DI. Since we're creating this delegate at startup time + // we don't want to create all of the things we use at runtime until the action + // actually matches. + // + // The request delegate is already a closure here because we close over + // the action descriptor. + IActionInvokerFactory invokerFactory = null; + + return (context) => + { + var endpoint = context.GetEndpoint(); + var dataTokens = endpoint.Metadata.GetMetadata(); + + var routeData = new RouteData(); + routeData.PushState(router: null, context.Request.RouteValues, new RouteValueDictionary(dataTokens?.DataTokens)); + + // Don't close over the ActionDescriptor, that's not valid for pages. + var action = endpoint.Metadata.GetMetadata(); + var actionContext = new ActionContext(context, routeData, action); + + if (invokerFactory == null) + { + invokerFactory = context.RequestServices.GetRequiredService(); + } + + var invoker = invokerFactory.CreateInvoker(actionContext); + return invoker.InvokeAsync(); + }; + } + + private class InertEndpointBuilder : EndpointBuilder + { + public override Endpoint Build() + { + return new Endpoint(RequestDelegate, new EndpointMetadataCollection(Metadata), DisplayName); + } } } } diff --git a/src/Mvc/Mvc.Core/src/Routing/ConsumesMatcherPolicy.cs b/src/Mvc/Mvc.Core/src/Routing/ConsumesMatcherPolicy.cs index abf85e3284..dbc65f26f4 100644 --- a/src/Mvc/Mvc.Core/src/Routing/ConsumesMatcherPolicy.cs +++ b/src/Mvc/Mvc.Core/src/Routing/ConsumesMatcherPolicy.cs @@ -73,8 +73,8 @@ namespace Microsoft.AspNetCore.Mvc.Routing { // We do this check first for consistency with how 415 is implemented for the graph version // of this code. We still want to know if any endpoints in this set require an a ContentType - // even if those endpoints are already invalid. - var metadata = candidates[i].Endpoint.Metadata.GetMetadata(); + // even if those endpoints are already invalid - hence the null check. + var metadata = candidates[i].Endpoint?.Metadata.GetMetadata(); if (metadata == null || metadata.ContentTypes.Count == 0) { // Can match any content type. diff --git a/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSource.cs b/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSource.cs index 6f9e4d492f..8a27c982dc 100644 --- a/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSource.cs +++ b/src/Mvc/Mvc.Core/src/Routing/ControllerActionEndpointDataSource.cs @@ -20,12 +20,12 @@ namespace Microsoft.AspNetCore.Mvc.Routing private int _order; public ControllerActionEndpointDataSource( - IActionDescriptorCollectionProvider actions, + IActionDescriptorCollectionProvider actions, ActionEndpointFactory endpointFactory) : base(actions) { _endpointFactory = endpointFactory; - + _routes = new List(); // In traditional conventional routing setup, the routes defined by a user have a order @@ -38,13 +38,17 @@ namespace Microsoft.AspNetCore.Mvc.Routing DefaultBuilder = new ControllerActionEndpointConventionBuilder(Lock, Conventions); - // IMPORTANT: this needs to be the last thing we do in the constructor. + // IMPORTANT: this needs to be the last thing we do in the constructor. // Change notifications can happen immediately! Subscribe(); } public ControllerActionEndpointConventionBuilder DefaultBuilder { get; } + // Used to control whether we create 'inert' (non-routable) endpoints for use in dynamic + // selection. Set to true by builder methods that do dynamic/fallback selection. + public bool CreateInertEndpoints { get; set; } + public ControllerActionEndpointConventionBuilder AddRoute( string routeName, string pattern, @@ -80,7 +84,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing { if (actions[i] is ControllerActionDescriptor action) { - _endpointFactory.AddEndpoints(endpoints, routeNames, action, _routes, conventions); + _endpointFactory.AddEndpoints(endpoints, routeNames, action, _routes, conventions, CreateInertEndpoints); if (_routes.Count > 0) { diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs index 7ac5115056..369502d633 100644 --- a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs +++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointMatcherPolicy.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc.Routing { @@ -49,8 +50,13 @@ namespace Microsoft.AspNetCore.Mvc.Routing for (var i = 0; i < endpoints.Count; i++) { - var metadata = endpoints[i].Metadata.GetMetadata(); - if (metadata != null) + if (endpoints[i].Metadata.GetMetadata() != null) + { + // Found a dynamic controller endpoint + return true; + } + + if (endpoints[i].Metadata.GetMetadata() != null) { // Found a dynamic controller endpoint return true; @@ -60,7 +66,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing return false; } - public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) + public async Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) { if (httpContext == null) { @@ -83,30 +89,56 @@ namespace Microsoft.AspNetCore.Mvc.Routing } var endpoint = candidates[i].Endpoint; + var originalValues = candidates[i].Values; - var metadata = endpoint.Metadata.GetMetadata(); - if (metadata == null) + RouteValueDictionary dynamicValues = null; + + // We don't expect both of these to be provided, and they are internal so there's + // no realistic way this could happen. + var dynamicControllerMetadata = endpoint.Metadata.GetMetadata(); + var transformerMetadata = endpoint.Metadata.GetMetadata(); + if (dynamicControllerMetadata != null) { + dynamicValues = dynamicControllerMetadata.Values; + } + else if (transformerMetadata != null) + { + var transformer = (DynamicRouteValueTransformer)httpContext.RequestServices.GetRequiredService(transformerMetadata.SelectorType); + dynamicValues = await transformer.TransformAsync(httpContext, originalValues); + } + else + { + // Not a dynamic controller. continue; } - var matchedValues = candidates[i].Values; - var endpoints = _selector.SelectEndpoints(metadata.Values); - if (endpoints.Count == 0) + if (dynamicValues == null) { - // If there's no match this is a configuration error. We can't really check - // during startup that the action you configured exists. + candidates.ReplaceEndpoint(i, null, null); + continue; + } + + 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 + // during startup that the action you configured exists, so this is the best we can do. throw new InvalidOperationException( "Cannot find the fallback endpoint specified by route values: " + - "{ " + string.Join(", ", metadata.Values.Select(kvp => $"{kvp.Key}: {kvp.Value}")) + " }."); + "{ " + string.Join(", ", dynamicValues.Select(kvp => $"{kvp.Key}: {kvp.Value}")) + " }."); + } + else if (endpoints.Count == 0) + { + candidates.ReplaceEndpoint(i, null, null); + continue; } // We need to provide the route values associated with this endpoint, so that features // like URL generation work. - var values = new RouteValueDictionary(metadata.Values); + var values = new RouteValueDictionary(dynamicValues); // Include values that were matched by the fallback route. - foreach (var kvp in matchedValues) + foreach (var kvp in originalValues) { values.TryAdd(kvp.Key, kvp.Value); } @@ -117,8 +149,6 @@ namespace Microsoft.AspNetCore.Mvc.Routing // Expand the list of endpoints candidates.ExpandEndpoint(i, endpoints, _comparer); } - - return Task.CompletedTask; } } } diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs index cbf2f6ac21..fbe0337420 100644 --- a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs +++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerEndpointSelector.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing internal class DynamicControllerEndpointSelector : IDisposable { private readonly ControllerActionEndpointDataSource _dataSource; - private readonly DataSourceDependentCache> _cache; + private readonly DataSourceDependentCache> _cache; public DynamicControllerEndpointSelector(ControllerActionEndpointDataSource dataSource) { @@ -22,12 +22,13 @@ namespace Microsoft.AspNetCore.Mvc.Routing } _dataSource = dataSource; - _cache = new DataSourceDependentCache>(dataSource, Initialize); + + _cache = new DataSourceDependentCache>(dataSource, Initialize); } - private ActionSelectionTable Table => _cache.EnsureInitialized(); + private ActionSelectionTable Table => _cache.EnsureInitialized(); - public IReadOnlyList SelectEndpoints(RouteValueDictionary values) + public IReadOnlyList SelectEndpoints(RouteValueDictionary values) { if (values == null) { @@ -38,10 +39,9 @@ namespace Microsoft.AspNetCore.Mvc.Routing var matches = table.Select(values); return matches; } - - private static ActionSelectionTable Initialize(IReadOnlyList endpoints) + private static ActionSelectionTable Initialize(IReadOnlyList endpoints) { - return ActionSelectionTable.Create(endpoints); + return ActionSelectionTable.Create(endpoints); } public void Dispose() diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicControllerRouteValueTransformerMetadata.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerRouteValueTransformerMetadata.cs new file mode 100644 index 0000000000..ebe6d07cc1 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Routing/DynamicControllerRouteValueTransformerMetadata.cs @@ -0,0 +1,32 @@ +// 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 Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + internal class DynamicControllerRouteValueTransformerMetadata : IDynamicEndpointMetadata + { + public DynamicControllerRouteValueTransformerMetadata(Type selectorType) + { + if (selectorType == null) + { + throw new ArgumentNullException(nameof(selectorType)); + } + + if (!typeof(DynamicRouteValueTransformer).IsAssignableFrom(selectorType)) + { + throw new ArgumentException( + $"The provided type must be a subclass of {typeof(DynamicRouteValueTransformer)}", + nameof(selectorType)); + } + + SelectorType = selectorType; + } + + public bool IsDynamic => true; + + public Type SelectorType { get; } + } +} diff --git a/src/Mvc/Mvc.Core/src/Routing/DynamicRouteValueTransformer.cs b/src/Mvc/Mvc.Core/src/Routing/DynamicRouteValueTransformer.cs new file mode 100644 index 0000000000..24a989e77c --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Routing/DynamicRouteValueTransformer.cs @@ -0,0 +1,42 @@ +// 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.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + /// + /// Provides an abstraction for dynamically manipulating route value to select a controller action or page. + /// + /// + /// + /// can be used with + /// + /// or MapDynamicPageRoute to implement custom logic that selects a controller action or page. + /// + /// + /// The route values returned from a implementation + /// will be used to select an action based on matching of the route values. All actions that match the route values + /// will be considered as candidates, and may be further disambiguated by + /// implementations such as . + /// + /// + /// Implementations should be registered with the service + /// collection as type . Implementations can use any service + /// lifetime. + /// + /// + public abstract class DynamicRouteValueTransformer + { + /// + /// Creates a set of transformed route values that will be used to select an action. + /// + /// The associated with the current request. + /// The route values associated with the current match. Implementations should not modify . + /// A task which asynchronously returns a set of route values. + public abstract Task TransformAsync(HttpContext httpContext, RouteValueDictionary values); + } +} diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/ActionSelectionTableTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/ActionSelectionTableTest.cs index 4e961370d9..c6cb0dcfad 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/ActionSelectionTableTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/ActionSelectionTableTest.cs @@ -300,36 +300,6 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure Assert.Single(matches); } - [Fact] - public void Select_Endpoint_NoMatch_ExcludesMatchingSuppressedAction() - { - var actions = new ActionDescriptor[] - { - new ActionDescriptor() - { - DisplayName = "A1", - RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "controller", "Home" }, - { "action", "Index" } - }, - EndpointMetadata = new List() - { - new SuppressMatchingMetadata(), - }, - }, - }; - - var table = CreateTableWithEndpoints(actions); - var values = new RouteValueDictionary(new { controller = "Home", action = "Index", }); - - // Act - var matches = table.Select(values); - - // Assert - Assert.Empty(matches); - } - // In this context `CaseSensitiveMatch` means that the input route values exactly match one of the action // descriptor's route values in terms of casing. This is important because we optimize for this case // in the implementation. @@ -584,20 +554,16 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure return ActionSelectionTable.Create(new ActionDescriptorCollection(actions, 0)); } - private static ActionSelectionTable CreateTableWithEndpoints(IReadOnlyList actions) + private static ActionSelectionTable CreateTableWithEndpoints(IReadOnlyList actions) { - - var endpoints = actions.Select(a => { var metadata = new List(a.EndpointMetadata ?? Array.Empty()); metadata.Add(a); - return new RouteEndpoint( + return new Endpoint( requestDelegate: context => Task.CompletedTask, - routePattern: RoutePatternFactory.Parse("/", defaults: a.RouteValues, parameterPolicies: null, requiredValues: a.RouteValues), - order: 0, metadata: new EndpointMetadataCollection(metadata), - a.DisplayName); + displayName: a.DisplayName); }); return ActionSelectionTable.Create(endpoints); diff --git a/src/Mvc/Mvc.Core/test/Routing/ActionEndpointFactoryTest.cs b/src/Mvc/Mvc.Core/test/Routing/ActionEndpointFactoryTest.cs index a209f710c3..0eb69069bf 100644 --- a/src/Mvc/Mvc.Core/test/Routing/ActionEndpointFactoryTest.cs +++ b/src/Mvc/Mvc.Core/test/Routing/ActionEndpointFactoryTest.cs @@ -64,7 +64,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing var action = CreateActionDescriptor(values); var route = CreateRoute( routeName: "Test", - pattern: "{controller}/{action}/{page}", + pattern: "{controller}/{action}/{page}", defaults: new RouteValueDictionary(new { action = "TestAction" })); // Act @@ -144,8 +144,8 @@ namespace Microsoft.AspNetCore.Mvc.Routing var values = new { controller = "TestController", action = "TestAction", page = (string)null }; var action = CreateActionDescriptor(values); var route = CreateRoute( - routeName: "test", - pattern: "{controller}/{action}/{id?}", + routeName: "test", + pattern: "{controller}/{action}/{id?}", defaults: new RouteValueDictionary(new { controller = "TestController", action = "TestAction1" })); // Act @@ -166,8 +166,8 @@ namespace Microsoft.AspNetCore.Mvc.Routing var values = new { controller = "TestController", action = "TestAction", page = (string)null }; var action = CreateActionDescriptor(values); var route = CreateRoute( - routeName: "test", - pattern: "/Blog/{*slug}", + routeName: "test", + pattern: "/Blog/{*slug}", defaults: new RouteValueDictionary(new { controller = "TestController", action = "TestAction1" })); // Act @@ -252,7 +252,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing var values = new { controller = "TestController", action = "TestAction1", page = (string)null }; var action = CreateActionDescriptor(values); var route = CreateRoute( - routeName: "test", + routeName: "test", pattern: "{controller}/{action}", constraints: new RouteValueDictionary(new { action = "(TestAction1|TestAction2)" })); @@ -270,7 +270,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing var values = new { controller = "TestController", action = "TestAction", page = (string)null }; var action = CreateActionDescriptor(values); var route = CreateRoute( - routeName: "test", + routeName: "test", pattern: "{controller}/{action}", constraints: new RouteValueDictionary(new { action = "(TestAction1|TestAction2)" })); @@ -316,11 +316,25 @@ namespace Microsoft.AspNetCore.Mvc.Routing Assert.Equal(2, matcherEndpoint.Order); }); } - + + [Fact] + public void AddEndpoints_CreatesInertEndpoint() + { + // Arrange + var values = new { controller = "TestController", action = "TestAction", page = (string)null }; + var action = CreateActionDescriptor(values); + + // Act + var endpoints = CreateConventionalRoutedEndpoints(action, Array.Empty(), createInertEndpoints: true); + + // Assert + Assert.IsType(Assert.Single(endpoints)); + } + private RouteEndpoint CreateAttributeRoutedEndpoint(ActionDescriptor action) { var endpoints = new List(); - Factory.AddEndpoints(endpoints, new HashSet(StringComparer.OrdinalIgnoreCase), action, Array.Empty(), Array.Empty>()); + Factory.AddEndpoints(endpoints, new HashSet(), action, Array.Empty(), Array.Empty>(), createInertEndpoints: false); return Assert.IsType(Assert.Single(endpoints)); } @@ -334,7 +348,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing Assert.NotNull(action.RouteValues); var endpoints = new List(); - Factory.AddEndpoints(endpoints, new HashSet(StringComparer.OrdinalIgnoreCase), action, new[] { route, }, Array.Empty>()); + Factory.AddEndpoints(endpoints, new HashSet(), action, new[] { route, }, Array.Empty>(), createInertEndpoints: false); var endpoint = Assert.IsType(Assert.Single(endpoints)); // This should be true for all conventional-routed actions. @@ -343,20 +357,20 @@ namespace Microsoft.AspNetCore.Mvc.Routing return endpoint; } - private IReadOnlyList CreateConventionalRoutedEndpoints(ActionDescriptor action, ConventionalRouteEntry route) + private IReadOnlyList CreateConventionalRoutedEndpoints(ActionDescriptor action, ConventionalRouteEntry route) { return CreateConventionalRoutedEndpoints(action, new[] { route, }); } - private IReadOnlyList CreateConventionalRoutedEndpoints(ActionDescriptor action, IReadOnlyList routes) + private IReadOnlyList CreateConventionalRoutedEndpoints(ActionDescriptor action, IReadOnlyList routes, bool createInertEndpoints = false) { var endpoints = new List(); - Factory.AddEndpoints(endpoints, new HashSet(StringComparer.OrdinalIgnoreCase), action, routes, Array.Empty>()); - return endpoints.Cast().ToList(); + Factory.AddEndpoints(endpoints, new HashSet(), action, routes, Array.Empty>(), createInertEndpoints); + return endpoints.ToList(); } private ConventionalRouteEntry CreateRoute( - string routeName, + string routeName, string pattern, RouteValueDictionary defaults = null, IDictionary constraints = null, diff --git a/src/Mvc/Mvc.RazorPages/ref/Microsoft.AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs b/src/Mvc/Mvc.RazorPages/ref/Microsoft.AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs index b4c1d6e6b6..247bcbabcd 100644 --- a/src/Mvc/Mvc.RazorPages/ref/Microsoft.AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs +++ b/src/Mvc/Mvc.RazorPages/ref/Microsoft.AspNetCore.Mvc.RazorPages.netcoreapp3.0.cs @@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Builder } public static partial class RazorPagesEndpointRouteBuilderExtensions { + public static void MapDynamicPageRoute(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern) where TTransformer : Microsoft.AspNetCore.Mvc.Routing.DynamicRouteValueTransformer { } public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToAreaPage(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string page, string area) { throw null; } public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToAreaPage(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, string page, string area) { throw null; } public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToPage(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string page) { throw null; } diff --git a/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs b/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs index e5c6e4466b..289da17e6d 100644 --- a/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs +++ b/src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -74,7 +75,7 @@ namespace Microsoft.AspNetCore.Builder EnsureRazorPagesServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints); + GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -140,7 +141,7 @@ namespace Microsoft.AspNetCore.Builder EnsureRazorPagesServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints); + GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -198,7 +199,7 @@ namespace Microsoft.AspNetCore.Builder EnsureRazorPagesServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints); + GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -266,7 +267,7 @@ namespace Microsoft.AspNetCore.Builder EnsureRazorPagesServices(endpoints); // Called for side-effect to make sure that the data source is registered. - GetOrCreateDataSource(endpoints); + GetOrCreateDataSource(endpoints).CreateInertEndpoints = true; // Maps a fallback endpoint with an empty delegate. This is OK because // we don't expect the delegate to run. @@ -279,6 +280,53 @@ namespace Microsoft.AspNetCore.Builder return builder; } + /// + /// 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. + /// 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) + 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. + 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 DynamicPageRouteValueTransformerMetadata(typeof(TTransformer))); + }); + } + private static DynamicPageMetadata CreateDynamicPageMetadata(string page, string area) { return new DynamicPageMetadata(new RouteValueDictionary() diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DefaultPageLoader.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DefaultPageLoader.cs index f9042f1d64..32472186e2 100644 --- a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DefaultPageLoader.cs +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DefaultPageLoader.cs @@ -110,7 +110,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure routeNames: new HashSet(StringComparer.OrdinalIgnoreCase), action: compiled, routes: Array.Empty(), - conventions: Array.Empty>()); + conventions: Array.Empty>(), + createInertEndpoints: false); // In some test scenarios there's no route so the endpoint isn't created. This is fine because // it won't happen for real. diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs index 79853782b3..6751d41658 100644 --- a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointMatcherPolicy.cs @@ -3,12 +3,13 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { @@ -16,8 +17,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { private readonly DynamicPageEndpointSelector _selector; private readonly PageLoader _loader; + private readonly EndpointMetadataComparer _comparer; - public DynamicPageEndpointMatcherPolicy(DynamicPageEndpointSelector selector, PageLoader loader) + public DynamicPageEndpointMatcherPolicy(DynamicPageEndpointSelector selector, PageLoader loader, EndpointMetadataComparer comparer) { if (selector == null) { @@ -29,8 +31,14 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure throw new ArgumentNullException(nameof(loader)); } + if (comparer == null) + { + throw new ArgumentNullException(nameof(comparer)); + } + _selector = selector; _loader = loader; + _comparer = comparer; } public override int Order => int.MinValue + 100; @@ -50,8 +58,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure for (var i = 0; i < endpoints.Count; i++) { - var metadata = endpoints[i].Metadata.GetMetadata(); - if (metadata != null) + if (endpoints[i].Metadata.GetMetadata() != null) + { + // Found a dynamic page endpoint + return true; + } + + if (endpoints[i].Metadata.GetMetadata() != null) { // Found a dynamic page endpoint return true; @@ -84,40 +97,72 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure } var endpoint = candidates[i].Endpoint; + var originalValues = candidates[i].Values; - var metadata = endpoint.Metadata.GetMetadata(); - if (metadata == null) + RouteValueDictionary dynamicValues = null; + + // We don't expect both of these to be provided, and they are internal so there's + // no realistic way this could happen. + var dynamicPageMetadata = endpoint.Metadata.GetMetadata(); + var transformerMetadata = endpoint.Metadata.GetMetadata(); + if (dynamicPageMetadata != null) { + dynamicValues = dynamicPageMetadata.Values; + } + else if (transformerMetadata != null) + { + var transformer = (DynamicRouteValueTransformer)httpContext.RequestServices.GetRequiredService(transformerMetadata.SelectorType); + dynamicValues = await transformer.TransformAsync(httpContext, originalValues); + } + else + { + // Not a dynamic page continue; } - var matchedValues = candidates[i].Values; - var endpoints = _selector.SelectEndpoints(metadata.Values); - if (endpoints.Count == 0) + if (dynamicValues == null) { - // If there's no match this is a configuration error. We can't really check - // during startup that the action you configured exists. - throw new InvalidOperationException( - "Cannot find the fallback endpoint specified by route values: " + - "{ " + string.Join(", ", metadata.Values.Select(kvp => $"{kvp.Key}: {kvp.Value}")) + " }."); + candidates.ReplaceEndpoint(i, null, null); + continue; } - // It is possible to have more than one result for pages but they are equivalent. - - var compiled = await _loader.LoadAsync(endpoints[0].Metadata.GetMetadata()); - var replacement = compiled.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 + // during startup that the action you configured exists, so this is the best we can do. + throw new InvalidOperationException( + "Cannot find the fallback endpoint specified by route values: " + + "{ " + string.Join(", ", dynamicValues.Select(kvp => $"{kvp.Key}: {kvp.Value}")) + " }."); + } + else if (endpoints.Count == 0) + { + candidates.ReplaceEndpoint(i, null, null); + continue; + } // We need to provide the route values associated with this endpoint, so that features // like URL generation work. - var values = new RouteValueDictionary(metadata.Values); + var values = new RouteValueDictionary(dynamicValues); // Include values that were matched by the fallback route. - foreach (var kvp in matchedValues) + foreach (var kvp in originalValues) { values.TryAdd(kvp.Key, kvp.Value); } - candidates.ReplaceEndpoint(i, replacement, values); + // Update the route values + candidates.ReplaceEndpoint(i, endpoint, values); + + var loadedEndpoints = new List(endpoints); + for (var j = 0; j < loadedEndpoints.Count; j++) + { + var compiled = await _loader.LoadAsync(loadedEndpoints[j].Metadata.GetMetadata()); + loadedEndpoints[j] = compiled.Endpoint; + } + + // Expand the list of endpoints + candidates.ExpandEndpoint(i, loadedEndpoints, _comparer); } } } diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs index 42f87b0210..0e143db00b 100644 --- a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageEndpointSelector.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure internal class DynamicPageEndpointSelector : IDisposable { private readonly PageActionEndpointDataSource _dataSource; - private readonly DataSourceDependentCache> _cache; + private readonly DataSourceDependentCache> _cache; public DynamicPageEndpointSelector(PageActionEndpointDataSource dataSource) { @@ -22,12 +22,12 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure } _dataSource = dataSource; - _cache = new DataSourceDependentCache>(dataSource, Initialize); + _cache = new DataSourceDependentCache>(dataSource, Initialize); } - private ActionSelectionTable Table => _cache.EnsureInitialized(); + private ActionSelectionTable Table => _cache.EnsureInitialized(); - public IReadOnlyList SelectEndpoints(RouteValueDictionary values) + public IReadOnlyList SelectEndpoints(RouteValueDictionary values) { if (values == null) { @@ -39,9 +39,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure return matches; } - private static ActionSelectionTable Initialize(IReadOnlyList endpoints) + private static ActionSelectionTable Initialize(IReadOnlyList endpoints) { - return ActionSelectionTable.Create(endpoints); + return ActionSelectionTable.Create(endpoints); } public void Dispose() diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageRouteValueTransformerMetadata.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageRouteValueTransformerMetadata.cs new file mode 100644 index 0000000000..00c5dea045 --- /dev/null +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/DynamicPageRouteValueTransformerMetadata.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure +{ + internal class DynamicPageRouteValueTransformerMetadata : IDynamicEndpointMetadata + { + public DynamicPageRouteValueTransformerMetadata(Type selectorType) + { + if (selectorType == null) + { + throw new ArgumentNullException(nameof(selectorType)); + } + + if (!typeof(DynamicRouteValueTransformer).IsAssignableFrom(selectorType)) + { + throw new ArgumentException( + $"The provided type must be a subclass of {typeof(DynamicRouteValueTransformer)}", + nameof(selectorType)); + } + + SelectorType = selectorType; + } + + public bool IsDynamic => true; + + public Type SelectorType { get; } + } +} diff --git a/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSource.cs b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSource.cs index ce7cd62e94..d7501266a4 100644 --- a/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSource.cs +++ b/src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSource.cs @@ -22,13 +22,17 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure DefaultBuilder = new PageActionEndpointConventionBuilder(Lock, Conventions); - // IMPORTANT: this needs to be the last thing we do in the constructor. + // IMPORTANT: this needs to be the last thing we do in the constructor. // Change notifications can happen immediately! Subscribe(); } public PageActionEndpointConventionBuilder DefaultBuilder { get; } + // Used to control whether we create 'inert' (non-routable) endpoints for use in dynamic + // selection. Set to true by builder methods that do dynamic/fallback selection. + public bool CreateInertEndpoints { get; set; } + protected override List CreateEndpoints(IReadOnlyList actions, IReadOnlyList> conventions) { var endpoints = new List(); @@ -37,7 +41,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { if (actions[i] is PageActionDescriptor action) { - _endpointFactory.AddEndpoints(endpoints, routeNames, action, Array.Empty(), conventions); + _endpointFactory.AddEndpoints(endpoints, routeNames, action, Array.Empty(), conventions, CreateInertEndpoints); } } diff --git a/src/Mvc/test/Mvc.FunctionalTests/RoutingDynamicTest.cs b/src/Mvc/test/Mvc.FunctionalTests/RoutingDynamicTest.cs new file mode 100644 index 0000000000..0974a23848 --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/RoutingDynamicTest.cs @@ -0,0 +1,103 @@ +// 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.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class RoutingDynamicTest : IClassFixture> + { + public RoutingDynamicTest(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public async Task DynamicController_CanGet404ForMissingAction() + { + // Arrange + var url = "http://localhost/dynamic/controller%3DFake,action%3DIndex"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Act + var response = await Client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DynamicPage_CanGet404ForMissingAction() + { + // Arrange + var url = "http://localhost/dynamicpage/page%3D%2FFake"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Act + var response = await Client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DynamicController_CanSelectControllerInArea() + { + // Arrange + var url = "http://localhost/dynamic/area%3Dadmin,controller%3Ddynamic,action%3Dindex"; + 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 controller: /link_generation/dynamic/index", content); + } + + [Fact] + public async Task DynamicController_CanSelectControllerInArea_WithActionConstraints() + { + // Arrange + var url = "http://localhost/dynamic/area%3Dadmin,controller%3Ddynamic,action%3Dindex"; + var request = new HttpRequestMessage(HttpMethod.Post, 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 controller POST: /link_generation/dynamic/index", content); + } + + [Fact] + public async Task DynamicPage_CanSelectPage() + { + // Arrange + var url = "http://localhost/dynamicpage/page%3D%2FDynamicPage"; + 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: /DynamicPage", content); + } + } +} diff --git a/src/Mvc/test/Mvc.FunctionalTests/RoutingFallbackTest.cs b/src/Mvc/test/Mvc.FunctionalTests/RoutingFallbackTest.cs index eb41d3b7f4..c4925485a0 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/RoutingFallbackTest.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/RoutingFallbackTest.cs @@ -66,7 +66,23 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("Hello from fallback controller: /Admin/Fallback", content); + Assert.Equal("Hello from fallback controller: /link_generation/Admin/Fallback/Index", content); + } + + [Fact] + public async Task Fallback_CanFallbackToControllerInArea_WithActionConstraints() + { + // Arrange + var url = "http://localhost/Admin/Foo"; + var request = new HttpRequestMessage(HttpMethod.Post, 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 fallback controller POST: /link_generation/Admin/Fallback/Index", content); } [Fact] @@ -82,7 +98,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("Hello from fallback controller POST: /Admin/Fallback", content); + Assert.Equal("Hello from fallback controller POST: /link_generation/Admin/Fallback/Index", content); } [Fact] diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/DynamicController.cs b/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/DynamicController.cs new file mode 100644 index 0000000000..b7aeba5d98 --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/DynamicController.cs @@ -0,0 +1,22 @@ +// 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; + +namespace RoutingWebSite.Areas.Admin +{ + [Area("Admin")] + public class DynamicController : Controller + { + public ActionResult Index() + { + return Content("Hello from dynamic controller: " + Url.Action()); + } + + [HttpPost] + public ActionResult Index(int x = 0) + { + return Content("Hello from dynamic controller POST: " + Url.Action()); + } + } +} diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/FallbackController.cs b/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/FallbackController.cs index 64d0cd7875..59a19bfedd 100644 --- a/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/FallbackController.cs +++ b/src/Mvc/test/WebSites/RoutingWebSite/Areas/Admin/FallbackController.cs @@ -14,7 +14,7 @@ namespace RoutingWebSite.Areas.Admin } [HttpPost] - public ActionResult Index(int x) + public ActionResult Index(int x = 0) { return Content("Hello from fallback controller POST: " + Url.Action()); } diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Pages/DynamicPage.cshtml b/src/Mvc/test/WebSites/RoutingWebSite/Pages/DynamicPage.cshtml new file mode 100644 index 0000000000..f1d271bc62 --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/Pages/DynamicPage.cshtml @@ -0,0 +1,3 @@ +@page +@model RoutingWebSite.Pages.DynamicPageModel +Hello from dynamic page: @Url.Page("") \ No newline at end of file diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Pages/DynamicPage.cshtml.cs b/src/Mvc/test/WebSites/RoutingWebSite/Pages/DynamicPage.cshtml.cs new file mode 100644 index 0000000000..a0acca5a17 --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/Pages/DynamicPage.cshtml.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RoutingWebSite.Pages +{ + public class DynamicPageModel : PageModel + { + public void OnGet() + { + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs b/src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs new file mode 100644 index 0000000000..ba8385fd5c --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs @@ -0,0 +1,66 @@ +// 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.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace RoutingWebSite +{ + // For by tests for dynamic routing to pages/controllers + public class StartupForDynamic + { + public void ConfigureServices(IServiceCollection services) + { + services + .AddMvc() + .AddNewtonsoftJson() + .SetCompatibilityVersion(CompatibilityVersion.Latest); + + services.AddSingleton(); + + // Used by some controllers defined in this project. + services.Configure(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer)); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapDynamicControllerRoute("dynamic/{**slug}"); + endpoints.MapDynamicPageRoute("dynamicpage/{**slug}"); + + endpoints.MapControllerRoute("link", "link_generation/{controller}/{action}/{id?}"); + + }); + + app.Map("/afterrouting", b => b.Run(c => + { + return c.Response.WriteAsync("Hello from middleware after routing"); + })); + } + + private class Transformer : DynamicRouteValueTransformer + { + // Turns a format like `controller=Home,action=Index` into an RVD + public override Task TransformAsync(HttpContext httpContext, RouteValueDictionary values) + { + var kvps = ((string)values["slug"]).Split(","); + + var results = new RouteValueDictionary(); + foreach (var kvp in kvps) + { + var split = kvp.Split("="); + results[split[0]] = split[1]; + } + + return Task.FromResult(results); + } + } + } +} diff --git a/src/Mvc/test/WebSites/RoutingWebSite/StartupForFallback.cs b/src/Mvc/test/WebSites/RoutingWebSite/StartupForFallback.cs index e59bf80b00..ed67647bb3 100644 --- a/src/Mvc/test/WebSites/RoutingWebSite/StartupForFallback.cs +++ b/src/Mvc/test/WebSites/RoutingWebSite/StartupForFallback.cs @@ -28,13 +28,10 @@ namespace RoutingWebSite app.UseRouting(); app.UseEndpoints(endpoints => { - // Workaround for #8130 - // - // You can't fallback to this unless it already has another route. - endpoints.MapAreaControllerRoute("admin", "Admin", "Admin/{controller=Home}/{action=Index}/{id?}"); - endpoints.MapFallbackToAreaController("admin/{*path:nonfile}", "Index", "Fallback", "Admin"); endpoints.MapFallbackToPage("/FallbackPage"); + + endpoints.MapControllerRoute("admin", "link_generation/{area}/{controller}/{action}/{id?}"); }); app.Map("/afterrouting", b => b.Run(c =>