Add dynamic controller/page routes

Adds infrastructure for a common IRouter-based pattern. In this pattern,
an extender subclasses Route to post-process the route values before MVC
action selection run. The new infrastructure duplicates this kind of
experience but based on endpoint routing.

The approach in this PR starts at the bottom... meaning that this is the
most-focused and least-invasive way to implement a feature like this.
Similar to fallback routing, this is a pattern built with matcher
policies and metadata rather than a built-in feature of routing.

It's valuable to point out that this approach uses IActionConstraint to
disambiguate between actions. The other way we could go would be to make
the *other* matcher policy implementations able to do this. This would
mean that whenever you have a dynamic endpoint, you will not by using
the DFA for features like HTTP methods. It also means that we need to go
re-implement a bunch of infrastructure.

This PR also adds the concept of an 'inert' endpoint - a non-Routable
endpoint that's created when fallback/dynamic is in use. This seems like
a cleaner design because we don't start *matching* RouteEndpoint
instances for URLs that don't match. This resolves #8130
This commit is contained in:
Ryan Nowak 2019-03-31 11:59:46 -07:00 committed by Ryan Nowak
parent 5ca92305c2
commit 6ce8a879ae
28 changed files with 707 additions and 179 deletions

View File

@ -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<IHttpMethodMetadata>();
// even if those endpoints are already invalid - hence the null-check.
var metadata = candidates[i].Endpoint?.Metadata.GetMetadata<IHttpMethodMetadata>();
if (metadata == null || metadata.HttpMethods.Count == 0)
{
// Can match any method.

View File

@ -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<TTransformer>(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<Microsoft.AspNetCore.Routing.RouteValueDictionary> 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
{

View File

@ -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;
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will
/// attempt to select a controller action using the route values produced by <typeparamref name="TTransformer"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="pattern">The URL pattern of the route.</param>
/// <typeparam name="TTransformer">The type of a <see cref="DynamicRouteValueTransformer"/>.</typeparam>
/// <remarks>
/// <para>
/// This method allows the registration of a <see cref="RouteEndpoint"/> and <see cref="DynamicRouteValueTransformer"/>
/// that combine to dynamically select a controller action using custom logic.
/// </para>
/// <para>
/// The instance of <typeparamref name="TTransformer"/> will be retrieved from the dependency injection container.
/// Register <typeparamref name="TTransformer"/> with the desired service lifetime in <c>ConfigureServices</c>.
/// </para>
/// </remarks>
public static void MapDynamicControllerRoute<TTransformer>(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()

View File

@ -74,23 +74,23 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
});
}
public static ActionSelectionTable<RouteEndpoint> Create(IEnumerable<Endpoint> endpoints)
public static ActionSelectionTable<Endpoint> Create(IEnumerable<Endpoint> endpoints)
{
return CreateCore<RouteEndpoint>(
return CreateCore<Endpoint>(
// we don't use version for endpoints
version: 0,
// Only include RouteEndpoints and only those that aren't suppressed.
items: endpoints.OfType<RouteEndpoint>().Where(e =>
// Exclude RouteEndpoints - we only process inert endpoints here.
items: endpoints.Where(e =>
{
return e.Metadata.GetMetadata<ISuppressMatchingMetadata>()?.SuppressMatching != true;
return e.GetType() == typeof(Endpoint);
}),
getRouteKeys: e => e.RoutePattern.RequiredValues.Keys,
getRouteKeys: e => e.Metadata.GetMetadata<ActionDescriptor>().RouteValues.Keys,
getRouteValue: (e, key) =>
{
e.RoutePattern.RequiredValues.TryGetValue(key, out var value);
e.Metadata.GetMetadata<ActionDescriptor>().RouteValues.TryGetValue(key, out var value);
return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
});
}

View File

@ -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<string> routeNames,
ActionDescriptor action,
IReadOnlyList<ConventionalRouteEntry> routes,
IReadOnlyList<Action<EndpointBuilder>> conventions)
IReadOnlyList<Action<EndpointBuilder>> 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<Action<EndpointBuilder>>());
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<Action<EndpointBuilder>>());
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<string> routeNames,
ActionDescriptor action,
RoutePattern routePattern,
string routeName,
int order,
RouteValueDictionary dataTokens,
bool suppressLinkGeneration,
bool suppressPathMatching,
IReadOnlyList<Action<EndpointBuilder>> conventions,
IReadOnlyList<Action<EndpointBuilder>> 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<IActionInvokerFactory>();
}
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<IDataTokensMetadata>();
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<ActionDescriptor>();
var actionContext = new ActionContext(context, routeData, action);
if (invokerFactory == null)
{
invokerFactory = context.RequestServices.GetRequiredService<IActionInvokerFactory>();
}
var invoker = invokerFactory.CreateInvoker(actionContext);
return invoker.InvokeAsync();
};
}
private class InertEndpointBuilder : EndpointBuilder
{
public override Endpoint Build()
{
return new Endpoint(RequestDelegate, new EndpointMetadataCollection(Metadata), DisplayName);
}
}
}
}

