Redirecting Routes To Maintain Persistent URLs
Over a decade ago, Tim Berners-Lee, creator of the World Wide Web instructed the world know that cool URIs don’t change with what appears to be a poem, but it doesn’t rhyme and it’s not haiku.
What makes a cool URI?
A cool URI is one which does not change.
What sorts of URI change?
URIs don’t change: people change them.
In a related article, URL as UI, usability expert Jakob Nielsen lists the following criteria for a usable site:
- a domain name that is easy to remember and easy to spell
- short URLs
- easy-to-type URLs
- URLs that visualize the site structure
- URLs that are “hackable” to allow users to move to higher levels of the information architecture by hacking off the end of the URL
- persistent URLs that don’t change
The permanence of URLs is a fundamental trait of the web that seems to
run counter to one of the benefits of using a feature like ASP.NET
Routing. For example, one benefit of routing is you can change a route
from {controller}/{action}/{id}
to {controller}/{id}/{action}
and
have every URL in your site corresponding to that route automatically be
updated.
This is very nice during development when you’re still fleshing out your URLs and haven’t committed to anything, but once you’ve published your site, changing a route URL violates the sacred trait of URL permanence.
This is exactly where I find myself with Subtext. All of our existing URLs end with the .aspx extension, a practice which Jon Udell convincingly argued is harmful. In the upcoming version of Subtext, we’re moving to extensionless URLs by building upon the great support built into ASP.NET 4 and Routing.
I could simply change our routes to remove the .aspx extension, but that would break nearly every existing URL in every blog running on Subtext. So much for URL permanence, right?
There’s a Better Way
Rather than changing routes, what I really want is a way to simply redirect the existing route to a new route. This is pretty easy, but there are a few caveats to keep in mind that make it non-trivial.
- Since you don’t want to generate URLs for the old route, the legacy route should never be selected for URL generation. It’s only for matching incoming requests.
- The legacy route should be registered after the new URL to ensure it doesn’t accidentally match and supersede the new URL.
I wrote a library that provides a RedirectRoute
and a simple extension
method for registering a RedirectRoute
that satisfies these
conditions. Let’s look at an example of how it would be used.
Let’s suppose we have the following route defined and the site has been published to the web..
routes.MapRoute("old", "foo/{controller}/{action}/{id}");
But later, we decide we want all such URLs to start with /bar instead and we want to re-order the id and action segments of the URL.
Here’s an example of how we can do that using this new library.
var route = routes.MapRoute("new", "bar/{controller}/{id}/{action}");
routes.Redirect(r => r.MapRoute("old", "foo/{controller}/{action}/{id}")) .To(route);
This snippet registers the new route and passes that route to the
RedirectRoute
that was returned by a call to the Redirect
extension
method. The RedirectRoute
delegates to the old route to match incoming
requests. With this in place, every request matching the old route will
be redirected to the new route.
Thus a request for /foo/home/index/123 will be redirected to /bar/home/123/index.
Why The Lambda Expression?
To fully understand what’s going on under the hood, I need to explain why the API takes in a lambda expression rather than simply taking in two routes, old route and new route.
Let’s suppose that the API did just that, simply accepted two routes. Here’s what a naïve attempt to use the method might look like.
var new = routes.MapRoute("new", "bar/{controller}/{id}/{action}");
var old = routes.MapRoute("new", "foo/{controller}/{action}/{id}");
routes.Redirect(old).To(new);
Hopefully it’s immediately apparent why this is not good. The old route is mapped before the redirect route. So the redirect route will never be matched.
The MapRoute
extension method not only creates a route, but it adds it
to the route collection. So we could have manually created the route,
but that’s a pain if you’re already using the MapRoute
method to
create the route. Or, we could have done this:
var new = routes.MapRoute("new", "bar/{controller}/{id}/{action}");
var throwAway = new RouteCollection();
var old = throwAway.MapRoute("new", "foo/{controller}/{action}/{id}");
routes.Redirect(old).To(new);
Requiring the user of the API to create a throwaway route collection is
ugly when the API itself could do it for you. Hence the lambda
expression argument to Redirect
. Internally, the method creates a
throwaway route collection and calls the expression against that instead
of against the main route collection.
Implementation Details
I won’t post the full source here, but the implementation details are
pretty simple. Here’s the implementation of GetRouteData
which is the
method called when matching incoming requests.
public override RouteData GetRouteData(HttpContextBase httpContext) {
// Use the original route to match
var routeData = SourceRoute.GetRouteData(httpContext);
if (routeData == null) {
return null;
}
// But swap its route handler with our own
routeData.RouteHandler = this;
return routeData;
}
Notice that I use the source route, which is the old route passed into
the redirect route, to match the request, but I swap the route handler
with the redirect route. RedirectRoute
also implements
IRouteHandler
. It was a little implementation shortcut I took which
happens to work fine in this case.
The implementation of GetVirtualPath
is even simpler.
public override VirtualPathData GetVirtualPath(RequestContext requestContext , RouteValueDictionary values) {
// Redirect routes never generate an URL.
return null;
}
We never want to generate a URL to the old route, so this method always returns null.
As mentioned, RedirectRoute
implements IRouteHandler
, so we should
look at its implementation.
public IHttpHandler GetHttpHandler(RequestContext requestContext) {
var requestRouteValues = requestContext.RouteData.Values;
var routeValues = AdditionalRouteValues.Merge(requestRouteValues);
var vpd = TargetRoute.GetVirtualPath(requestContext, routeValues);
string targetUrl = null;
if (vpd != null) {
targetUrl = "~/" + vpd.VirtualPath;
return new RedirectHttpHandler(targetUrl, Permanent, isReusable: false);
}
return new DelegateHttpHandler( httpContext => httpContext.Response.StatusCode = 404, false);
}
Notice that we make use of the DelegateHttpHandler
which is something
I wrote about a while
ago.
Where to get it?
All the code I showed here is now part of the RouteMagic library I blogged about recently. I’ve updated the package so all you need to do is Install-Package RouteMagicwithin NuGet.
Comments
35 responses