Looking at ASP.NET MVC 5.1 and Web API 2.1 - Part 2 - Attribute Routing with Custom Constraints
I'm continuing a series looking at some of the new features in ASP.NET MVC 5.1 and Web API 2.1. Part 1 (Overview and Enums) explained how to update your NuGet packages in an ASP.NET MVC application, so I won't rehash that here.
- Part 1: Overview and Enums
- Part 2: Attribute Routing with Custom Constraints
- Part 3: Bootstrap and JavaScript enhancements
- Part 4: Web API Help Pages, BSON, and Global Error Handling
The sample project covering the posts in this series is here; other referenced samples are in the ASP.NET sample repository.
In this post, we'll look at improvements to attribute routing for both ASP.NET MVC and ASP.NET Web API. First, a quick review of what routing constraints are used for.
Intro to Routing Constraints
ASP.NET MVC and Web API have both offered both simple and custom route constraints since they first came out. A simple constraint would be something like this:
routes.MapRoute("blog", "{year}/{month}/{day}", new { controller = "blog", action = "index" }, new { year = @"\d{4}", month = @"\d{2}", day = @"\d{2}" });
In the above case, "/2014/01/01" would match but "/does/this/work" would not since the values don't match the required pattern. If you needed something more complex than a simple pattern match, you'd use a custom constraint by implementing IRouteConstraint and defining the custom logic in the Match method - if it returns true, the route is a match.
public interface IRouteConstraint { bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection); }
Route Constraints in Attribute Routing
One of the top new features in ASP.NET MVC 5 and Web API 2 was the addition of Attribute Routing. Rather than defining all your routes in /App_Start/RouteConfig.cs using a series of routes.MapRoute() calls, you can define routes using attributes on your controller actions and controller classes. You can take your pick of whichever works better to you: continue to use traditional routing, use attribute routing instead, or use them both.
Attribute routing previously offered custom inline constraints, like this:
[Route("temp/{scale:values(celsius|fahrenheit)}")]
Here, the scale segment has a custom inline Values constraint which will only match if the the scale value is in the pipe-delimited list, e.g. this will match temp/celsius and /temp/fahrenheit but not /temp/foo. You can read more about the Attribute Routing features that shipped with ASP.NET MVC 5, including inline constraints like the above, on this post by Ken Egozi: Attribute Routing in ASP.NET MVC 5.
While inline constraints allow you to restrict values for a particular segment, they're both a little limited (e.g. they can't operate over the entire URL, and some more complex thing that aren't possible at that scope). To see more about what changed and why, see the issue report and changed code for this commit.
Now with ASP.NET MVC 5.1, we can create a new attribute that implements a custom route constraint. Here's an example.
ASP.NET MVC 5.1 Example: Adding a custom LocaleRoute
Here's a simple custom route attribute that matches based on a list of supported locales.
First, we'll create a custom LocaleRouteConstraint that implements IRouteConstraint:
public class LocaleRouteConstraint : IRouteConstraint { public string Locale { get; private set; } public LocaleRouteConstraint(string locale) { Locale = locale; } public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection) { object value; if (values.TryGetValue("locale", out value) && !string.IsNullOrWhiteSpace(value as string)) { string locale = value as string; if (isValid(locale)) { return string.Equals(Locale, locale, StringComparison.OrdinalIgnoreCase); } } return false; } private bool isValid(string locale) { string[] validOptions = "EN-US|EN-GB|FR-FR".Split('|') ; return validOptions.Contains(locale.ToUpper()); } }
IRouteConstraint has one method, Match. That's where you write your custom logic which determines if a set of incoming route values, context, etc., match your custom route. If you return true, routes with this constraint are eligible to respond to the request; if you return false the request will not be mapped to routes with this constraint.
In this case, we've got a simple isValid matcher which takes a locale string (e.g. fr-fr) and validates it against a list of supported locales. In more advanced use, this may be querying against a database backed cache of locales your site supports or using some other more advanced method. If you are working with a more advanced constraint, especially a locale constraint, I recommend Ben Foster's article Improving ASP.NET MVC Routing Configuration.
It's important to see that the real value in this case is running more advanced logic than a simple pattern match - if that's all you're doing, you could use a regex inline route constraint (e.g. {x:regex(^\d{3}-\d{3}-\d{4}$)}).
Now we've got a constraint, but we need to map it to an attribute to use in attribute routing. Note that separating constraints from attributes gives a lot more flexibility - for instance, we could use this constraint on multiple attributes.
Here's a simple one:
public class LocaleRouteAttribute : RouteFactoryAttribute { public LocaleRouteAttribute(string template, string locale) : base(template) { Locale = locale; } public string Locale { get; private set; } public override RouteValueDictionary Constraints { get { var constraints = new RouteValueDictionary(); constraints.Add("locale", new LocaleRouteConstraint(Locale)); return constraints; } } public override RouteValueDictionary Defaults { get { var defaults = new RouteValueDictionary(); defaults.Add("locale", "en-us"); return defaults; } } }
Now we've got a complete route attribute we can place on a controller or action:
using System.Web.Mvc; namespace StarDotOne.Controllers { [LocaleRoute("hello/{locale}/{action=Index}", "EN-GB")] public class ENGBHomeController : Controller { // GET: /hello/en-gb/ public ActionResult Index() { return Content("I am the EN-GB controller."); } } }
And here's our FR-FR controller:
using System.Web.Mvc; namespace StarDotOne.Controllers { [LocaleRoute("hello/{locale}/{action=Index}", "FR-FR")] public class FRFRHomeController : Controller { // GET: /hello/fr-fr/ public ActionResult Index() { return Content("Je suis le contrôleur FR-FR."); } } }
Before running this, we need to verify that we've got Attribute Routes enabled in our RouteConfig:
public class RouteConfig { public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapMvcAttributeRoutes(); routes.MapRoute( name: "Default", url: "{controller}/{action}/{id}", defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional } ); } }
Now a request to /hello/en-gb/ goes to our ENGBController and a request to /hello/fr-fr/ goes to the FRFRController:
Because we've set the default locale in the LocaleRouteAttribute to en-us, we can browse to it using either /hello/en-us/ or just /hello:
If you've been paying close attention, you may be thinking that we could have accomplished the same thing using an inline route constraint. I think the real benefit over a custom inline constraint is when you're doing more than operating on one segment in the URL: preforming logic on the entire route or context. One great example there would be using a custom attribute based on a user's locale selection (set in a cookie, perhaps) or using a header.
So, to recap:
- You could write custom route constraints before in "Traditional" code-based routing, but not in attribute routing
- You could write custom inline constraints, but they mapped just to a segment in the URL
- Custom route constraints now can operate at a higher level than just a segment on the URL path, e.g. headers or other request context
A very common use case for using headers in routing is versioning by header. We'll look at that with ASP.NET Web API 2.1 next. Keep in mind that, while the general recommendation is to use ASP.NET Web API for your HTTP APIs, many APIs are still running on ASP.NET MVC for a variety of reasons (existing / legacy systems APIs built on ASP.NET MVC, familiarity with MVC, mostly-MVC applications with relatively few APIs that want to stay simple, developer preferences, etc.) and for that reason, versioning ASP.NET MVC HTTP APIs by headers is probably one of the top use cases of custom route attribute constaints for ASP.NET MVC as well.
ASP.NET Web API 2.1 Custom Route Attributes example: Versioning By Header
Note: The example I'm showing here is in the official samples list on CodePlex. There's a lot of great examples there, including some samples showing off some of the more complex features you don't hear about all that often. Since the methodology is almost exactly the same as what we looked at in ASP.NET MVC 5.1 and the sample's available, I'll go through this one a lot faster.
First, the custom constraint:
internal class VersionConstraint : IHttpRouteConstraint { public const string VersionHeaderName = "api-version"; private const int DefaultVersion = 1; public VersionConstraint(int allowedVersion) { AllowedVersion = allowedVersion; } public int AllowedVersion { get; private set; } public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values, HttpRouteDirection routeDirection) { if (routeDirection == HttpRouteDirection.UriResolution) { int version = GetVersionHeader(request) ?? DefaultVersion; if (version == AllowedVersion) { return true; } } return false; } private int? GetVersionHeader(HttpRequestMessage request) { string versionAsString; IEnumerable<string> headerValues; if (request.Headers.TryGetValues(VersionHeaderName, out headerValues) && headerValues.Count() == 1) { versionAsString = headerValues.First(); } else { return null; } int version; if (versionAsString != null && Int32.TryParse(versionAsString, out version)) { return version; } return null; } }
This is similar to the simpler LocaleConstraint we looked at earlier, but parses an integer version number from a header. Now, like before, we create an attribute to put this constraint to work:
internal class VersionedRoute : RouteFactoryAttribute { public VersionedRoute(string template, int allowedVersion) : base(template) { AllowedVersion = allowedVersion; } public int AllowedVersion { get; private set; } public override IDictionary<string, object> Constraints { get { var constraints = new HttpRouteValueDictionary(); constraints.Add("version", new VersionConstraint(AllowedVersion)); return constraints; } } } }
And with that set up, we can just slap the attribute header on a couple different ApiControllers:
[VersionedRoute("api/Customer", 1)] public class CustomerVersion1Controller : ApiController { // controller code goes here } [VersionedRoute("api/Customer", 2)] public class CustomerVersion2Controller : ApiController { // controller code goes here }
That's it - now requests to /api/Customer with the api-version header set to 1 (or empty, since it's the default) go to the first controller, and with api-version set to 2 go to the second controller. The sample includes a handy test client console app that does just that:
Okay, let's wrap up there for now. In the next (probably final) post, we'll take a quick high level look at some of the other features in this release.
Recap:
- Custom route constraints let you run custom logic to determine if a route matches as well as other things like compute values that are available in the matching controllers
- The previous release allowed for custom inline route constraints, but they only operated on a segment
- This *.1 release includes support for full custom route constraints