Skip to content

Commit f8445ec

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 285110d commit f8445ec

28 files changed

+747
-388
lines changed

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
@@ -206,7 +207,7 @@ public static void MapFallbackToController(
206207
EnsureControllerServices(endpoints);
207208

208209
// Called for side-effect to make sure that the data source is registered.
209-
GetOrCreateDataSource(endpoints);
210+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
210211

211212
// Maps a fallback endpoint with an empty delegate. This is OK because
212213
// we don't expect the delegate to run.
@@ -280,7 +281,7 @@ public static void MapFallbackToController(
280281
EnsureControllerServices(endpoints);
281282

282283
// Called for side-effect to make sure that the data source is registered.
283-
GetOrCreateDataSource(endpoints);
284+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
284285

285286
// Maps a fallback endpoint with an empty delegate. This is OK because
286287
// we don't expect the delegate to run.
@@ -346,7 +347,7 @@ public static void MapFallbackToAreaController(
346347
EnsureControllerServices(endpoints);
347348

348349
// Called for side-effect to make sure that the data source is registered.
349-
GetOrCreateDataSource(endpoints);
350+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
350351

351352
// Maps a fallback endpoint with an empty delegate. This is OK because
352353
// we don't expect the delegate to run.
@@ -422,7 +423,7 @@ public static void MapFallbackToAreaController(
422423
EnsureControllerServices(endpoints);
423424

424425
// Called for side-effect to make sure that the data source is registered.
425-
GetOrCreateDataSource(endpoints);
426+
GetOrCreateDataSource(endpoints).CreateInertEndpoints = true;
426427

427428
// Maps a fallback endpoint with an empty delegate. This is OK because
428429
// we don't expect the delegate to run.
@@ -433,6 +434,44 @@ public static void MapFallbackToAreaController(
433434
});
434435
}
435436

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

src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,11 @@ internal static void AddMvcCoreServices(IServiceCollection services)
171171
//
172172
// Action Selection
173173
//
174-
services.TryAddSingleton<IActionSelector, ActionSelector>();
174+
services.TryAddSingleton<IActionSelector>(services =>
175+
{
176+
return services.GetRequiredService<ActionSelector>();
177+
});
178+
services.TryAddSingleton<ActionSelector>();
175179
services.TryAddSingleton<ActionConstraintCache>();
176180

177181
// Will be cached by the DefaultActionSelector
@@ -269,7 +273,6 @@ internal static void AddMvcCoreServices(IServiceCollection services)
269273
//
270274
// Endpoint Routing / Endpoints
271275
//
272-
services.TryAddSingleton<ActionEndpointDataSource>();
273276
services.TryAddSingleton<ControllerActionEndpointDataSource>();
274277
services.TryAddSingleton<ActionEndpointFactory>();
275278
services.TryAddSingleton<DynamicControllerEndpointSelector>();

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/ActionEndpointDataSource.cs

Lines changed: 0 additions & 62 deletions
This file was deleted.

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

Lines changed: 93 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ public void AddEndpoints(
3434
List<Endpoint> endpoints,
3535
ActionDescriptor action,
3636
IReadOnlyList<ConventionalRouteEntry> routes,
37-
IReadOnlyList<Action<EndpointBuilder>> conventions)
37+
IReadOnlyList<Action<EndpointBuilder>> conventions,
38+
bool createInertEndpoints)
3839
{
3940
if (endpoints == null)
4041
{
@@ -56,6 +57,17 @@ public void AddEndpoints(
5657
throw new ArgumentNullException(nameof(conventions));
5758
}
5859

60+
if (createInertEndpoints)
61+
{
62+
// For each ActionDescriptor create a single 'inert' Endpoint without routing information.
63+
//
64+
// This makes those endpoints accessible for dynamic and fallback routing. We don't want to
65+
// mix RouteEndpoint that is selected by routing, vs Endpoint that is selected by user code/policies
66+
//
67+
// This also helps us avoid ambiguities when an endpoint has multiple conventional routes.
68+
endpoints.Add(CreateInertEndpoint(action, conventions));
69+
}
70+
5971
if (action.AttributeRouteInfo == null)
6072
{
6173
// In traditional conventional routing setup, the routes defined by a user have a static order
@@ -181,32 +193,54 @@ private RouteEndpoint CreateEndpoint(
181193
bool suppressPathMatching,
182194
IReadOnlyList<Action<EndpointBuilder>> conventions)
183195
{
184-
185-
// We don't want to close over the retrieve the Invoker Factory in ActionEndpointFactory as
186-
// that creates cycles in DI. Since we're creating this delegate at startup time
187-
// we don't want to create all of the things we use at runtime until the action
188-
// actually matches.
189-
//
190-
// The request delegate is already a closure here because we close over
191-
// the action descriptor.
192-
IActionInvokerFactory invokerFactory = null;
193-
194-
RequestDelegate requestDelegate = (context) =>
196+
var builder = new RouteEndpointBuilder(CreateRequestDelegate(action), routePattern, order)
195197
{
196-
var routeData = context.GetRouteData();
197-
var actionContext = new ActionContext(context, routeData, action);
198+
DisplayName = action.DisplayName,
199+
};
198200

199-
if (invokerFactory == null)
201+
// Add action metadata first so it has a low precedence
202+
if (action.EndpointMetadata != null)
203+
{
204+
foreach (var d in action.EndpointMetadata)
200205
{
201-
invokerFactory = context.RequestServices.GetRequiredService<IActionInvokerFactory>();
206+
builder.Metadata.Add(d);
202207
}
208+
}
203209

204-
var invoker = invokerFactory.CreateInvoker(actionContext);
205-
return invoker.InvokeAsync();
206-
};
210+
builder.Metadata.Add(action);
211+
212+
if (dataTokens != null)
213+
{
214+
builder.Metadata.Add(new DataTokensMetadata(dataTokens));
215+
}
207216

208-
var builder = new RouteEndpointBuilder(requestDelegate, routePattern, order)
217+
builder.Metadata.Add(new RouteNameMetadata(routeName));
218+
219+
AddMetadataFromActionDescriptor(builder, action);
220+
221+
if (suppressLinkGeneration)
209222
{
223+
builder.Metadata.Add(new SuppressLinkGenerationMetadata());
224+
}
225+
226+
if (suppressPathMatching)
227+
{
228+
builder.Metadata.Add(new SuppressMatchingMetadata());
229+
}
230+
231+
for (var i = 0; i < conventions.Count; i++)
232+
{
233+
conventions[i](builder);
234+
}
235+
236+
return (RouteEndpoint)builder.Build();
237+
}
238+
239+
private Endpoint CreateInertEndpoint(ActionDescriptor action, IReadOnlyList<Action<EndpointBuilder>> conventions)
240+
{
241+
var builder = new InertEndpointBuilder()
242+
{
243+
RequestDelegate = CreateRequestDelegate(action),
210244
DisplayName = action.DisplayName,
211245
};
212246

@@ -221,13 +255,44 @@ private RouteEndpoint CreateEndpoint(
221255

222256
builder.Metadata.Add(action);
223257

224-
if (dataTokens != null)
258+
AddMetadataFromActionDescriptor(builder, action);
259+
260+
for (var i = 0; i < conventions.Count; i++)
225261
{
226-
builder.Metadata.Add(new DataTokensMetadata(dataTokens));
262+
conventions[i](builder);
227263
}
228264

229-
builder.Metadata.Add(new RouteNameMetadata(routeName));
265+
return builder.Build();
266+
}
230267

268+
private RequestDelegate CreateRequestDelegate(ActionDescriptor action)
269+
{
270+
// We don't want to close over the retrieve the Invoker Factory in ActionEndpointFactory as
271+
// that creates cycles in DI. Since we're creating this delegate at startup time
272+
// we don't want to create all of the things we use at runtime until the action
273+
// actually matches.
274+
//
275+
// The request delegate is already a closure here because we close over
276+
// the action descriptor.
277+
IActionInvokerFactory invokerFactory = null;
278+
279+
return (context) =>
280+
{
281+
var routeData = context.GetRouteData();
282+
var actionContext = new ActionContext(context, routeData, action);
283+
284+
if (invokerFactory == null)
285+
{
286+
invokerFactory = context.RequestServices.GetRequiredService<IActionInvokerFactory>();
287+
}
288+
289+
var invoker = invokerFactory.CreateInvoker(actionContext);
290+
return invoker.InvokeAsync();
291+
};
292+
}
293+
294+
private void AddMetadataFromActionDescriptor(EndpointBuilder builder, ActionDescriptor action)
295+
{
231296
// Add filter descriptors to endpoint metadata
232297
if (action.FilterDescriptors != null && action.FilterDescriptors.Count > 0)
233298
{
@@ -263,23 +328,14 @@ private RouteEndpoint CreateEndpoint(
263328
}
264329
}
265330
}
331+
}
266332

267-
if (suppressLinkGeneration)
268-
{
269-
builder.Metadata.Add(new SuppressLinkGenerationMetadata());
270-
}
271-
272-
if (suppressPathMatching)
273-
{
274-
builder.Metadata.Add(new SuppressMatchingMetadata());
275-
}
276-
277-
for (var i = 0; i < conventions.Count; i++)
333+
private class InertEndpointBuilder : EndpointBuilder
334+
{
335+
public override Endpoint Build()
278336
{
279-
conventions[i](builder);
337+
return new Endpoint(RequestDelegate, new EndpointMetadataCollection(Metadata), DisplayName);
280338
}
281-
282-
return (RouteEndpoint)builder.Build();
283339
}
284340
}
285341
}

0 commit comments

Comments
 (0)