Tuesday, 1 November 2011

Route Constraints: Working with conflicting routes in MVC

Routing is one of the things in the MVC framework that seems to just 'get me' every once in a while. Routing isn't that hard - it's pretty straight forward stuff. Routing is something you should, ideally, set up once - and then forget about. In order to do this, though, you need to have a plan up front - and you don't always have that (personally I think it's best if you've got a site map and a URL schema worked out before you start). If you add routes as you need them you can end up with more routes than you need and route conflicts that you didn't foresee.

This isn't intended as a post about MVC routing in general. Rather I wanted to post about a little piece of work I had to do in order to make use of two routes that conflict with one another. I think these routes highlight the most common problem people have when they're getting to grips with MVC routes; your URL is matched by the wrong route entry.

I like to keep things simple, and I don't like making extra work for myself. As such I like the default route that any Visual Studio MVC project comes set up with:
routes.MapRoute(string.Empty, "{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional });

This route entry is simple to understand and you can build large applications based entirely on this single route entry. Of course, you may want to add some "pretty" routes as well such as "/login" or "/signup" - but that single route is really all you need. That is, until you decide that not all your URLs should be on the form "{controller}/{action}/{id}."

In my case, I also needed a route like this:
routes.MapRoute(string.Empty, "{controller}/{id}",
new { controller = "Clubs", action = "Index", id = UrlParameter.Optional });

This second route is important to my application because it's used to display certain entities by their unique identifier. Such entities can be a member of the site, a club, or a store. Example URLs are:

http://mywebsite.com/members/oyvind
http://mywebsite.com/stores/oyvindsmegamart
http://mywebsite.com/clubs/oyvindsgreatbigfanclub

As you can tell from the example URLs, several controllers can be mapped by this route and, if they are, the route should always map to the "index" action method and pass in the "id" parameter. But, if you add these routes to your application, you'll run into trouble because they conflict with each other. Let's look at why.

An incoming URL will be matched by one route and one route only. Routes are examined one by one and the first match is the one that will be used. This means that the order in which we add routes is important. We always want our most specific routes to be listed first. Let's apply this principle to our two routes:
routes.MapRoute(string.Empty, "{controller}/{id}",
new { controller = "Clubs", action = "Index", id = UrlParameter.Optional });
routes.MapRoute(string.Empty, "{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional });

Here I've added the "{controller}/{id}" route first because I feel this is the more specific of the two routes. Now let's look at a sample URL:

http://mywebsite.com/members/oyvind

If we break this URL down you'll see that the "/members" section of the URL will map to the "{controller}" portion of both routes. Also, the "/oyvind" section of the URL will map to the "{id}" portion of the first route. Happy days! We have a match! It looks like our two route entries might work after all.

But not so fast. What about this URL?

http://mywebsite.com/account/changepassword

This URL should look familiar to you as it's more of a traditional MVC route; in fact, it's a classic default route for any standard MVC project. But will this work with our two route entries? The "/account" section of the URL will map to the "{controller}" portion of both routes. The "/changepassword" section of the URL will map to both the "{id}" portion of the first route, and the "{action}" portion of the second route. However, because the first match wins, the first route is chosen and the request will end up being directed to the Index action method on the AccountController class, with an id parameter of "changepassword"... this isn't what we intended.

What happens if we change the order of the routes?
routes.MapRoute(string.Empty, "{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional });
routes.MapRoute(string.Empty, "{controller}/{id}",
new { controller = "Clubs", action = "Index", id = UrlParameter.Optional });

Let's look at the last URL from the previous example first:

http://mywebsite.com/account/changepassword

The "/account" section of the URL will map to the "{controller}" portion of both routes. The "/changepassword" section of the URL will map to both the "{action}" portion of the first route, and the "{id}" portion of the second route. In this case our first route will be selected - and the request will be directed to the ChangePassword action method on the AccountController (with an empty id). This is the desired result for this URL. But what about this URL?

http://mywebsite.com/members/oyvind

The "/members" section of the URL will map to the "{controller}" portion of both routes. The "/oyvind" sectin of the URL will map to the "{action}" portion of the first route, and the "{id}" portion of the second route. Because of the order of precedence, the first route will be selected and our request will be directed to the Oyvind action on the MembersController, with an empty id. Most likely we'll end up with a "404 - Not Found" because I doubt very much you'll have an action called Oyvind on any of your controllers.

I need both routes to work, but they clearly conflict with each other and changing the order of the routes doesn't actually help. What can I do? Somehow I need to help the MVC framework understand when to pick one route over the other. Thankfully there's a built-in mechanism we can leverage to help us: the route constraint.

When you add a route to the route table you can specify that this route has certain constraints. A constraint applies to a portion of the route (for example the "{id}" portion) and can set out that this portion has to match certain values, be of a certain format, or exclude specific values. When defining routes you pass the constraints as a third parameter to the MapRoute method:
routes.MapRoute(string.Empty, "{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional },
new { action = "MySpecialAction"});

In this contrived example I've specified a route using the standard/default route pattern, but I've specified a constraint for the "{action}" portion of the route. The constraint states that unless "{action}" equals "MySpecialAction" the route will not be matched. This route constraint is actually a regular expression constraint, so you if you want to allow "{action}" to include not only "MySpecialAction" but also "YourSpecialAction" you can alter the route entry as follows:
routes.MapRoute(string.Empty, "{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional },
new { action = "MySpecialAction|YourSpecialAction"});

You can read more about route constraints here (http://www.asp.net/mvc/tutorials/creating-a-route-constraint-cs) as I'm not going to dwell on the specifics here. Rather, I want to get on with the problem at hand. Let's break it down a little:
  • I want to use the default route "{controller}/{action}/{id}" as much as possible. This is the route I want to base my whole site on.
  • In some special cases I want the route "{controller}/{id}" to take precedence. At the moment I know that I want this route to take precedence for the MembersController, the ShopsController, and the ClubsController.
  • I want to be able to define action methods other than Index on the MembersController, ShopsController, and ClubsController - and I want these actions to be matched by the default route.
The problem we encountered with the routes in their raw, unconstrained, form is that the "{id}" portion of a route will happily match the "{action}" portion of the other route, and vice versa. Since we add the most specific route first
routes.MapRoute(string.Empty, "{controller}/{id}",
new { controller = "Clubs", action = "Index", id = UrlParameter.Optional });

we need to make sure that the "{id}" parameter does not match any action methods on the controller. Also, we don't want this route to apply to all controllers, so we need to constrain the "{controller}" portion of the route to the desired controllers. Let's start with the controller constraint first. We want this route to only apply to the ClubsController, ShopsController, and MembersController:
routes.MapRoute(string.Empty, "{controller}/{id}",
new { controller = "Clubs", action = "Index", id = UrlParameter.Optional },
new { controller = "Clubs|Members|Shops"});

That's all we need to do.

The other constraint, however, is a little bit more involved. The route should not be matched if the "{id}" portion of the URL matches any of the action methods on any of the controllers that this route applies to. We can do this by applying another regex constraint which contains the names of all the action methods on these controllers:
routes.MapRoute(string.Empty, "{controller}/{id}",
new { controller = "Clubs", action = "Index", id = UrlParameter.Optional },
new
{
controller = "Clubs|Members|Shops",
id = "ClubMembers|Photos|News|Deals|Staff|Wall|About|OpeningHours|Info|Friends"
});

The above example is contrived - but it attempts to highlight a problem with the approach. Not only will this list of action names grow very quickly, you will also have to remember to add action names to this list whenever you add an action on any of the controllers (or modify the list if you change the name of any of the action methods). This approach will work, but it's not a very maintainable solution.

A better approach would be to use a custom route constraint. A custom route constraint is a class which implements the IRouteConstraint interface. This interface defines a method called Match:
bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection);

The job of the custom route constraint is to decide if a given route parameter is valid for the given route. We want to check a single parameter, "id" against a potentially large list of values. To this end, I've created a ValuesConstraint class:
public class ValuesConstraint : IRouteConstraint
{
private readonly bool _include;
private readonly string[] _values;

public ValuesConstraint(params string[] values) : this(true, values){}
public ValuesConstraint(bool include, params string[] values)
{
_include = include;
_values = values;
}

public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
return _include && (_values.Contains(values[parameterName].ToString(), StringComparer.InvariantCultureIgnoreCase));
}
}

The ValuesConstraint class is instantiated by passing in the list of values, and an an optional flag which indicates if the route parameter should be a match or not be a match for these values. It can be used in the following fashion:
routes.MapRoute(string.Empty, "{controller}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional },
new
{
controller = "Clubs|Members|Shops",
id = new ValuesConstraint(false, "ClubMembers", "Photos", "News", "Deals", "Staff", "Wall", "About", "OpeningHours", "Info", "Friends")
});

While the above would work it doesn't actually solve the problem of maintainability, because we're still hard-codign a list of strings representing the action methods on our controllers. So, the final piece of the puzzle is to create a method that outputs a list of all the action methods on controllers of our choosing:
private string[] GetActionNames(params Type[] controllers)
{
var actionNames = new List();
foreach(Type controllerType in controllers)
{
MethodInfo[] methodInfos = controllerType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
foreach(var method in methodInfos)
actionNames.Add(method.Name);
}

return actionNames.ToArray();
}

And then we can use it like this:
var controllerActions = GetActionNames(typeof(ClubsController), typeof(MembersController), typeof(ShopsController));
routes.MapRoute(string.Empty, "{controller}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional },
new
{
controller = "Clubs|Members|Shops",
id = new ValuesConstraint(false, controllerActions)
});

Now our "{controller}/{id}" will only be matched by URLs where "{controller}" equals "Clubs", "Members", or "Shops" and "{id}" does not equal any action method name on any of these controllers. Any URL that does not match this route will then default to our, uhm, default route. Ta-dah!

2 comments:

Anonymous said...

great post!!

for IT the said...

I have read your blog its very attractive and impressive. I like it your blog.

ASP.NET MVC Training in Chennai


ASP.NET MVC Online Training | Online LINQ Training