Redirecting Routes To Maintain Persistent URLs

asp.net, nuget, open source 0 comments suggest edit

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.

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

Found a typo or error? Suggest an edit! If accepted, your contribution is listed automatically here.

Comments

avatar

35 responses

  1. Avatar for Richard McCutchen
    Richard McCutchen February 2nd, 2011

    Very nice indeed Phil. Just wanted to let you know that since I took the leap-of-faith dive into ASP.NET MVC I have learned so much from your blog, and this one on redirecting to maintain persistent route is, well in my opinion it's sheer genius.
    I'll definitely be downloading the source for RouteMagic. I suppose RouteMagic is compiled to work with MVC 3 isn't it? If so would you mind if I made some changes to make it work with MVC 2. I'm kind of stuck working primarily with MVC 2 (Client didn't want his application built with technologies that were in 'beta', no matter who the author/company is).
    Kudos to you and all you give back to the .NET world, it's people like you who will ensure the .NET Community stays fresh, up-to-date and ever growing, in the right routes.

  2. Avatar for Richard McCutchen
    Richard McCutchen February 2nd, 2011

    Sorry Phil, one more question please. Since there are many of us who are in the early stages of getting a firm grasp on MVC, would it be possible for you to write a series on MVC 2, something like from beginning to intermediate then intermediate to advanced.
    Covering such topics and data binding (and complex binding), ViewModels versus Models, IoC utilizing StructureMap (that's the IoC container I prefer actually). And maybe topics on using the generic repository pattern coupled with the unit of work pattern (or other n-tier styles, even something like the Specification pattern (I have implementation of all 3 but would like to see your views on them)
    I know the writing you do takes a lot of work, and I could completely understand if you wanted to decline, especially with MVC 3 and Razor Engine being so popular so fast. So no hard feelings (none and I mean it)if it's nothing something that interests you (or you just have so much on your plate and aren't sure you could finish what you started. But remember one thing, I'd be more than willing to help out and I'm sure I could fine at least 1 or 2 more to help as well.
    Let me know richard(dot)mccutchen(at)psychocoder(dot) net. I look forward to hearing from you ;)
    Thanks,
    Richard :)
    http://blog.psychocoder.net

  3. Avatar for Pierrick
    Pierrick February 2nd, 2011

    Great post Phil! This could become really handy when updating web apps :)
    One thing though on the following
    var new = routes.MapRoute("new", "bar/{controller}/{id}/{action}");
    var old = routes.MapRoute("new", "foo/{controller}/{action}/{id}");
    routes.Redirect(old).To(new);
    Did you mean in the 2nd route?
    routes.MapRoute("old", "foo/{controller}/{action}/{id}");
    Thanks!

  4. Avatar for Maarten Balliauw
    Maarten Balliauw February 2nd, 2011

    Great feature, Phil! Any chance to get my localized route and domain route feature in (the pull request is waiting for you at CodePlex).

  5. Avatar for Colin Farr
    Colin Farr February 2nd, 2011

    @Pierrick, you are worrying about the wrong example. The lambda expression is the one that you want. The one you have was the example that wont work.
    Also, no he didn't mean the 2nd route to be what you suggested. It was an example of a changing URL.

  6. Avatar for Pierrick
    Pierrick February 2nd, 2011

    @Colin Farr I see thanks

  7. Avatar for Russ
    Russ February 2nd, 2011

    Hmmmm... shouldn't you be 301'ing these instead of redirecting?

  8. Avatar for Jon Udell
    Jon Udell February 2nd, 2011

    Very nice, Phil. This will certainly help the cause. Thanks!

  9. Avatar for Andrey Shchekin
    Andrey Shchekin February 2nd, 2011

    Great stuff that leaves me even more confused about http://nuget.org/Packages/Packages/Details/{whatever} URLs that Jakob Nielsen would probably dislike with passion.

  10. Avatar for Paul
    Paul February 3rd, 2011

    Great article! I am 100% with Russ though, you really should be sending a 301 not a 404. This tell a search engine that the content from the old page is still pertinet/valid, just moved. A 404 is going to tell the search engine that the page is gone and that you want it to redirect to your NEW page. That is bad because NEW pages without 301's are just that, new and have no credit in the eyes of search engines. Just my opinion, but def going to use this code in a project I am working on.

  11. Avatar for haacked
    haacked February 4th, 2011

    @Paul @Russ I think you misinterpreted the implementation. Notice that if the target route matches, it returns a RedirectHttpHandler which redirects to the target route. If it doesn't match, then we return a 404.
    I think that's exactly what you want. After all, if the target page really isn't there, it should be a 404. If it is there, then you get redirected with the appropriate redirect status code.

  12. Avatar for haacked
    haacked February 4th, 2011

    @andrey I agree and I hope to get that improved in the future. :)

  13. Avatar for haacked
    haacked February 4th, 2011

    @Richard All the writing on ASP.NET MVC 2 I want to do is in the book Professional ASP.NET MVC 2 or on my blog already :) Also check out Brad Wilson's blog.
    I've also heard that Steve Sanderson's book is really good too. He's now on the ASP.NET team as well.

  14. Avatar for Andy West
    Andy West February 5th, 2011

    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.


    This implies that all URIs are cool. I'm not sure I agree with that. ;)

  15. Avatar for Stuart
    Stuart February 6th, 2011

    Hi Phil
    Firstly - thanks for the blog. I get a lot of great samples and ideas here.
    I'm actually struggling with what I believe should be a simple routing scenario... the details are over on the .net forums:
    forums.asp.net/p/1650496/4291223.aspx#4291223
    If you have a little time, take a look as I'd be delighted to hear how the development team intend for such scenarios to be handled!
    All the best, Stuart

  16. Avatar for Solmead
    Solmead February 7th, 2011

    What if I have a url:
    /ImNew/ContactUs
    And it uses the default route. "{controller}/{acton}/{id}"
    I want to redirect /ImNew/ContactUs to /ImNew/Locations
    But if I put the redirect after the default rule the redirect never gets hit.
    Is it possible to do this?

  17. Avatar for haacked
    haacked February 9th, 2011

    @Solmead I would create a specific route for /lmnew/contactus to redirect to the new url and put it *before* the default route.

  18. Avatar for Claire
    Claire February 15th, 2011

    This is a great solution, but I really wish ASP.NET had a version of apache mod_rewrite--it's so much easier!

  19. Avatar for haacked
    haacked February 15th, 2011

    @Claire, we do! Check out the IIS Url Rewriter Module learn.iis.net/.../using-the-url-rewrite-module/

  20. Avatar for Rex
    Rex April 27th, 2011


    I'm sure I'm misunderstanding something fundamental here, but I'm moving an MVC3 app from IIS6 to IIS7 and want to redirect all .mvc routes to their extension-free counterparts.
    I downloaded the RouteMagic source and in the web demo project placed only the new route and a redirect from the old route in RegisterRoutes:

    var defaultRoute = routes.MapRoute(
    "Default",
    "{controller}/{action}",
    new { controller = "Home", action = "Index" }
    ).SetRouteName("Default");
    routes.Redirect(r => r.Map("OldDefault",
    "{controller}.mvc/{action}",
    new { controller = "Home", action = "Index" }))
    .To(defaultRoute);

    When I fire up the example with RouteDebugger attached the url /home/index works as expected, but for /home.mvc/index RouteDebugger shows it matching the "{controller}/{action}" route, not "{controller}.mvc/{action}" and the value for the controller token is "home.mvc" instead of just "home", which doesn't map to an existing controller (there's a HomeController.cs, not Home.MvcController.cs).
    I'm thinking I'm trying to do something that's not possible here but I can't put my finger on it. Sorry for the long post...

  21. Avatar for haacked
    haacked April 27th, 2011

    Routing matches routes in order. {controller}/{action} will match any 2 segment route. Well, /home.mvc/index is a 2 segment URL, so it is going to match.
    The fix should be as simple as re-ordering the two routes and putting the {controller}.mvc/{action} route first.

  22. Avatar for Rex
    Rex April 27th, 2011

    Thanks Phil. I'm pretty sure I understand. Putting the {controller}.mvc/{action} route first works, but that redirects home/index to home.mvc/index and I was hoping for a way to do the inverse. That is for all the static links out in the wild (e.g. users' bookmarks, old reports, links in e-mail) that point to home.mvc/index, have them redirect to home/index. It sounds like since any two-segment url will match {controller}/{action} it won't work using redirect. No biggie... we'll just have to live with the .mvc extension for a while longer.
    Thanks for RouteDebugger, RouteMagic, all your work on MVC and NuGet, and other contributions to the Web Stack of Love, and of course your blog. They help to make work fun.

  23. Avatar for haacked
    haacked April 28th, 2011

    @Rex Ah! My mistake. Here's another easy approach. Add a constraint to the default route to not allow matching the dot character.
    var defaultRoute = routes.MapRoute(
    "Default",
    "{controller}/{action}",
    new { controller = "Home", action = "Index" },
    new {controller = "[^\.]+"}
    ).SetRouteName("Default");
    That way it won't match anything with a . character.

  24. Avatar for Kenneth
    Kenneth January 27th, 2012

    I'm having some trouble using the redirect route. I keep getting the error "A potentially dangerous Request.Path value was detected from the client (:)." and the redirect doesn't work :(
    I think I'm doing everything correctly, but is the above error something that's easy to fix?

  25. Avatar for Paul
    Paul April 26th, 2012

    Hey Phil - i cant get the 301 redirect to work - i simply want to redirect to the same controller but with a lower case starting letter ie from "MyControllerName/" to "myControllerName" so that the url in the user's browser changes - can you advise a simple example? Thanks

  26. Avatar for haacked
    haacked April 26th, 2012

    You'll need to write a custom route. Check out my RouteMagic library for some guidance.

  27. Avatar for Vindberg
    Vindberg June 8th, 2012

    Hi thanks for a great tool. How would you redirect an old url with a querystring? e.g. user.aspx?id=888. Im not allowed to add "?" in the routes.
    Thanks

  28. Avatar for Austin
    Austin July 5th, 2012

    Does this work for areas to? In the RegisterArea method of the AreaRegistration class I call MapRoute on the AreaRegistrationContext instead of on the RouteCollection, but I don't see the Redirect extension methods from RouteMagic available on the AreaRegistrationContext.

  29. Avatar for Sunny
    Sunny September 6th, 2012

    When i try routes.Redirect(...) its throws an error saying "missing assembly reference", Could you tell me what I am missing? I m sure RouteCollection doesn't have a Redirect method. Any suggestions?

  30. Avatar for Vedank Kulshrestha
    Vedank Kulshrestha September 21st, 2012

    Currently i am using below code snippet during Application start
    routes.MapPageRoute("CategoryRoute", "{sCatName}/{sCatId}", "~/Category.aspx");
    routes.MapPageRoute("SubCategoryRoute", "{CatName}/{CatId}", "~/SubCategory.aspx");

    On page i am using

    sCatId = Convert.ToInt32(RouteData.Values["sCatId"].ToString());

    Still facing problem when i redirecting from this page to another page. It stucks back on "RouteData.Values["sCatId"].ToString()" and gives error.

  31. Avatar for haacked
    haacked September 21st, 2012

    Could it be that RouteData.Values["sCatId"] is null?

  32. Avatar for shrikant chandak
    shrikant chandak December 11th, 2014

    Hey, I have my old website in asp.net web forms (for eg: www.example.com/work.aspx) and now i have created my new website in asp.net mvc which is having its url as www.example.com/work so how do i suppose to redirect (or Permanent Redirect) without loosing my SEO...

  33. Avatar for Maneesh
    Maneesh June 14th, 2017

    How do I permanent redirect to lowercase url's throughout the site.
    Ex: http://xyz.com/Pages/Contact to http://xyz.com/pages/contact
    Can I use RouteMagic and do something in Route.Config where I setup my routing?

  34. Avatar for Maneesh
    Maneesh June 14th, 2017

    So essentially any "controller/action" which has uppercase character to lowercase

  35. Avatar for Ravee
    Ravee October 18th, 2017

    how we can do country code base url using ip address