View File

@ -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<IConsumesMetadata>();
// even if those endpoints are already invalid - hence the null check.
var metadata = candidates[i].Endpoint?.Metadata.GetMetadata<IConsumesMetadata>();
if (metadata == null || metadata.ContentTypes.Count == 0)
{
// Can match any content type.

View File

@ -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<ConventionalRouteEntry>();
// 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)
{

View File

@ -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<DynamicControllerMetadata>();
if (metadata != null)
if (endpoints[i].Metadata.GetMetadata<DynamicControllerMetadata>() != null)
{
// Found a dynamic controller endpoint
return true;
}
if (endpoints[i].Metadata.GetMetadata<DynamicControllerRouteValueTransformerMetadata>() != 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<DynamicControllerMetadata>();
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<DynamicControllerMetadata>();
var transformerMetadata = endpoint.Metadata.GetMetadata<DynamicControllerRouteValueTransformerMetadata>();
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;
}
}
}

View File

@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
internal class DynamicControllerEndpointSelector : IDisposable
{
private readonly ControllerActionEndpointDataSource _dataSource;
private readonly DataSourceDependentCache<ActionSelectionTable<RouteEndpoint>> _cache;
private readonly DataSourceDependentCache<ActionSelectionTable<Endpoint>> _cache;
public DynamicControllerEndpointSelector(ControllerActionEndpointDataSource dataSource)
{
@ -22,12 +22,13 @@ namespace Microsoft.AspNetCore.Mvc.Routing
}
_dataSource = dataSource;
_cache = new DataSourceDependentCache<ActionSelectionTable<RouteEndpoint>>(dataSource, Initialize);
_cache = new DataSourceDependentCache<ActionSelectionTable<Endpoint>>(dataSource, Initialize);
}
private ActionSelectionTable<RouteEndpoint> Table => _cache.EnsureInitialized();
private ActionSelectionTable<Endpoint> Table => _cache.EnsureInitialized();
public IReadOnlyList<RouteEndpoint> SelectEndpoints(RouteValueDictionary values)
public IReadOnlyList<Endpoint> 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<RouteEndpoint> Initialize(IReadOnlyList<Endpoint> endpoints)
private static ActionSelectionTable<Endpoint> Initialize(IReadOnlyList<Endpoint> endpoints)
{
return ActionSelectionTable<RouteEndpoint>.Create(endpoints);
return ActionSelectionTable<Endpoint>.Create(endpoints);
}
public void Dispose()

View File

@ -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; }
}
}

View File

@ -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
{
/// <summary>
/// Provides an abstraction for dynamically manipulating route value to select a controller action or page.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DynamicRouteValueTransformer"/> can be used with
/// <see cref="Microsoft.AspNetCore.Builder.ControllerEndpointRouteBuilderExtensions.MapDynamicControllerRoute{TTransformer}(IEndpointRouteBuilder, string)" />
/// or <c>MapDynamicPageRoute</c> to implement custom logic that selects a controller action or page.
/// </para>
/// <para>
/// The route values returned from a <see cref="TransformAsync(HttpContext, RouteValueDictionary)"/> 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 <see cref="IEndpointSelectorPolicy" />
/// implementations such as <see cref="HttpMethodMatcherPolicy" />.
/// </para>
/// <para>
/// Implementations <see cref="DynamicRouteValueTransformer" /> should be registered with the service
/// collection as type <see cref="DynamicRouteValueTransformer" />. Implementations can use any service
/// lifetime.
/// </para>
/// </remarks>
public abstract class DynamicRouteValueTransformer
{
/// <summary>
/// Creates a set of transformed route values that will be used to select an action.
/// </summary>
/// <param name="httpContext">The <see cref="HttpContext" /> associated with the current request.</param>
/// <param name="values">The route values associated with the current match. Implementations should not modify <paramref name="values"/>.</param>
/// <returns>A task which asynchronously returns a set of route values.</returns>
public abstract Task<RouteValueDictionary> TransformAsync(HttpContext httpContext, RouteValueDictionary values);
}
}

