Skip to content

Commit 6ce8a87

Browse files
Ryan Nowakrynowak
Ryan Nowak
authored andcommitted
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 5ca9230 commit 6ce8a87

28 files changed

+706
-178
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: 47 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,48 @@ 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+
endpoints.Map(
482+
pattern,
483+
context =>
484+
{
485+
throw new InvalidOperationException("This endpoint is not expected to be executed directly.");
486+
})
487+
.Add(b =>
488+
{
489+
b.Metadata.Add(new DynamicControllerRouteValueTransformerMetadata(typeof(TTransformer)));
490+
});
491+
}
492+
450493
private static DynamicControllerMetadata CreateDynamicControllerMetadata(string action, string controller, string area)
451494
{
452495
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: 80 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
2020
internal class ActionEndpointFactory
2121
{
2222
private readonly RoutePatternTransformer _routePatternTransformer;
23+
private readonly RequestDelegate _requestDelegate;
2324

2425
public ActionEndpointFactory(RoutePatternTransformer routePatternTransformer)
2526
{
@@ -29,14 +30,16 @@ public ActionEndpointFactory(RoutePatternTransformer routePatternTransformer)
2930
}
3031

3132
_routePatternTransformer = routePatternTransformer;
33+
_requestDelegate = CreateRequestDelegate();
3234
}
3335

3436
public void AddEndpoints(
3537
List<Endpoint> endpoints,
3638
HashSet<string> routeNames,
3739
ActionDescriptor action,
3840
IReadOnlyList<ConventionalRouteEntry> routes,
39-
IReadOnlyList<Action<EndpointBuilder>> conventions)
41+
IReadOnlyList<Action<EndpointBuilder>> conventions,
42+
bool createInertEndpoints)
4043
{
4144
if (endpoints == null)
4245
{
@@ -63,6 +66,26 @@ public void AddEndpoints(
6366
throw new ArgumentNullException(nameof(conventions));
6467
}
6568

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

82105
// We suppress link generation for each conventionally routed endpoint. We generate a single endpoint per-route
83106
// to handle link generation.
84-
var builder = CreateEndpoint(
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,21 @@ public void AddEndpoints(
109135
throw new InvalidOperationException("Failed to update route pattern with required values.");
110136
}
111137

112-
var endpoint = CreateEndpoint(
138+
var builder = new RouteEndpointBuilder(_requestDelegate, updatedRoutePattern, action.AttributeRouteInfo.Order)
139+
{
140+
DisplayName = action.DisplayName,
141+
};
142+
AddActionDataToBuilder(
143+
builder,
113144
routeNames,
114145
action,
115-
updatedRoutePattern,
116146
action.AttributeRouteInfo.Name,
117-
action.AttributeRouteInfo.Order,
118147
dataTokens: null,
119148
action.AttributeRouteInfo.SuppressLinkGeneration,
120149
action.AttributeRouteInfo.SuppressPathMatching,
121150
conventions,
122151
perRouteConventions: Array.Empty<Action<EndpointBuilder>>());
123-
endpoints.Add(endpoint);
152+
endpoints.Add(builder.Build());
124153
}
125154
}
126155

@@ -262,49 +291,17 @@ private static (RoutePattern resolvedRoutePattern, IDictionary<string, string> r
262291
return (attributeRoutePattern, resolvedRequiredValues ?? action.RouteValues);
263292
}
264293

265-
private RouteEndpoint CreateEndpoint(
294+
private void AddActionDataToBuilder(
295+
EndpointBuilder builder,
266296
HashSet<string> routeNames,
267297
ActionDescriptor action,
268-
RoutePattern routePattern,
269298
string routeName,
270-
int order,
271299
RouteValueDictionary dataTokens,
272300
bool suppressLinkGeneration,
273301
bool suppressPathMatching,
274302
IReadOnlyList<Action<EndpointBuilder>> conventions,
275303
IReadOnlyList<Action<EndpointBuilder>> perRouteConventions)
276304
{
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-
308305
// Add action metadata first so it has a low precedence
309306
if (action.EndpointMetadata != null)
310307
{
@@ -399,8 +396,47 @@ private RouteEndpoint CreateEndpoint(
399396
{
400397
perRouteConventions[i](builder);
401398
}
399+
}
402400

403-
return (RouteEndpoint)builder.Build();
401+
private static RequestDelegate CreateRequestDelegate()
402+
{
403+
// We don't want to close over the Invoker Factory in ActionEndpointFactory as
404+
// that creates cycles in DI. Since we're creating this delegate at startup time
405+
// we don't want to create all of the things we use at runtime until the action
406+
// actually matches.
407+
//
408+
// The request delegate is already a closure here because we close over
409+
// the action descriptor.
410+
IActionInvokerFactory invokerFactory = null;
411+
412+
return (context) =>
413+
{
414+
var endpoint = context.GetEndpoint();
415+
var dataTokens = endpoint.Metadata.GetMetadata<IDataTokensMetadata>();
416+
417+
var routeData = new RouteData();
418+
routeData.PushState(router: null, context.Request.RouteValues, new RouteValueDictionary(dataTokens?.DataTokens));
419+
420+
// Don't close over the ActionDescriptor, that's not valid for pages.
421+
var action = endpoint.Metadata.GetMetadata<ActionDescriptor>();
422+
var actionContext = new ActionContext(context, routeData, action);
423+
424+
if (invokerFactory == null)
425+
{
426+
invokerFactory = context.RequestServices.GetRequiredService<IActionInvokerFactory>();
427+
}
428+
429+
var invoker = invokerFactory.CreateInvoker(actionContext);
430+
return invoker.InvokeAsync();
431+
};
432+
}
433+
434+
private class InertEndpointBuilder : EndpointBuilder
435+
{
436+
public override Endpoint Build()
437+
{
438+
return new Endpoint(RequestDelegate, new EndpointMetadataCollection(Metadata), DisplayName);
439+
}
404440
}
405441
}
406442
}

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)