Wednesday, July 30, 2008

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:

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

URL Controller Action Id
/houstonTechFest Conference Index  
/houstonTechFest/sessions Sessions Index  
/houstonTechFest/sessions/add Sessions Add  
/houstonTechFest/sponsors/edit/5 Sponsors Edit 5

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.

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:

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.

Thursday, July 31, 2008 2:04:53 AM (Central Standard Time, UTC-06:00)
Interesting stuff! When you have more than a few route entries, it really does get difficult to figure out the correct ordering and combination of constraints/defaults so that both inbound and outbound routing works properly in all cases. Things like negative backreferences help, but it does start to feel like some kind of logic puzzle.

I wonder what the solution is... Should we force ourselves to stick to very simple routing configs? Should we use named routes all the time? Clearly having a solid set of tests for inbound/outbound routing helps, as does things like Phil Haack's routing debugger, but you still have to solve a combinatoric math test.
Thursday, July 31, 2008 7:50:07 AM (Central Standard Time, UTC-06:00)
I think the solution is defining what URLs you want, and creating a suite of tests against each different form. Not only testing from the URL to a route, but from route values back to a URL.

I'm going to push some of the route testing stuff into MVCContrib so that we can more easily test routes.

Also I think named routes are a bad idea altogether. The view shouldn't have to know which format it wants to render. This seems like a hackish workaround to getting your URLs to look clean without spending some effort on your route definitions. Maybe there's a scenario where this works well, but so far I haven't seen it.
Comments are closed.