View File

@ -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<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
EndpointMetadata = new List<object>()
{
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<ActionDescriptor>.Create(new ActionDescriptorCollection(actions, 0));
}
private static ActionSelectionTable<RouteEndpoint> CreateTableWithEndpoints(IReadOnlyList<ActionDescriptor> actions)
private static ActionSelectionTable<Endpoint> CreateTableWithEndpoints(IReadOnlyList<ActionDescriptor> actions)
{
var endpoints = actions.Select(a =>
{
var metadata = new List<object>(a.EndpointMetadata ?? Array.Empty<object>());
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<ActionDescriptor>.Create(endpoints);

View File

@ -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<ConventionalRouteEntry>(), createInertEndpoints: true);
// Assert
Assert.IsType<Endpoint>(Assert.Single(endpoints));
}
private RouteEndpoint CreateAttributeRoutedEndpoint(ActionDescriptor action)
{
var endpoints = new List<Endpoint>();
Factory.AddEndpoints(endpoints, new HashSet<string>(StringComparer.OrdinalIgnoreCase), action, Array.Empty<ConventionalRouteEntry>(), Array.Empty<Action<EndpointBuilder>>());
Factory.AddEndpoints(endpoints, new HashSet<string>(), action, Array.Empty<ConventionalRouteEntry>(), Array.Empty<Action<EndpointBuilder>>(), createInertEndpoints: false);
return Assert.IsType<RouteEndpoint>(Assert.Single(endpoints));
}
@ -334,7 +348,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
Assert.NotNull(action.RouteValues);
var endpoints = new List<Endpoint>();
Factory.AddEndpoints(endpoints, new HashSet<string>(StringComparer.OrdinalIgnoreCase), action, new[] { route, }, Array.Empty<Action<EndpointBuilder>>());
Factory.AddEndpoints(endpoints, new HashSet<string>(), action, new[] { route, }, Array.Empty<Action<EndpointBuilder>>(), createInertEndpoints: false);
var endpoint = Assert.IsType<RouteEndpoint>(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<RouteEndpoint> CreateConventionalRoutedEndpoints(ActionDescriptor action, ConventionalRouteEntry route)
private IReadOnlyList<Endpoint> CreateConventionalRoutedEndpoints(ActionDescriptor action, ConventionalRouteEntry route)
{
return CreateConventionalRoutedEndpoints(action, new[] { route, });
}
private IReadOnlyList<RouteEndpoint> CreateConventionalRoutedEndpoints(ActionDescriptor action, IReadOnlyList<ConventionalRouteEntry> routes)
private IReadOnlyList<Endpoint> CreateConventionalRoutedEndpoints(ActionDescriptor action, IReadOnlyList<ConventionalRouteEntry> routes, bool createInertEndpoints = false)
{
var endpoints = new List<Endpoint>();
Factory.AddEndpoints(endpoints, new HashSet<string>(StringComparer.OrdinalIgnoreCase), action, routes, Array.Empty<Action<EndpointBuilder>>());
return endpoints.Cast<RouteEndpoint>().ToList();
Factory.AddEndpoints(endpoints, new HashSet<string>(), action, routes, Array.Empty<Action<EndpointBuilder>>(), createInertEndpoints);
return endpoints.ToList();
}
private ConventionalRouteEntry CreateRoute(
string routeName,
string routeName,
string pattern,
RouteValueDictionary defaults = null,
IDictionary<string, object> constraints = null,

View File

@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Builder
}
public static partial class RazorPagesEndpointRouteBuilderExtensions
{
public static void MapDynamicPageRoute<TTransformer>(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; }

View File

@ -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;
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will
/// attempt to select a page using the route values produced by <typeparamref name="TTransformer"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="pattern">The URL pattern of the route.</param>
/// <typeparam name="TTransformer">The type of a <see cref="DynamicRouteValueTransformer"/>.</typeparam>
/// <remarks>
/// <para>
/// This method allows the registration of a <see cref="RouteEndpoint"/> and <see cref="DynamicRouteValueTransformer"/>
/// that combine to dynamically select a page using custom logic.
/// </para>
/// <para>
/// The instance of <typeparamref name="TTransformer"/> will be retrieved from the dependency injection container.
/// Register <typeparamref name="TTransformer"/> with the desired service lifetime in <c>ConfigureServices</c>.
/// </para>
/// </remarks>
public static void MapDynamicPageRoute<TTransformer>(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()

View File

@ -110,7 +110,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
routeNames: new HashSet<string>(StringComparer.OrdinalIgnoreCase),
action: compiled,
routes: Array.Empty<ConventionalRouteEntry>(),
conventions: Array.Empty<Action<EndpointBuilder>>());
conventions: Array.Empty<Action<EndpointBuilder>>(),
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.

View File

@ -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<DynamicPageMetadata>();
if (metadata != null)
if (endpoints[i].Metadata.GetMetadata<DynamicPageMetadata>() != null)
{
// Found a dynamic page endpoint
return true;
}
if (endpoints[i].Metadata.GetMetadata<DynamicPageRouteValueTransformerMetadata>() != 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<DynamicPageMetadata>();
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<DynamicPageMetadata>();
var transformerMetadata = endpoint.Metadata.GetMetadata<DynamicPageRouteValueTransformerMetadata>();
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<PageActionDescriptor>());
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<Endpoint>(endpoints);
for (var j = 0; j < loadedEndpoints.Count; j++)
{
var compiled = await _loader.LoadAsync(loadedEndpoints[j].Metadata.GetMetadata<PageActionDescriptor>());
loadedEndpoints[j] = compiled.Endpoint;
}
// Expand the list of endpoints
candidates.ExpandEndpoint(i, loadedEndpoints, _comparer);
}
}
}

