ASP.NET MVC Tip #30 – Create Custom Route Constraints
In this tip, I show how you can create custom route constraints that prevent you from accessing a URL unless you are local and authenticated. I show you how you can create a LocalConstraint and an AuthenticatedConstraint. I also demonstrate how you can test your custom constraints.
When you create an MVC route, you can add constraints to a route. For example, the following route maps browser requests to a controller named Blog and an action named Archive:
routes.MapRoute( "BlogArchive", "Archive/{entryDate}", new { controller = "Blog", action = "Archive" } );
This route, named BlogArchive, maps three parameters. The controller parameter is assigned the value Blog and the action parameter is assigned the value Archive. Finally, the entryDate parameter gets its value from the entryDate parameter in the URL.
Unfortunately, this route matches too many requests. It matches:
· /Archive/12-25-1966
· /Archive/12
· /Archive/apple
You don’t want entryDate to get values like 12 or apple. These values cannot be converted into a date.
You can fix this problem with the BlogArchive route by adding a constraint to the entryDate parameter like this:
routes.MapRoute( "BlogArchive", "Archive/{entryDate}", new { controller = "Blog", action = "Archive" }, new { entryDate = @"\d{2}-\d{2}-\d{4}" } );
This new version of the BlogArchive route includes a constraint on the entryDate parameter. The constraint requires that the entryDate parameter matches a date pattern that looks like 12-25-1966. A URL like /Archive/apple won’t satisfy this constraint and the route won’t be matched.
Two Types of Route Constraints
The URL Routing framework recognizes two different types of constraints. When you supply a constraint, you can either supply a string or you can supply a class that implements the IRouteConstraint interface.
If you supply a string for a constraint then the string is interpreted as a regular expression. For example, the entryDate constraint that we discussed in the previous section is an example of a regular expression constraint.
The other option is to create a custom constraint by creating an instance of a class that implements the IRouteConstraint interface. The URL Routing framework includes one custom constraint: the HttpMethod constraint.
Using the HttpMethod Constraint
You can take advantage of the HttpMethod constraint to prevent a controller action from being invoked unless the action is invoked with a particular HTTP method. For example, you might want a controller action named Insert() to be invoked only when performing an HTTP POST operation and not when performing an HTTP GET operation.
Here’s how you can use the HttpMethod constraint:
routes.MapRoute( "Product", "Product/Insert", new { controller = "Product", action = "Insert"}, new { httpMethod = new HttpMethodConstraint("POST") } );
The last argument passed to the MapRoute() method represents a new HttpMethod constraint named httpMethod. If you post an HTML form to the /Product/Insert URL, then the Product.Insert() controller action will be invoked. However, if you simply request /Product/Insert with an HTTP GET, then this route won’t be matched.
By the way, the name of the constraint is not important. Only the value is important. For example, the following code works just as well as the previous code:
routes.MapRoute( "Product", "Product/Insert", new { controller = "Product", action = "Insert"}, new { Grendal = new HttpMethodConstraint("POST") } );
In this code, the HttpMethodConstraint is named Grendal. You can name the constraint anything you want and the constraint will still work.
Creating an Authenticated Constraint
You create custom constraints by implementing the IRouteConstraint interface. This interface has one method that you must implement: the Match() method.
For example, the code in Listing 1 represents a custom constraint that prevents unauthenticated access to a URL:
Listing 1 – AuthenticatedConstraint.cs
using System.Web; using System.Web.Routing; public class AuthenticatedConstraint : IRouteConstraint { public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection) { return httpContext.Request.IsAuthenticated; } }
Notice that Listing 1 contains a class that implements the IRouteConstraint interface. The Match() method checks whether the current user is authenticated and returns either True or False.
Here’s how you can use the AuthenticatedConstraint when creating a route:
routes.MapRoute( "Admin", "Admin/{action}", new { controller = "Admin", action = "Index" }, new { authenticated= new AuthenticatedConstraint()} );
This constraint prevents requests from anonymous users from being mapped to the Admin route.
It is important to understand that even though this particular route cannot be accessed by an anonymous user, a later route might map an anonymous user to the same controller and controller action. For example, if the Admin route is followed by the following Default route, then a user can access the Admin pages:
routes.MapRoute( "Default", "{controller}/{action}/{id}", new { controller = "Home", action = "Index", id = "" } );
For this reason, you should explicitly exclude the Admin pages from the Default route with an explicit constraint.
Creating a NotEqual Constraint
The easiest way to exclude one set of pages from matching a particular route is to take advantage of the custom route constraint in Listing 2.
Listing 2 – NotEqualConstraint.cs
using System; using System.Web; using System.Web.Routing; public class NotEqual : IRouteConstraint { private string _match = String.Empty; public NotEqual(string match) { _match = match; } public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection) { return String.Compare(values[parameterName].ToString(), _match, true) != 0; } }
Here’s how you can use the NotEqual constraint to exclude the /Admin pages from the Default route:
routes.MapRoute( "Default", "{controller}/{action}/{id}", new { controller = "Home", action = "Index", id = "" }, new { controller = new NotEqual("Admin") } );
This route won’t match any request when the controller parameter gets the value Admin. For example, this route won’t match the URLs /Admin/DeleteAll or /Admin/Index.
Creating a Local Constraint
You also can create a custom constraint that prevents a request from matching a URL unless the request is made from the local machine. This type of constraint can be useful for restricting access to website administrative pages.
Listing 3 contains the code for the LocalConstraint class.
Listing 3 – LocalConstaint.cs
using System.Web; using System.Web.Routing; public class LocalConstraint : IRouteConstraint { public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection) { return httpContext.Request.IsLocal; } }
The LocalConstraint simply checks whether the current request is a local request by taking advantage of the Request.IsLocal property. This property returns the value True when the host is either equal to localhost or 127.0.0.1.
Testing Route Constraints
So how do you test route constraints? Easy, fake the HttpContext. The test in Listing 4 can be used to verify that the Product route includes an HttpMethod constraint.
Listing 4 – A Unit Test for the HttpMethod Constraint
[TestMethod] public void TestInsertIsPost() { // Arrange var routes = new RouteCollection(); GlobalApplication.RegisterRoutes(routes); // Act with POST request var fakeContext1 = new FakeHttpContext("~/Product/Insert", "POST"); var routeData1 = routes.GetRouteData(fakeContext1); // Assert NamedRoute matchedRoute1 = (NamedRoute)routeData1.Route; Assert.AreEqual("Product", matchedRoute1.Name); // Act with GET request var fakeContext2 = new FakeHttpContext("~/Product/Insert"); var routeData2 = routes.GetRouteData(fakeContext2); // Assert NamedRoute matchedRoute2 = (NamedRoute)routeData2.Route; Assert.AreNotEqual("Product", matchedRoute2.Name); }
The unit test in Listing 4 consists of two tests. First, the URL /Product/Insert is requested by performing a POST operation. The Product route should be matched in the route table. Next, the same URL is requested while performing a GET operation. The Product route should not be matched when performing a GET.
The unit test in Listing 5 demonstrates how you can test the AuthenticatedConstraint.
Listing 5 – Unit Test for AuthenticatedConstraint
[TestMethod] public void TestAdminRouteIsAuthenticated() { // Arrange var routes = new RouteCollection(); GlobalApplication.RegisterRoutes(routes); // Act with authenticated request var fakeUser = new FakePrincipal(new FakeIdentity("Bob"), null); var fakeContext1 = new FakeHttpContext(new Uri("http://localhost/Admin/Index"), "~/Admin/Index", fakeUser); var routeData1 = routes.GetRouteData(fakeContext1); // Assert NamedRoute matchedRoute1 = (NamedRoute)routeData1.Route; Assert.AreEqual("Admin", matchedRoute1.Name); // Act with anonymous request var fakeContext2 = new FakeHttpContext(new Uri("http://localhost/Admin/Index"), "~/Admin/Index"); var routeData2 = routes.GetRouteData(fakeContext2); // Assert Assert.IsNull(routeData2); }
This unit test also consists of two tests. First, a fake user is created with the help of the FakeIdentity class. When the /Admin/Index URL is requested with the fake identity in context, the Admin route should be matched. When the same URL is requested anonymously, on the other hand, no route should be matched.
Summary
In this tip, you learned how to create custom route constraints. We created three custom route constraints: the AuthenticatedConstraint, the NotEqualConstraint, and the LocalConstraint. I also showed you how you can build unit tests for routes that include custom constraints.