Skip to content

Commit 66c53c9

Browse files
committed
Merge branch 'feature/Area-Routing' into develop
Updated `OnTopic.AspNetCore.Mvc` project to provide better out-of-the-box support for area routing. Notably, this includes the introduction of the following new extension methods for configuring endpoint routing: - `MapTopicAreaRoute()` for mapping `{area}/{**path}` (6c6ad74, 762d229) - `MapDefaultAreaControllerRoute()` for mapping `{area}/{controller}/{action}/{id?}` (35cdd74) - `MapImplicitAreaControllerRoute()` for mapping `{area}/{action}`, with `{controller}` set to `{area}` (8a65c95, f41b76a) The parameterless overloads for `MapTopicAreaRoute()` (762d229) and `MapImplicitAreaControllerRoute()` (f41b76a) both use `MapDynamicControllerRoute<>()`, and rely on a newly introduced `TopicRouteValueTransformer` class for evaluating the existing routing variables and defining implicit defaults based on conventions commonly used for OnTopic-based websites (865f26a). As part of this, I also extended the search criteria for mapping routing variables to topic paths, in order to support implicit controllers (74a8a76). This mitigates a workaround in which affected controllers had to dynamically remove the `controller` route variable as part of an `OnActionExecuting()` event handler in order to prevent the `ITopicRepository.Load(RouteData)` extension method from looking under e.g. `/Forms/Forms` (for a scenario where `[Area("Forms")]` and `FormsController` are present). Finally, I implemented some cleanup, including: - Marked `MapTopicRoute(IRouteData)` as deprecated since sites should be moving toward endpoint routing (`IEndpointRouteBuilder`) (25419dd, fe9d7a8) - Implemented ASP.NET Core 3.0's new `{**path}` catch-all pattern for our custom `path` route variable, so the value isn't encoded (30c6691)
2 parents 9a63855 + f41b76a commit 66c53c9

File tree

3 files changed

+211
-14
lines changed

3 files changed

+211
-14
lines changed

OnTopic.AspNetCore.Mvc/ServiceCollectionExtensions.cs

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
| Client Ignia, LLC
44
| Project Topics Library
55
\=============================================================================================================================*/
6-
using OnTopic.AspNetCore.Mvc.Controllers;
7-
using OnTopic.Internal.Diagnostics;
6+
using System;
87
using Microsoft.AspNetCore.Builder;
98
using Microsoft.AspNetCore.Mvc.Infrastructure;
9+
using Microsoft.AspNetCore.Mvc.TagHelpers;
1010
using Microsoft.AspNetCore.Routing;
1111
using Microsoft.Extensions.DependencyInjection;
1212
using Microsoft.Extensions.DependencyInjection.Extensions;
13+
using OnTopic.AspNetCore.Mvc.Controllers;
14+
using OnTopic.Internal.Diagnostics;
1315