View File

@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
internal class DynamicPageEndpointSelector : IDisposable
{
private readonly PageActionEndpointDataSource _dataSource;
private readonly DataSourceDependentCache<ActionSelectionTable<RouteEndpoint>> _cache;
private readonly DataSourceDependentCache<ActionSelectionTable<Endpoint>> _cache;
public DynamicPageEndpointSelector(PageActionEndpointDataSource dataSource)
{
@ -22,12 +22,12 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
}
_dataSource = dataSource;
_cache = new DataSourceDependentCache<ActionSelectionTable<RouteEndpoint>>(dataSource, Initialize);
_cache = new DataSourceDependentCache<ActionSelectionTable<Endpoint>>(dataSource, Initialize);
}
private ActionSelectionTable<RouteEndpoint> Table => _cache.EnsureInitialized();
private ActionSelectionTable<Endpoint> Table => _cache.EnsureInitialized();
public IReadOnlyList<RouteEndpoint> SelectEndpoints(RouteValueDictionary values)
public IReadOnlyList<Endpoint> SelectEndpoints(RouteValueDictionary values)
{
if (values == null)
{
@ -39,9 +39,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
return matches;
}
private static ActionSelectionTable<RouteEndpoint> Initialize(IReadOnlyList<Endpoint> endpoints)
private static ActionSelectionTable<Endpoint> Initialize(IReadOnlyList<Endpoint> endpoints)
{
return ActionSelectionTable<RouteEndpoint>.Create(endpoints);
return ActionSelectionTable<Endpoint>.Create(endpoints);
}
public void Dispose()

View File

@ -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; }
}
}

View File

@ -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<Endpoint> CreateEndpoints(IReadOnlyList<ActionDescriptor> actions, IReadOnlyList<Action<EndpointBuilder>> conventions)
{
var endpoints = new List<Endpoint>();
@ -37,7 +41,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
{
if (actions[i] is PageActionDescriptor action)
{
_endpointFactory.AddEndpoints(endpoints, routeNames, action, Array.Empty<ConventionalRouteEntry>(), conventions);
_endpointFactory.AddEndpoints(endpoints, routeNames, action, Array.Empty<ConventionalRouteEntry>(), conventions, CreateInertEndpoints);
}
}

View File

@ -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<MvcTestFixture<RoutingWebSite.StartupForDynamic>>
{
public RoutingDynamicTest(MvcTestFixture<RoutingWebSite.StartupForDynamic> fixture)
{
var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
Client = factory.CreateDefaultClient();
}
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => builder.UseStartup<RoutingWebSite.StartupForDynamic>();
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);
}
}
}

View File

@ -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]

View File

@ -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());
}
}
}

View File

@ -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());
}

View File

@ -0,0 +1,3 @@
@page
@model RoutingWebSite.Pages.DynamicPageModel
Hello from dynamic page: @Url.Page("")

View File

@ -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()
{
}
}
}

View File

@ -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<Transformer>();
// Used by some controllers defined in this project.
services.Configure<RouteOptions>(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer));
}
public void Configure(IApplicationBuilder app)
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapDynamicControllerRoute<Transformer>("dynamic/{**slug}");
endpoints.MapDynamicPageRoute<Transformer>("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<RouteValueDictionary> 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);
}
}
}
}

View File

@ -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 =>