Skip to content

Commit c0dfc55

Browse files
author
Ryan Nowak
committed
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
1 parent 56ffc6b commit c0dfc55

28 files changed

+673
-175
lines changed

src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
113113
{
114114
// We do this check first for consistency with how 405 is implemented for the graph version
115115
// of this code. We still want to know if any endpoints in this set require an HTTP method
116-
// even if those endpoints are already invalid.
117-
var metadata = candidates[i].Endpoint.Metadata.GetMetadata<IHttpMethodMetadata>();
116+
// even if those endpoints are already invalid - hence the null-check.
117+
var metadata = candidates[i].Endpoint?.Metadata.GetMetadata<IHttpMethodMetadata>();
118118
if (metadata == null || metadata.HttpMethods.Count == 0)
119119
{
120120
// Can match any method.

src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public static partial class ControllerEndpointRouteBuilderExtensions
1414
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; }
1515
public static Microsoft.AspNetCore.Builder.ControllerActionEndpointConventionBuilder MapControllers(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints) { throw null; }
1616
public static Microsoft.AspNetCore.Builder.ControllerActionEndpointConventionBuilder MapDefaultControllerRoute(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints) { throw null; }
17+
public static void MapDynamicControllerRoute<TTransformer>(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern) where TTransformer : Microsoft.AspNetCore.Mvc.Routing.DynamicRouteValueTransformer { }
1718
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToAreaController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string action, string controller, string area) { throw null; }
1819
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToAreaController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, string action, string controller, string area) { throw null; }
1920
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToController(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string action, string controller) { throw null; }
@@ -2909,6 +2910,11 @@ public ValidatorCache() { }
29092910
}
29102911
namespace Microsoft.AspNetCore.Mvc.Routing
29112912
{
2913+
public abstract partial class DynamicRouteValueTransformer
2914+
{
2915+
protected DynamicRouteValueTransformer() { }
2916+
public abstract System.Threading.Tasks.Task<Microsoft.AspNetCore.Routing.RouteValueDictionary> TransformAsync(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.Routing.RouteValueDictionary values);
2917+
}
29122918
[System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=true, Inherited=true)]
29132919
public abstract partial class HttpMethodAttribute : System.Attribute, Microsoft.AspNetCore.Mvc.Routing.IActionHttpMethodProvider, Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider
29142920
{

src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.AspNetCore.Mvc.Routing;
99
using Microsoft.AspNetCore.Routing;
1010
using Microsoft.AspNetCore.Routing.Constraints;
11+
using Microsoft.AspNetCore.Routing.Patterns;
1112
using Microsoft.Extensions.DependencyInjection;
1213

1314
namespace Microsoft.AspNetCore.Builder
@@ -212,7 +213,7 @@ public static IEndpointConventionBuilder MapFallbackToController(
212213
EnsureControllerServices(endpoints);
213214

214215
// Called for side-effect to make sure that the data source is registered.
215-
GetOrCreateDataSource(endpoints);
216+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
216217

217218
// Maps a fallback endpoint with an empty delegate. This is OK because
218219
// we don't expect the delegate to run.
@@ -288,7 +289,7 @@ public static IEndpointConventionBuilder MapFallbackToController(
288289
EnsureControllerServices(endpoints);
289290

290291
// Called for side-effect to make sure that the data source is registered.
291-
GetOrCreateDataSource(endpoints);
292+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
292293

293294
// Maps a fallback endpoint with an empty delegate. This is OK because
294295
// we don't expect the delegate to run.
@@ -356,7 +357,7 @@ public static IEndpointConventionBuilder MapFallbackToAreaController(
356357
EnsureControllerServices(endpoints);
357358

358359
// Called for side-effect to make sure that the data source is registered.
359-
GetOrCreateDataSource(endpoints);
360+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
360361

361362
// Maps a fallback endpoint with an empty delegate. This is OK because
362363
// we don't expect the delegate to run.
@@ -434,7 +435,7 @@ public static IEndpointConventionBuilder MapFallbackToAreaController(
434435
EnsureControllerServices(endpoints);
435436

436437
// Called for side-effect to make sure that the data source is registered.
437-
GetOrCreateDataSource(endpoints);
438+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
438439

439440
// Maps a fallback endpoint with an empty delegate. This is OK because
440441
// we don't expect the delegate to run.
@@ -447,6 +448,44 @@ public static IEndpointConventionBuilder MapFallbackToAreaController(
447448
return builder;
448449
}
449450

451+
/// <summary>
452+
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will
453+
/// attempt to select a controller action using the route values produced by <typeparamref name="TTransformer"/>.
454+
/// </summary>
455+
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
456+
/// <param name="pattern">The URL pattern of the route.</param>
457+
/// <typeparam name="TTransformer">The type of a <see cref="DynamicRouteValueTransformer"/>.</typeparam>
458+
/// <remarks>
459+
/// <para>
460+
/// This method allows the registration of a <see cref="RouteEndpoint"/> and <see cref="DynamicRouteValueTransformer"/>
461+
/// that combine to dynamically select a controller action using custom logic.
462+
/// </para>
463+
/// <para>
464+
/// The instance of <typeparamref name="TTransformer"/> will be retrieved from the dependency injection container.
465+
/// Register <typeparamref name="TTransformer"/> with the desired service lifetime in <c>ConfigureServices</c>.
466+
/// </para>
467+
/// </remarks>
468+
public static void MapDynamicControllerRoute<TTransformer>(this IEndpointRouteBuilder endpoints, string pattern)
469+
where TTransformer : DynamicRouteValueTransformer
470+
{
471+
if (endpoints == null)
472+
{
473+
throw new ArgumentNullException(nameof(endpoints));
474+
}
475+
476+
EnsureControllerServices(endpoints);
477+
478+
// Called for side-effect to make sure that the data source is registered.
479+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
480+
481+
// Maps a dynamic controller endpoint with an empty delegate. This is OK because
482+
// we don't expect the delegate to run.
483+
endpoints.Map(pattern, context => Task.CompletedTask).Add(b =>
484+
{
485+
b.Metadata.Add(new DynamicControllerRouteValueTransformerMetadata(typeof(TTransformer)));
486+
});
487+
}
488+
450489
private static DynamicControllerMetadata CreateDynamicControllerMetadata(string action, string controller, string area)
451490
{
452491
return new DynamicControllerMetadata(new RouteValueDictionary()

src/Mvc/Mvc.Core/src/Infrastructure/ActionSelectionTable.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,23 +74,23 @@ public static ActionSelectionTable<ActionDescriptor> Create(ActionDescriptorColl
7474
});
7575
}
7676

77-
public static ActionSelectionTable<RouteEndpoint> Create(IEnumerable<Endpoint> endpoints)
77+
public static ActionSelectionTable<Endpoint> Create(IEnumerable<Endpoint> endpoints)
7878
{
79-
return CreateCore<RouteEndpoint>(
79+
return CreateCore<Endpoint>(
8080

8181
// we don't use version for endpoints
8282
version: 0,
8383

84-
// Only include RouteEndpoints and only those that aren't suppressed.
85-
items: endpoints.OfType<RouteEndpoint>().Where(e =>
84+
// Exclude RouteEndpoints - we only process inert endpoints here.
85+
items: endpoints.Where(e =>
8686
{
87-
return e.Metadata.GetMetadata<ISuppressMatchingMetadata>()?.SuppressMatching != true;
87+
return e.GetType() == typeof(Endpoint);
8888
}),
8989

90-
getRouteKeys: e => e.RoutePattern.RequiredValues.Keys,
90+
getRouteKeys: e => e.Metadata.GetMetadata<ActionDescriptor>().RouteValues.Keys,
9191
getRouteValue: (e, key) =>
9292
{
93-
e.RoutePattern.RequiredValues.TryGetValue(key, out var value);
93+
e.Metadata.GetMetadata<ActionDescriptor>().RouteValues.TryGetValue(key, out var value);
9494
return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
9595
});
9696
}

src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs

Lines changed: 81 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public void AddEndpoints(
3636
HashSet<string> routeNames,
3737
ActionDescriptor action,
3838
IReadOnlyList<ConventionalRouteEntry> routes,
39-
IReadOnlyList<Action<EndpointBuilder>> conventions)
39+
IReadOnlyList<Action<EndpointBuilder>> conventions,
40+
bool createInertEndpoints)
4041
{
4142
if (endpoints == null)
4243
{
@@ -63,6 +64,27 @@ public void AddEndpoints(
6364
throw new ArgumentNullException(nameof(conventions));
6465
}
6566

67+
if (createInertEndpoints)
68+
{
69+
var requestDelegate = CreateRequestDelegate();
70+
var builder = new InertEndpointBuilder()
71+
{
72+
DisplayName = action.DisplayName,
73+
RequestDelegate = requestDelegate,
74+
};
75+
AddActionDataToBuilder(
76+
builder,
77+
routeNames,
78+
action,
79+
routeName: null,
80+
dataTokens: null,
81+
suppressLinkGeneration: false,
82+
suppressPathMatching: false,
83+
conventions,
84+
Array.Empty<Action<EndpointBuilder>>());
85+
endpoints.Add(builder.Build());
86+
}
87+
6688
if (action.AttributeRouteInfo == null)
6789
{
6890
// Check each of the conventional patterns to see if the action would be reachable.
@@ -81,18 +103,22 @@ public void AddEndpoints(
81103

82104
// We suppress link generation for each conventionally routed endpoint. We generate a single endpoint per-route
83105
// to handle link generation.
84-
var builder = CreateEndpoint(
106+
var requestDelegate = CreateRequestDelegate();
107+
var builder = new RouteEndpointBuilder(requestDelegate, updatedRoutePattern, route.Order)
108+
{
109+
DisplayName = action.DisplayName,
110+
};
111+
AddActionDataToBuilder(
112+
builder,
85113
routeNames,
86114
action,
87-
updatedRoutePattern,
88115
route.RouteName,
89-
route.Order,
90116
route.DataTokens,
91117
suppressLinkGeneration: true,
92118
suppressPathMatching: false,
93119
conventions,
94120
route.Conventions);
95-
endpoints.Add(builder);
121+
endpoints.Add(builder.Build());
96122
}
97123
}
98124
else
@@ -109,18 +135,22 @@ public void AddEndpoints(
109135
throw new InvalidOperationException("Failed to update route pattern with required values.");
110136
}
111137

112-
var endpoint = CreateEndpoint(
138+
var requestDelegate = CreateRequestDelegate();
139+
var builder = new RouteEndpointBuilder(requestDelegate, updatedRoutePattern, action.AttributeRouteInfo.Order)
140+
{
141+
DisplayName = action.DisplayName,
142+
};
143+
AddActionDataToBuilder(
144+
builder,
113145
routeNames,
114146
action,
115-
updatedRoutePattern,
116147
action.AttributeRouteInfo.Name,
117-
action.AttributeRouteInfo.Order,
118148
dataTokens: null,
119149
action.AttributeRouteInfo.SuppressLinkGeneration,
120150
action.AttributeRouteInfo.SuppressPathMatching,
121151
conventions,
122152
perRouteConventions: Array.Empty<Action<EndpointBuilder>>());
123-
endpoints.Add(endpoint);
153+
endpoints.Add(builder.Build());
124154
}
125155
}
126156

@@ -262,49 +292,17 @@ private static (RoutePattern resolvedRoutePattern, IDictionary<string, string> r
262292
return (attributeRoutePattern, resolvedRequiredValues ?? action.RouteValues);
263293
}
264294

265-
private RouteEndpoint CreateEndpoint(
295+
private void AddActionDataToBuilder(
296+
EndpointBuilder builder,
266297
HashSet<string> routeNames,
267298
ActionDescriptor action,
268-
RoutePattern routePattern,
269299
string routeName,
270-
int order,
271300
RouteValueDictionary dataTokens,
272301
bool suppressLinkGeneration,
273302
bool suppressPathMatching,
274303
IReadOnlyList<Action<EndpointBuilder>> conventions,
275304
IReadOnlyList<Action<EndpointBuilder>> perRouteConventions)
276305
{
277-
278-
// We don't want to close over the retrieve the Invoker Factory in ActionEndpointFactory as
279-
// that creates cycles in DI. Since we're creating this delegate at startup time
280-
// we don't want to create all of the things we use at runtime until the action
281-
// actually matches.
282-
//
283-
// The request delegate is already a closure here because we close over
284-
// the action descriptor.
285-
IActionInvokerFactory invokerFactory = null;
286-
287-
RequestDelegate requestDelegate = (context) =>
288-
{
289-
var routeData = new RouteData();
290-
routeData.PushState(router: null, context.Request.RouteValues, dataTokens);
291-
292-
var actionContext = new ActionContext(context, routeData, action);
293-
294-
if (invokerFactory == null)
295-
{
296-
invokerFactory = context.RequestServices.GetRequiredService<IActionInvokerFactory>();
297-
}
298-
299-
var invoker = invokerFactory.CreateInvoker(actionContext);
300-
return invoker.InvokeAsync();
301-
};
302-
303-
var builder = new RouteEndpointBuilder(requestDelegate, routePattern, order)
304-
{
305-
DisplayName = action.DisplayName,
306-
};
307-
308306
// Add action metadata first so it has a low precedence
309307
if (action.EndpointMetadata != null)
310308
{
@@ -399,8 +397,47 @@ private RouteEndpoint CreateEndpoint(
399397
{
400398
perRouteConventions[i](builder);
401399
}
400+
}
401+
402+
private static RequestDelegate CreateRequestDelegate()
403+
{
404+
// We don't want to close over the retrieve the Invoker Factory in ActionEndpointFactory as
405+
// that creates cycles in DI. Since we're creating this delegate at startup time
406+
// we don't want to create all of the things we use at runtime until the action
407+
// actually matches.
408+
//
409+
// The request delegate is already a closure here because we close over
410+
// the action descriptor.
411+
IActionInvokerFactory invokerFactory = null;
412+
413+
return (context) =>
414+
{
415+
var endpoint = context.GetEndpoint();
416+
var dataTokens = endpoint.Metadata.GetMetadata<IDataTokensMetadata>();
402417

403-
return (RouteEndpoint)builder.Build();
418+
var routeData = new RouteData();
419+
routeData.PushState(router: null, context.Request.RouteValues, new RouteValueDictionary(dataTokens?.DataTokens));
420+
421+
// Don't close over the ActionDescriptor, that's not valid for pages.
422+
var action = endpoint.Metadata.GetMetadata<ActionDescriptor>();
423+
var actionContext = new ActionContext(context, routeData, action);
424+
425+
if (invokerFactory == null)
426+
{
427+
invokerFactory = context.RequestServices.GetRequiredService<IActionInvokerFactory>();
428+
}
429+
430+
var invoker = invokerFactory.CreateInvoker(actionContext);
431+
return invoker.InvokeAsync();
432+
};
433+
}
434+
435+
private class InertEndpointBuilder : EndpointBuilder
436+
{
437+
public override Endpoint Build()
438+
{
439+
return new Endpoint(RequestDelegate, new EndpointMetadataCollection(Metadata), DisplayName);
440+
}
404441
}
405442
}
406443
}

src/Mvc/Mvc.Core/src/Routing/ConsumesMatcherPolicy.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
7373
{
7474
// We do this check first for consistency with how 415 is implemented for the graph version
7575
// of this code. We still want to know if any endpoints in this set require an a ContentType
76-
// even if those endpoints are already invalid.
77-
var metadata = candidates[i].Endpoint.Metadata.GetMetadata<IConsumesMetadata>();
76+
// even if those endpoints are already invalid - hence the null check.
77+
var metadata = candidates[i].Endpoint?.Metadata.GetMetadata<IConsumesMetadata>();
7878
if (metadata == null || metadata.ContentTypes.Count == 0)
7979
{
8080
// Can match any content type.

0 commit comments

Comments
 (0)