1416
namespace OnTopic.AspNetCore.Mvc {
1517

@@ -38,6 +40,7 @@ public static IMvcBuilder AddTopicSupport(this IMvcBuilder services) {
3840
| Register services
3941
\-----------------------------------------------------------------------------------------------------------------------*/
4042
services.Services.TryAddSingleton<IActionResultExecutor<TopicViewResult>, TopicViewResultExecutor>();
43+
services.Services.TryAddSingleton<TopicRouteValueTransformer>();
4144

4245
/*------------------------------------------------------------------------------------------------------------------------
4346
| Configure services
@@ -64,6 +67,12 @@ public static IMvcBuilder AddTopicSupport(this IMvcBuilder services) {
6467
/// <summary>
6568
/// Adds an MVC route for handling OnTopic related requests, and maps it to the <see cref="TopicController"/> by default.
6669
/// </summary>
70+
/// <remarks>
71+
/// For ASP.NET Core 3, prefer instead <see cref="MapTopicRoute(IEndpointRouteBuilder, String, String, String)"/>, as
72+
/// endpoint routing is preferred in ASP.NET Core 3. OnTopic also offers far more extension methods for endpoint routing,
73+
/// while this method is provided exclusively for backward compatibility.
74+
/// </remarks>
75+
[Obsolete("This method is deprecated and will be removed in OnTopic 5. Callers should migrate to endpoint routing.", false)]
6776
public static IRouteBuilder MapTopicRoute(
6877
this IRouteBuilder routes,
6978
string rootTopic,
@@ -80,12 +89,10 @@ public static IRouteBuilder MapTopicRoute(
8089
| EXTENSION: MAP TOPIC ROUTE (IENDPOINTROUTEBUILDER)
8190
\-------------------------------------------------------------------------------------------------------------------------*/
8291
/// <summary>
83-
/// Adds an MVC route for handling OnTopic related requests, and maps it to the <see cref="TopicController"/> by default.
92+
/// Adds the <c>{rootTopic}/{**path}</c> endpoint route for a specific root topic, which enables that root to be mapped to
93+
/// specific topics via the <see cref="TopicRepositoryExtensions.Load(Repositories.ITopicRepository, RouteData)"/>
94+
/// extension method used by <see cref="TopicController"/>.
8495
/// </summary>
85-
/// <remarks>
86-
/// This is functionally identical to <see cref="MapTopicRoute(IRouteBuilder, String, String, String)"/>, except that it
87-
/// targets the <see cref="IEndpointRouteBuilder"/>, which is preferred in ASP.NET Core 3.
88-
/// </remarks>
8996
public static ControllerActionEndpointConventionBuilder MapTopicRoute(
9097
this IEndpointRouteBuilder routes,
9198
string rootTopic,
@@ -94,10 +101,114 @@ public static ControllerActionEndpointConventionBuilder MapTopicRoute(
94101
) =>
95102
routes.MapControllerRoute(
96103
name: $"{rootTopic}Topic",
97-
pattern: rootTopic + "/{*path}",
104+
pattern: rootTopic + "/{**path}",
98105
defaults: new { controller, action, rootTopic }
99106
);
100107

108+
/*==========================================================================================================================
109+
| EXTENSION: MAP TOPIC AREA ROUTE (IENDPOINTROUTEBUILDER)
110+
\-------------------------------------------------------------------------------------------------------------------------*/
111+
/// <summary>
112+
/// Adds the <c>{areaName}/{**path}</c> endpoint route for a specific area, which enables the areas to be mapped to
113+
/// specific topics via the <see cref="TopicRepositoryExtensions.Load(Repositories.ITopicRepository, RouteData)"/>
114+
/// extension method used by <see cref="TopicController"/>.
115+
/// </summary>
116+
/// <remarks>
117+
/// If there are multiple routes that fit this description, you can instead opt to use the <see cref=
118+
/// "MapTopicAreaRoute(IEndpointRouteBuilder)"/> extension, which will register all areas.
119+
/// </remarks>
120+
public static ControllerActionEndpointConventionBuilder MapTopicAreaRoute(
121+
this IEndpointRouteBuilder routes,
122+
string areaName,
123+
string? controller = null,
124+
string action = "Index"
125+
) =>
126+
routes.MapAreaControllerRoute(
127+
name: $"TopicAreas",
128+
areaName: areaName,
129+
pattern: areaName + "/{**path}",
130+
defaults: new { controller = controller?? areaName, action, rootTopic = areaName }
131+
);
132+
133+
/// <summary>
134+
/// Adds the <c>{area:exists}/{**path}</c> endpoint route for all areas, which enables the areas to be mapped to specific
135+
/// topics via the <see cref="TopicRepositoryExtensions.Load(Repositories.ITopicRepository, RouteData)"/> extension method
136+
/// used by <see cref="TopicController"/>.
137+
/// </summary>
138+
/// <remarks>
139+
/// Be aware that this method uses the <see cref="ControllerEndpointRouteBuilderExtensions.MapDynamicControllerRoute{
140+
/// TTransformer}(IEndpointRouteBuilder, String)"/> method. In .NET 3.x, this is incompatible with both the <see cref=
141+
/// "AnchorTagHelper"/> and <see cref="LinkGenerator"/> classes. This means that e.g. <c>@Url.Action()</c> references
142+
/// in views won't be properly formed. If these are required, prefer registering each route individually using <see cref=
143+
/// "MapTopicAreaRoute(IEndpointRouteBuilder, String, String?, String)"/>.
144+
/// </remarks>
145+
public static void MapTopicAreaRoute(this IEndpointRouteBuilder routes) =>
146+
routes.MapDynamicControllerRoute<TopicRouteValueTransformer>("{area:exists}/{**path}");
147+
148+
/*==========================================================================================================================
149+
| EXTENSION: MAP DEFAULT AREA CONTROLLER ROUTES (IENDPOINTROUTEBUILDER)
150+
\-------------------------------------------------------------------------------------------------------------------------*/
151+
/// <summary>
152+
/// Adds the fully-qualified <c>{area:exists}/{controller}/{action=Index}/{id?}</c> endpoint route for all areas.
153+
/// </summary>
154+
/// <remarks>
155+
/// This is analogous to the standard <see cref="ControllerEndpointRouteBuilderExtensions.MapDefaultControllerRoute(
156+
/// IEndpointRouteBuilder)"/> method that ships with ASP.NET, except that it works with areas.
157+
/// </remarks>
158+
public static void MapDefaultAreaControllerRoute(this IEndpointRouteBuilder routes) =>
159+
routes.MapControllerRoute(
160+
name: "TopicAreas",
161+
pattern: "{area:exists}/{controller}/{action=Index}/{id?}"
162+
);
163+
164+
/*==========================================================================================================================
165+
| EXTENSION: MAP IMPLICIT AREA CONTROLLER ROUTES (IENDPOINTROUTEBUILDER)
166+
\-------------------------------------------------------------------------------------------------------------------------*/
167+
/// <summary>
168+
/// Adds the <c>{areaName}/{action=Index}</c> endpoint route for a specific area where the controller has the same name as
169+
/// the area.
170+
/// </summary>
171+
/// <remarks>
172+
/// <para>
173+
/// This extension method implicitly assigns the controller name based on the area name. This is advantageous when an
174+
/// area has a single controller which is named after the area—e.g., <c>[Area("Forms")]</c> and <c>FormsController</c>—
175+
/// as this allows the redundant <c>Controller</c> to be ommited from the route (e.g., <c>/Forms/Forms/{action}</c>.
176+
/// </para>
177+
/// <para>
178+
/// If there are multiple routes that fit this description, you can instead opt to use the <see cref=
179+
/// "MapImplicitAreaControllerRoute(IEndpointRouteBuilder)"/> overload, which will register all areas.
180+
/// </para>
181+
/// </remarks>
182+
public static void MapImplicitAreaControllerRoute(this IEndpointRouteBuilder routes, string areaName) =>
183+
routes.MapAreaControllerRoute(
184+
name: $"{areaName}TopicArea",
185+
areaName: areaName,
186+
pattern: $"{areaName}/{{action}}",
187+
defaults: new { controller = areaName }
188+
);
189+
190+
/// <summary>
191+
/// Adds the <c>{area:exists}/{action=Index}</c> endpoint route for all areas where the controller has the same name as
192+
/// the area.
193+
/// </summary>
194+
/// <remarks>
195+
/// <para>
196+
/// This extension method implicitly assigns the controller name based on the area name. This is advantageous when there
197+
/// are multiple areas which have a single controller which is named after the area—e.g., <c>[Area("Forms")]</c> and
198+
/// <c>FormsController: Controller</c>—as this allows those to be collectively registered under a single route, without
199+
/// needing the redundant <c>Controller</c> value to be defined in the route (e.g., <c>/Forms/Forms/{action}</c>.
200+
/// </para>
201+
/// <para>
202+
/// Be aware that this method uses the <see cref="ControllerEndpointRouteBuilderExtensions.MapDynamicControllerRoute{
203+
/// TTransformer}(IEndpointRouteBuilder, String)"/> method. In .NET 3.x, this is incompatible with both the <see cref=
204+
/// "AnchorTagHelper"/> and <see cref="LinkGenerator"/> classes. This means that e.g. <c>@Url.Action()</c> references
205+
/// in views won't be properly formed. If these are required, prefer registering each route individually using <see
206+
/// cref="MapImplicitAreaControllerRoute(IEndpointRouteBuilder, String)"/>.
207+
/// </para>
208+
/// </remarks>
209+
public static void MapImplicitAreaControllerRoute(this IEndpointRouteBuilder routes) =>
210+
routes.MapDynamicControllerRoute<TopicRouteValueTransformer>("{area:exists}/{action=Index}");
211+
101212
/*==========================================================================================================================
102213
| EXTENSION: MAP TOPIC REDIRECT (IENDPOINTROUTEBUILDER)
103214
\-------------------------------------------------------------------------------------------------------------------------*/

OnTopic.AspNetCore.Mvc/TopicRepositoryExtensions.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,11 @@ RouteData routeData
6464
| that path does need to be defined—thus e.g. {area}/{controller}/{path}.
6565
\-----------------------------------------------------------------------------------------------------------------------*/
6666
var paths = new List<string?>() {
67-
cleanPath($"{rootTopic}/{path}"),
68-
cleanPath($"{area}/{controller}/{action}/{path}"),
69-
cleanPath($"{area}/{controller}/{path}"),
67+
cleanPath($"{rootTopic}/{path}"),
68+
cleanPath($"{area}/{controller}/{action}/{path}"),
69+
cleanPath($"{area}/{controller}/{path}"),
70+
cleanPath($"{area}/{action}/{path}"),
71+
cleanPath($"{area}/{path}")
7072
};
7173

7274
/*------------------------------------------------------------------------------------------------------------------------
@@ -75,9 +77,9 @@ RouteData routeData
7577
var topic = (Topic?)null;
7678

7779
foreach (var searchPath in paths) {
78-
if (topic != null) break;
79-
if (String.IsNullOrEmpty(searchPath)) continue;
80-
topic = topicRepository.Load(searchPath);
80+
if (topic != null) break;
81+
if (String.IsNullOrEmpty(searchPath)) continue;
82+
topic = topicRepository.Load(searchPath);
8183
}
8284

8385
return topic;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*==============================================================================================================================
2+
| Author Ignia, LLC
3+
| Client Ignia, LLC
4+
| Project Topics Library
5+
\=============================================================================================================================*/
6+
using System;
7+
using System.Threading.Tasks;
8+
using Microsoft.AspNetCore.Http;
9+
using Microsoft.AspNetCore.Mvc.Routing;
10+
using Microsoft.AspNetCore.Routing;
11+
using OnTopic.Internal.Diagnostics;
12+
13+
namespace OnTopic.AspNetCore.Mvc {
14+
15+
/*============================================================================================================================
16+
| CLASS: TOPIC ROUTE VALUE TRANSFORMER
17+
\---------------------------------------------------------------------------------------------------------------------------*/
18+
/// <summary>
19+
/// Interprets endpoint routes associated with OnTopic in order to dynamically fill in any missing metadata based on the
20+
/// needs of the <see cref="TopicRepositoryExtensions.Load(Repositories.ITopicRepository, RouteData)"/> method.
21+
/// </summary>
22+
public class TopicRouteValueTransformer: DynamicRouteValueTransformer {
23+
24+
/*==========================================================================================================================
25+
| OVERRIDE: TRANSFORM (ASYNC)
26+
\-------------------------------------------------------------------------------------------------------------------------*/
27+
/// <summary>
28+
/// Evaluates the current <see cref="RouteValueDictionary"/> for any missing attributes, and attempts to dynamically
29+
/// inject them based on other values.
30+
/// </summary>
31+
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
32+
public override async ValueTask<RouteValueDictionary> TransformAsync(HttpContext httpContext, RouteValueDictionary values) {
33+
34+
/*------------------------------------------------------------------------------------------------------------------------
35+
| Validate parameters
36+
\-----------------------------------------------------------------------------------------------------------------------*/
37+
Contract.Requires(httpContext, nameof(httpContext));
38+
Contract.Requires(values, nameof(values));
39+
40+
/*------------------------------------------------------------------------------------------------------------------------
41+
| Implicitly set controller
42+
>-------------------------------------------------------------------------------------------------------------------------
43+
| If the area is set, but not the controller, assume that the controller is named after the area by convention. If the
44+
| controller is being set in the route pattern, this won't change that.
45+
\-----------------------------------------------------------------------------------------------------------------------*/
46+
var controller = (string)values["controller"];
47+
var area = (string)values["area"];
48+
if (area != null && controller == null) {
49+
values["controller"] = area;
50+
}
51+
52+
/*------------------------------------------------------------------------------------------------------------------------
53+
| Implicitly set action
54+
>-------------------------------------------------------------------------------------------------------------------------
55+
| If the action isn't defined in the route, assume Index—which is the default action for the TopicController.
56+
\-----------------------------------------------------------------------------------------------------------------------*/
57+
var action = (string)values["action"];
58+
if (action == null) {
59+
action = "Index";
60+
values["action"] = action;
61+
}
62+
63+
/*------------------------------------------------------------------------------------------------------------------------
64+
| Implicitly set root topic
65+
>-------------------------------------------------------------------------------------------------------------------------
66+
| If the path is set, but the root topic isn't, then set the root topic to the area/controller name. The root topic is
67+
| required by the TopicRepositoryExtensions to create a fully qualified topic path, and correctly identify the topic
68+
| based on the path. It is not needed when routing by controller/action pairs.
69+
\-----------------------------------------------------------------------------------------------------------------------*/
70+
var path = (string)values["path"];
71+
if (path != null || action.Equals("Index", StringComparison.OrdinalIgnoreCase)) {
72+
values["rootTopic"] = area;
73+
}
74+
75+
/*------------------------------------------------------------------------------------------------------------------------
76+
| Return values
77+
\-----------------------------------------------------------------------------------------------------------------------*/
78+
return values;
79+
80+
}
81+
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
82+
83+
} //Class
84+
} //Namespace

0 commit comments

Comments
 (0)