When the ASP.NET MVC framework was first released, two open source projects sprung up immediately: MVCContrib and Code Camp Server. I quickly jumped on Code Camp Server as a lead contributor because I wanted to get some hands on experience on the ASP.NET MVC Framework.
In the first example you see when you install the new MVC templates, you see that the default routing rules are defined like this:
{controller}/{action}/{id}
This works well for a lot of applications, where you'd end up with URLs like:
/customers/list/customers/delete/12/products/show/5/products/edit/5
It's quite easy to see how each of those map to the components of the route. In Code Camp Server, we wanted the system to support many code camps, so your URL will look like this:
/austinCodeCamp/details/houstonTechFest/directions
So we ended up with a default route that looked like this:
routes.MapRoute("conference", "{conferenceKey}/{action}", new { controller="conference", action="details });
Here we omitted the controller as it was implied. Everything was placed on ConferenceController, and it worked pretty well to start out. Then we started adding actions like Sessions, and ListAttendees, and started to notice that those things probably belong on their own controllers.
But we had a problem... if I have a URL like this:
/houstonTechFest/sessions
this will translate to an action called "sessions" on the conference controller. That's not what we want. So we started to hard code these specific instances like this....
routes.MapRoute("speakers", "{conferenceKey}/speakers/{action}", new {controller = "speaker", action = "list"});routes.MapRoute("schedule", "{conferenceKey}/schedule/{action}", new {controller = "schedule", action = "index"});routes.MapRoute("sessions", "{conferenceKey}/sessions/{action}", new {controller = "session", action = "list"});routes.MapRoute("sponsors", "{conferenceKey}/sponsors/{action}", new {controller = "sponsor", action = "list"});
YUCK. Things are starting to get really hairy now.
So, after a phone call with Jeffrey Palermo, he raised the question:
"Why even have the default point to conference controller? The only common action on that is Details. All of the rest are separate controllers."
And then it clicked. We can now change the route definition to this:
routes.MapRoute("standard", "{conferenceKey}/{controller}/{action}/{id}", new { controller="conference", action="index", id=(string)null});
Which produces these very acceptable URLs:
there are a few extra cases where we don't want to start with a conference key (for example, /conference/list, /conference/current, /login, /admin, etc...). These URLs all work with the standard route, so we can define that just below our first route.
routes.MapRoute("conference", "{controller}/{action}/{id}", new {controller="conference", action="list"});
Now the URL /conference/list (and the others) will get routed properly with this definition.
Now there's just one final problem. Can you see it? If we define the other route first, then the first token of the route will get picked up as a conference key! We certainly don't want that. So we'll add a constraint to the first route to match everything except the controllers we want to address specifically. Our route now looks like...
routes.MapRoute("confkey", "{conferenceKey}/{controller}/{action}/{id}", new { controller="conference", action="index", id=(string)null }, new { conferenceKey="(?!conference|admin|login).*"}); routes.MapRoute("standard", "{controller}/{action}/{id}", new { controller="conference", action="index", id=(string)null });
The first route will grab everything except those URLs that start with "conference", "admin", or "login." There might be other controllers, but this is all we need for now. Those URLS get picked up by the 2nd route and everything works fine after that.
Coming up with a clear set of routes that don't conflict with each other is very difficult once you stray away from the norm. The more route definitions you have, the more care you have to take to ensure that the new rule doesn't break a whole slew of existing URLs. This is critically important if you already have an application in production. A simple change in the Global.asax file can change all of the URL's for your application! That's like paraquat for Google Juice.
Unit tests can surely help here. For these examples, I followed a simple technique for testing my routes. First is the method to fake out the actual request:
private static RouteData getMatchingRouteData(string appRelativeUrl) { RouteTable.Routes.Clear(); var configurator = new RouteConfigurator(); configurator.RegisterRoutes(); RouteData routeData; var mocks = new MockRepository(); var httpContext = mocks.DynamicMock<HttpContextBase>(); var request = mocks.DynamicMock<HttpRequestBase>(); using (mocks.Record()) { SetupResult.For(httpContext.Request).Return(request); mocks.Replay(httpContext); SetupResult.For(httpContext.Request.AppRelativeCurrentExecutionFilePath) .Return(appRelativeUrl); SetupResult.For(httpContext.Request.PathInfo) .Return(string.Empty); } using (mocks.Playback()) { routeData = RouteTable.Routes.GetRouteData(httpContext); } return routeData; }
Now we can have a simple helper method for asserting that are routes produce the right tokens:
private void AssertRoute(string virtualPath, string expectedController, string expectedAction, IDictionary<string,string> expectedTokens) { var routeData = getMatchingRouteData(virtualPath); Assert.That(routeData.GetRequiredString("controller"), Is.EqualTo(expectedController)); Assert.That(routeData.GetRequiredString("action"), Is.EqualTo(expectedAction)); foreach (var pair in expectedTokens) { Assert.That(routeData.GetRequiredString(pair.Key), Is.EqualTo(pair.Value)); } }
And finally the tests that I'm running to ensure these routes are working properly...
[Test] public void TestSiteRoutes() { AssertRoute("~/austinCodeCamp2008", "conference", "index", new Dictionary<string, string> {{"conferenceKey", "austinCodeCamp2008"}}); AssertRoute("~/login", "login", "index"); AssertRoute("~/conference/new", "conference", "new"); AssertRoute("~/conference/current", "conference", "current"); AssertRoute("~/admin", "admin", "index"); AssertRoute("~/houstonTechFest/sessions/add", "sessions", "add", new Dictionary<string,string> {{"conferenceKey", "houstonTechFest"}}); }
This is only half of the story though. You should also test that the routes you ask for (like with Html.ActionLink and Url.Action) produce the intended URLs. Otherwise you may have URLs that match routes, but routes that don't match your intended URLs.
Testing these is a bit harder, and it's 1am now... so I'll take the ultimate cop-out and leave it as an exercise to the reader.
I'm Ben Scheirman. I am a .NET software developer with a strong interest in agility. I work as a Principal Consultant with Sogeti.
Read more here.
email me
Ads by The Lounge
Disclaimer The opinions expressed herein are my own personal opinions and do not represent my employer's view in anyway.