Bootstrapping ASP.NET MVC
- By Dino Esposito
- 1/2/2019
- Enabling the MVC Application Model
- Configuring the Routing Table
- Map of ASP.NET MVC Machinery
- Summary
Configuring the Routing Table
Historically, the primary way to define routes in ASP.NET MVC is to add URL templates to an in-memory table. It is worth noting that ASP.NET Core also supports routes defined as attributes of controller methods, as you’ll learn in Chapter 3.
Whether defined through a table entry or through an attribute, conceptually, a route is always the same and always contains the same amount of information.
Anatomy of a Route
A route is essentially given by a unique name and a URL pattern. The URL pattern can be made of static text or can include dynamic parameters whose values are excerpted from the URL and possibly the whole HTTP context. The full syntax for defining a route is shown below.
app.UseMvc(routes => { routes.MapRoute( name: "your_route", template: "...", defaults: new { controller = "...", action = "..." }, constraints: { ... }, dataTokens: { ... }); })
The template argument refers to the URL pattern of your choice. As mentioned, for the default conventional route, it is equal to:
{controller}/{action}/{id?}
Defining additional routes can take any form you like and can include both static text and custom route parameters. The defaults argument specifies default values for the route parameters. The template argument can be fused to the defaults argument. When this happens, the defaults argument is omitted, and the template argument takes the following form.
template: "{controller=Home}/{action=Index}/{id?}"
As mentioned, if the ? symbol is appended to the parameter name, then the parameter is optional.
The constraints argument refers to constraints set on a particular route parameter such as acceptable values or required type. The dataTokens argument refers to additional custom values associated with the route but not used to determine whether the route matches a URL pattern. We’ll return on these advanced aspects of a route in a moment.
Defining Custom Routes
Conventional routing figures out controller and method name automatically from the segments of the URL. Custom routes just use alternative algorithms to figure out the same information. More often, custom routes are made of static text explicitly mapped to a controller/method pair.
While conventional routing is fairly common in ASP.NET MVC applications, there’s no reason for not having additional routes defined. Typically, you don’t disable conventional routing; you simply add some ad hoc routes to have some controlled URLs to invoke a certain behavior of the application.
public void Configure(IApplicationBuilder app) { // Custom routes app.UseMvc(routes => { routes.MapRoute(name: "route-today", template: "today", defaults: new { controller="date", action="day", offset = 0 }); routes.MapRoute(name: "route-yesterday", template: "yesterday", defaults: new { controller = "date", action = "day", offset = -1 }); routes.MapRoute(name: "route-tomorrow", template: "tomorrow", defaults: new { controller = "date", action = "day", offset = 1 }); }); // Conventional routing app.UseMvcWithDefaultRoute(); // Terminating middleware app.Run(async (context) => { await context.Response.WriteAsync( "I'd rather say there are no configured routes here."); }); }
Figure 3-4 Shows the output of the newly defined routes.
FIGURE 3-4 New routes in action
All the new routes are based on a static text mapped to the method Day on the controller Date. The only difference is the value of an additional route parameter—the offset parameter. For the sample code to work as shown in the Figure 3-4, a DateController class is required in the project. Here’s a possible implementation:
public class DateController : Controller { public IActionResult Day(int offset) { ... } }
It’s interesting to notice what happens when you invoke a URL like the following /date/day?offset=1. Not surprisingly, the output is the same as invoking /tomorrow. This is the effect of having custom routes and conventional routing working side by side. Instead, the URL /date/day/1 won’t be recognized properly, but you won’t get an HTTP 404 error or a message from the terminating middleware. The URL is resolved as if you had called /today or /date/day.
As expected, the URL /date/day/1 doesn’t match any of the custom routes. However, it is perfectly matched by the default route. The controller parameter is set to Date, and the action parameter is set to Day. However, the default route features a third optional parameter—the id parameter—whose value is excerpted from the third segment of the URL. The value 1 of the sample URL is then assigned to a variable named id, not to a variable named offset. The parameter offset that is passed to the Day method in the controller implementation only gets the default value of its type—0 for an integer.
To give a URL like /date/day/1 the meaning of one day after today, you must slightly rework the list of custom routes and add a new one at the end of the table.
routes.MapRoute(name: "route-day", template: "date/day/{offset}", defaults: new { controller = "date", action = "day", offset = 0 });
Also, you could even edit the route-today route as below:
routes.MapRoute(name: "route-today", template: "today/{offset}", defaults: new { controller = "date", action = "day", offset = 0 });
Now any text following /date/day/ and /today/ will be assigned to the route parameter named offset and made available within the controller class action methods (see Figure 3-5).
FIGURE 3-5 Slightly edited routes
At this point, a good question would be: Is there a way to force the text being assigned to the offset route parameter to be a number? That’s just what route constraints are for. However, we have a couple of other topics to cover before approaching route constraints.
Order of Routes
When you work with multiple routes, the order in which they appear in the table is important. The routing service, in fact, scans the route table from top to bottom and evaluates routes as they appear. The scan stops at the first match. In other words, very specific routes should be given a higher position in the table so that they are evaluated before more generic routes.
The default route is a fairly generic one because it determines controller and action directly from the URL. The default route is so generic that it can even be the only route you use in an application. Most of the ASP.NET MVC applications I have in production only use conventional routing.
If you have custom routes, however, make sure you list them before enabling conventional routing; otherwise, you risk that the greedier default route will capture the URL. Note, however, that in ASP.NET MVC Core, capturing the URL is not limited to extracting the name of the controller and method. A route is selected only if both the controller class and the related method exist in the application. For example, let’s consider a scenario in which conventional routing is enabled as the first route and is followed by all custom routes we saw in Figure 3-5. What happens when the user requests /today? The default route would resolve it to the Today controller and Index method. However, if the application lacks a TodayController class, or an Index action method, then the default route is discarded, and the search proceeds with the next route.
It might be a good idea to have a catch-all route at the very bottom of the table, after the default route. A catch-all route is a fairly generic route that is matched in any case and works as a recovery step. Here’s an example of it:
app.UseMvc(routes => { // Custom routes }); // Conventional routing app.UseMvcWithDefaultRoute(); // Catch-all route app.UseMvc(routes => { routes.MapRoute(name: "catch-all", template: "{*url}", defaults: new { controller = "error", action = "message" }); });
The catch-all route map to the Message method of the ErrorController class that accepts a route parameter named url. The asterisk symbol indicates that this parameter grabs the rest of the URL.
Accessing Route Data Programmatically
The information available about the route that matches the requested URL is saved to a data container of type RouteData. Figure 3-6 provides a glimpse of the internals of RouteData during the execution of a request for home/index.
FIGURE 3-6 RouteData internals
The incoming URL has been matched to the default route and, because of the URL pattern, the first segment is mapped to the controller route parameter while the second segment is mapped to the action route parameter. Route parameters are defined within the URL template through the {parameter} notation. The {parameter=value} notation, instead, defines a default value for the parameter to be used in case the given segment is missing. Route parameters can be accessed programmatically using the following expression:
var controller = RouteData.Values["controller"]; var action = RouteData.Values["action"];
The code works nicely if you are in the context of a controller class that inherits from the base Controller class.
As we’ll see in Chapter 4, though, ASP.NET Core also supports plain-old CLR object (POCO) controllers, namely controller classes that do not inherit from Controller. In this case, getting the route data is a bit more complicated.
public class PocoController { private IActionContextAccessor _accessor; public PocoController(IActionContextAccessor accessor) { _accessor = accessor; } public IActionResult Index() { var controller = _accessor.ActionContext.RouteData.Values["controller"]; var action = _accessor.ActionContext.RouteData.Values["action"]; var text = string.Format("{0}.{1}", controller, action); return new ContentResult { Content = text }; } }
You need to have an action context accessor injected into the controller. ASP.NET Core provides a default action context accessor but binding it to the services collection is a responsibility of the developer.
public void ConfigureServices(IServiceCollection services) { // More code may go here ... // Register the action context accessor services.AddSingleton<IActionContextAccessor, ActionContextAccessor>(); }
To access route data parameters from within controllers, you don’t strictly need to use any of the techniques illustrated here. As we’ll see in Chapter 4, the model binding infrastructure will automatically bind HTTP context values to declared parameters by name.
Advanced Aspects of Routing
A route can be further characterized by constraints and data tokens. A constraint is a sort of a validation rule that is associated with a route parameter. If a constraint is not validated the route is not matched. Data tokens, instead, are simple bits of information associated with a route made available to the controller but not used to determine if a URL matches the route.
Route Constraints
Technically speaking, a constraint is a class that implements the IRouteConstraint interface and essentially validates the value passed to a given route parameter. For example, you can use a constraint to ensure that a route is matched only if a given parameter receives a value of the expected type. Here’s how you define a route constraint:
app.UseMvc(routes => { routes.MapRoute(name: "route-today", template: "today/{offset}", defaults: new { controller="date", action="day", offset=0 } constraints: new { offset = new IntRouteConstraint() }); });
In the example, the offset parameter of the route is subject to the action of the IntRouteConstraint class, one of the predefined constraint classes in the ASP.NET MVC Core framework. The following code shows the skeleton of a constraint class.
// Code adapted from the actual implementation of IntRouteConstraint class. public class IntRouteConstraint : IRouteConstraint { public bool Match( HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { object value; if (values.TryGetValue(routeKey, out value) && value != null) { if (value is int) return true; int result; var valueString = Convert.ToString(value, CultureInfo.InvariantCulture); return int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out result); } return false; } }
A constraint class extracts the value of the routeKey parameter from the dictionary of route values and makes reasonable checks on it. The IntRouteConstraint class simply checks that the value can be successfully parsed to an integer.
Note that a constraint can be associated with a unique name string that explains how the constraint is used. The constraint name can be used to specify the constraint more compactly.
routes.MapRoute(name: "route-day", template: "date/day/{offset:int}", defaults: new { controller = "date", action = "day", offset = 0 });
The name of the IntRouteConstraint class is int meaning that {offset:int} associates the action of the class to the offset parameter. IntRouteConstraint is one of the predefined route constraint classes in ASP.NET MVC Core, and their names are set at startup and fully documented. If you create a custom constraint class, you should set the name of the constraint when you register it with the system.
public void ConfigureServices(IServiceCollection services) { ... services.Configure<RouteOptions>(options => options.ConstraintMap.Add("your-route", typeof(YourRouteConstraint))); }
Based on that you can now use the {parametername:contraintprefix} notation to bind the constraint to a given route parameter.
Predefined Route Constraints
Table 3-2 presents the list of predefined route constraints and their mapped names.
TABLE 3-2 Predefined route constraints
Mapping Name |
Class |
Description |
Int |
IntRouteConstraint |
Ensures the route parameter is set to an integer |
Bool |
BoolRouteConstraint |
Ensures the route parameter is set to a Boolean value |
datetime |
DateTimeRouteConstraint |
Ensures the route parameter is set to a valid date |
decimal |
DecimalRouteConstraint |
Ensures the route parameter is set to a decimal |
double |
DoubleRouteConstraint |
Ensures the route parameter is set to a double |
Float |
FloatRouteConstraint |
Ensures the route parameter is set to a float |
Guid |
GuidRouteConstraint |
Ensures the route parameter is set to a GUID |
Long |
LongRouteConstraint |
Ensures the route parameter is set to a long integer |
minlength(N) |
MinLengthRouteConstraint |
Ensures the route parameter is set to a string no shorter than the specified length |
maxlength(N) |
MaxLengthRouteConstraint |
Ensures the route parameter is set to a string no longer than the specified length |
length(N) |
LengthRouteConstraint |
Ensures the route parameter is set to a string of the specified length |
min(N) |
MinRouteConstraint |
Ensures the route parameter is set to an integer greater than the specified value |
max(N) |
MaxRouteConstraint |
Ensures the route parameter is set to an integer smaller than the specified value |
range(M, N) |
RangeRouteConstraint |
Ensures the route parameter is set to an integer that falls within the specified range of values |
alpha |
AlphaRouteConstraint |
Ensures the route parameter is set to a string made of alphabetic characters |
regex(RE) |
RegexInlineRouteConstraint |
Ensures the route parameter is set to a string compliant with the specified regular expression |
required |
RequiredRouteConstraint |
Ensures the route parameter has an assigned value in the URL |
As you might have noticed, the list of predefined route constraints doesn’t include a fairly common one: Ensuring that the route parameter takes a value from a known set of possible values. To constrain a parameter in this way, you can use a regular expression, as shown below.
{format:regex(json|xml|text)}
A URL would match the route with such a format parameter only if the parameter takes any of the listed substrings.
Data Tokens
In ASP.NET MVC, a route is not limited to the information within the URL. The URL segments are used to determine whether a route matches a request, but additional information can be associated with a route and retrieved programmatically later. To attach extra information to a route you use data tokens.
A data token is defined with the route and is nothing more than a name/value pair. Any route can have any number of data tokens. Data tokens are free bits of information not used to match a URL to the route.
app.UseMvc(routes => { routes.MapRoute(name: "catch-all", template: "{*url}", defaults: new { controller = "home", action = "index" }, constraints: new { }, dataTokens: new { reason = "catch-all" }); });
Data tokens are not a critical, must-have feature of the ASP.NET MVC routing system, but they are sometimes useful. For example, let’s say you have a catch-all route mapped to a controller/action pair that is also used for other purposes and imagine that the Index method of the Home controller is used for a URL that doesn’t match any of the routes. The idea is to show the home page if a more specific URL can’t be determined.
How can you distinguish between a direct request for the home page and the home page displayed because of a catch-all routing? Data tokens are an option. Here’s how you can retrieve data tokens programmatically.
var catchall = RouteData.DataTokens["reason"] ?? "";
Data tokens are defined with routes but are only used programmatically.