Ben Scheirman

These fickle bits...

Menu

A Routing Evolution

216624fork-in-road-posters 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:

1
{controller}/{action}/{id}

This works well for a lot of applications, where you’d end up with URLs like:

1
/customers/list<br>/customers/delete/12<br>/products/show/5<br>/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:

1
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:

1
/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….

1
routes.MapRoute("speakers", "{conferenceKey}/speakers/{action}", new {controller = "speaker", action = "list"});<br>routes.MapRoute("schedule", "{conferenceKey}/schedule/{action}", new {controller = "schedule", action = "index"});<br>routes.MapRoute("sessions", "{conferenceKey}/sessions/{action}", new {controller = "session", action = "list"});<br>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:

1
routes.MapRoute("standard", "{conferenceKey}/{controller}/{action}/{id}", new { controller="conference", action="index", id=(string)null});

Which produces these very acceptable URLs:

URLControllerActionId
/houstonTechFestConferenceIndex 
/houstonTechFest/sessionsSessionsIndex 
/houstonTechFest/sessions/addSessionsAdd 
/houstonTechFest/sponsors/edit/5SponsorsEdit5

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.

1
routes.MapRoute("conference", "{controller}/{action}/{id}",<br>                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…

1
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.

Testing the Routes

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:

1
<span class="kwrd">private</span> <span class="kwrd">static</span> RouteData getMatchingRouteData(<span class="kwrd">string</span> appRelativeUrl){RouteTable.Routes.Clear();var configurator = <span class="kwrd">new</span> RouteConfigurator();configurator.RegisterRoutes();RouteData routeData;var mocks = <span class="kwrd">new</span> MockRepository(); var httpContext = mocks.DynamicMock<HttpContextBase>();var request = mocks.DynamicMock<HttpRequestBase>();<span class="kwrd">using</span> (mocks.Record()){SetupResult.For(httpContext.Request).Return(request);mocks.Replay(httpContext);SetupResult.For(httpContext.Request.AppRelativeCurrentExecutionFilePath).Return(appRelativeUrl);SetupResult.For(httpContext.Request.PathInfo).Return(<span class="kwrd">string</span>.Empty);}<span class="kwrd">using</span> (mocks.Playback()){routeData = RouteTable.Routes.GetRouteData(httpContext);}<span class="kwrd">return</span> routeData;}

Now we can have a simple helper method for asserting that are routes produce the right tokens:

1
2
<span class="kwrd">private</span> <span class="kwrd">void</span> AssertRoute(<span class="kwrd">string</span> virtualPath, <span class="kwrd">string</span> expectedController, <span class="kwrd">string</span> expectedAction, IDictionary<<span class="kwrd">string</span>,<span class="kwrd">string</span>> expectedTokens){var routeData = get
MatchingRouteData(virtualPath);Assert.That(routeData.GetRequiredString(<span class="str">"controller"</span>), Is.EqualTo(expectedController));Assert.That(routeData.GetRequiredString(<span class="str">"action"</span>), Is.EqualTo(expectedAction));<span class="kwrd">foreach</span> (var pair <span class="kwrd">in</span> 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…

1
[Test]<span class="kwrd">public</span> <span class="kwrd">void</span> TestSiteRoutes(){AssertRoute(<span class="str">"~/austinCodeCamp2008"</span>, <span class="str">"conference"</span>, <span class="str">"index"</span>,<span class="kwrd">new</span> Dictionary<<span class="kwrd">string</span>, <span class="kwrd">string</span>> );AssertRoute(<span class="str">"~/login"</span>, <span class="str">"login"</span>, <span class="str">"index"</span>);AssertRoute(<span class="str">"~/conference/new"</span>, <span class="str">"conference"</span>, <span class="str">"new"</span>);AssertRoute(<span class="str">"~/conference/current"</span>, <span class="str">"conference"</span>, <span class="str">"current"</span>);AssertRoute(<span class="str">"~/admin"</span>, <span class="str">"admin"</span>, <span class="str">"index"</span>);AssertRoute(<span class="str">"~/houstonTechFest/sessions/add"</span>, <span class="str">"sessions"</span>, <span class="str">"add"</span>,<span class="kwrd">new</span> Dictionary<<span class="kwrd">string</span>,<span class="kwrd">string</span>> );}

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.

Comments