ASP.NET MVC Permanent 301 redirects for legacy routes

Having a good url routing scheme is extremely important when developing an application. Urls should be canonical to aid in search engine optimization and discoverable in order to aid users in learning where to find application functionality for particular tasks. But there are points in an applications lifecycle where one wishes to change the url routing scheme in order to employ a better one. In such instances, URL redirection can be used to preserve search engine rankings, user bookmarks to specific pages or to allow more than one URL to serve up the same content, as is the case when employing URL shortening.

ASP.NET MVC has the RedirectResult to perform a 302 temporarily moved redirection response from one URL to another, but it does not have any built in way to handle permanent "301 moved permanently" redirections. This is the topic of today's post.

Dude, Where's my Content?

Using an ActionResult to perform redirections works great for the Post-Redirect-Get pattern in conjunction with 302 temporary redirections, but doesn't fit particularly well for 301 permanent redirections. In the latter case, it would be better if we could let our routing handle this for us and not require a controller action in conjunction with a route as exists in the former case. For permanent redirections then, let's define a LegacyRoute:

/// <summary>
/// Represents a legacy route to a resource 
/// that has been superceded by a new route
/// </summary>
public class LegacyRoute : Route
{
    /// <summary>
    /// Initializes a new instance of the <see cref="LegacyRoute"/> class.
    /// </summary>
    /// <param name="url">The URL.</param>
    /// <param name="routeHandler">The route handler.</param>
    public LegacyRoute(string url, IRouteHandler routeHandler)
        : base(url, routeHandler)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="LegacyRoute"/> class.
    /// </summary>
    /// <param name="url">The URL.</param>
    /// <param name="defaults">The defaults.</param>
    /// <param name="routeHandler">The route handler.</param>
    public LegacyRoute(string url, RouteValueDictionary defaults, IRouteHandler routeHandler)
        : base(url, defaults, routeHandler)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="LegacyRoute"/> class.
    /// </summary>
    /// <param name="url">The URL.</param>
    /// <param name="defaults">The defaults.</param>
    /// <param name="constraints">The constraints.</param>
    /// <param name="routeHandler">The route handler.</param>
    public LegacyRoute(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, IRouteHandler routeHandler)
        : base(url, defaults, constraints, routeHandler)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="LegacyRoute"/> class.
    /// </summary>
    /// <param name="url">The URL pattern for the route.</param>
    /// <param name="defaults">The values to use if the URL does not contain all the parameters.</param>
    /// <param name="constraints">A regular expression that specifies valid values for a URL parameter.</param>
    /// <param name="dataTokens">Custom values that are passed to the route handler, but which are not used to determine whether the route matches a specific URL pattern. These values are passed to the route handler, where they can be used for processing the request.</param>
    /// <param name="routeHandler">The object that processes requests for the route.</param>
    public LegacyRoute(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, RouteValueDictionary dataTokens, IRouteHandler routeHandler)
        : base(url, defaults, constraints, dataTokens, routeHandler)
    {
    }

    /// <summary>
    /// Returns information about the URL that is associated with the route.
    /// </summary>
    /// <param name="requestContext">An object that encapsulates information about the requested route.</param>
    /// <param name="values">An object that contains the parameters for a route.</param>
    /// <returns>
    /// An object that contains information about the URL that is associated with the route.
    /// </returns>
    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        // legacy routes NEVER generate outbound URLs
        return null;
    }
}

We inherit from Route and override GetVirtualPath, a method called when generating outbound URLs. Since we don't ever want legacy routes to take part in outbound URL generation, we simply return null. The plan is to register the legacy routes before other more generic routes, so we don't want legacy routes providing a match for a URL pattern over other routes.

Now that we have a route class to use, an IRouteHandler is also needed to process the request for a URL pattern matched by a legacy route. Enter LegacyRouteHandler:

/// <summary>
/// Handles legacy routes
/// </summary>
public class LegacyRouteHandler : IRouteHandler
{
    /// <summary>
    /// Gets or sets the route values.
    /// </summary>
    /// <value>The route values.</value>
    public RouteValueDictionary RouteValues { get; set; }

    /// <summary>
    /// Initializes a new instance of the <see cref="LegacyRouteHandler"/> class.
    /// </summary>
    /// <param name="routeValues">the route values</param>
    public LegacyRouteHandler(object routeValues)
    {
        RouteValues = new RouteValueDictionary(routeValues);
    }

    /// <summary>
    /// Provides the object that processes the request.
    /// </summary>
    /// <param name="requestContext">An object that encapsulates information about the request.</param>
    /// <returns>An object that processes the request.</returns>
    public IHttpHandler GetHttpHandler(RequestContext requestContext)
    {
        var urlHelper = new UrlHelper(requestContext);
        var url = urlHelper.Action(null, RouteValues);

        var absoluteUrl = requestContext.HttpContext.Request.Url.Scheme + "://" +
                          requestContext.HttpContext.Request.Url.Authority + url;

        return new RedirectHandler(absoluteUrl);
    }
}

When the LegacyRouteHandler is instantiated, it is passed route values in the constructor. These will be used when GetHttpHandler is called to construct the new URL that the old URL should be redirected to. An absolute URL (technically, URI) is constructed and passed to a RedirectHandler which will be used to set the Location HTTP Header on the response. Whilst most web browsers will support a relative URI, RFC 2616 mandates that the URI should be absolute. The RedirectHandler looks like so:

/// <summary>
/// Handles requests with a 301 Moved Permanently response
/// </summary>
public class RedirectHandler : IHttpHandler
{
    /// <summary>
    /// backing field for the redirect url
    /// </summary>
    private readonly string _redirectUrl;

    /// <summary>
    /// Initializes a new instance of the <see cref="RedirectHandler"/> class.
    /// </summary>
    /// <param name="redirectUrl">The redirect URL.</param>
    public RedirectHandler(string redirectUrl)
    {
        _redirectUrl = redirectUrl;
    }

    /// <summary>
    /// Gets a value indicating whether another request
    /// can use the <see cref="T:System.Web.IHttpHandler"/> instance.
    /// </summary>
    /// <value></value>
    /// <returns>returns false.</returns>
    public bool IsReusable
    {
        get { return false; }
    }

    /// <summary>
    /// Processes the request.
    /// </summary>
    /// <param name="httpContext">The HTTP context.</param>
    public void ProcessRequest(HttpContext httpContext)
    {
        httpContext.Response.Clear();
        httpContext.Response.Status = "301 Moved Permanently";
        httpContext.Response.StatusCode = 301;
        httpContext.Response.AppendHeader("Location", _redirectUrl);
        httpContext.Response.Flush();
        httpContext.Response.End();
    }
}

RedirectHandler implements IHttpHandler. When the ProcessRequest method is called by the framework, the handler uses the absolute URL passed to it in the constructor to set the Location HTTP header on the response and returns a 301 HTTP status code with accompanying status message.

With the classes defined above, one can start implementing redirections for legacy routes immediately using the following inside of RegisterRoutes(RouteCollection routes) in Global.asax.cs

routes.Add(new LegacyRoute("old-route", new LegacyRouteHandler(new { controller = "Home", action = "Index", area = "" })));

It's much cleaner to define some extension methods on RouteCollection for mapping legacy routes instead:

/// <summary>
/// Houses extension methods for <see cref="RouteCollection"/>
/// </summary>
public static class RouteCollectionExtensions
{
    /// <summary>
    /// Maps a legacy route.
    /// </summary>
    /// <param name="routes">The routes.</param>
    /// <param name="url">The old URL.</param>
    /// <param name="routeValues">The route values.</param>
    /// <returns></returns>
    public static Route MapLegacyRoute(this RouteCollection routes, string url, object routeValues)
    {
        return MapLegacyRoute(routes, null, url, routeValues);
    }


    /// <summary>
    /// Maps a legacy route.
    /// </summary>
    /// <param name="routes">The routes.</param>
    /// <param name="name">The name.</param>
    /// <param name="url">The old URL.</param>
    /// <param name="routeValues">The route values.</param>
    /// <returns>the <see cref="LegacyRoute"/> created</returns>
    public static Route MapLegacyRoute(this RouteCollection routes, string name, string url, object routeValues)
    {
        if (routes == null)
        {
            throw new ArgumentNullException("routes");
        }

        if (url == null)
        {
            throw new ArgumentNullException("url");
        }

        var route = new LegacyRoute(url, new LegacyRouteHandler(routeValues))
            {
                Defaults =  new RouteValueDictionary(routeValues),
                DataTokens = new RouteValueDictionary()             
            };

        routes.Add(name, route);

        return route;
    } 
}

Now our RegisterRoutes method looks like

public class MvcApplication : System.Web.HttpApplication
{
    public static void RegisterGlobalFilters(GlobalFilterCollection filters)
    {
        filters.Add(new HandleErrorAttribute());
    }

    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        // map the legacy route above the more generic Default route
        routes.MapLegacyRoute(null, "old-route", new { controller = "Home", action = "Index", area="" });

        routes.MapRoute(
            "Default",
            "{controller}/{action}/{id}",
            new { controller = "Home", action = "Index", area="", id = UrlParameter.Optional }
        );

    }

    protected void Application_Start()
    {
        AreaRegistration.RegisterAllAreas();

        RegisterGlobalFilters(GlobalFilters.Filters);
        RegisterRoutes(RouteTable.Routes);
    }
}

The above will permanently redirect requests to /old-route to /Home/Index (well, in fact they would redirect to / as the HomeController and Index action are the default values of the Default route).

What about Areas?

We can see above that areas get registered before RegisterRoutes is called. This means that areas have the opportunity to register routes before the main application does. Since the order of routes in the application's RouteTable is important for route matching (a top to bottom operation that stops as soon as a match is found), we would want to register any legacy routes for URLs that may be matched by generic routes registered by areas before those generic routes. The following AreaRegistrationContext extension methods can be used for this purpose

/// <summary>
/// Houses extension methods for <see cref="AreaRegistrationContext"/>
/// </summary>
public static class AreaRegistrationContextExtensions
{
    /// <summary>
    /// Maps a legacy route.
    /// </summary>
    /// <param name="areaRegistrationContext">The area registration context.</param>
    /// <param name="url">The old URL.</param>
    /// <param name="routeValues">The route values.</param>
    /// <returns>the <see cref="LegacyRoute"/> created</returns>
    public static Route MapLegacyRoute(this AreaRegistrationContext areaRegistrationContext, string url, object routeValues)
    {
        return MapLegacyRoute(areaRegistrationContext, null, url, routeValues);
    } 

    /// <summary>
    /// Maps a legacy route.
    /// </summary>
    /// <param name="areaRegistrationContext">The area registration context.</param>
    /// <param name="name">The name.</param>
    /// <param name="url">The old URL.</param>
    /// <param name="routeValues">The route values.</param>
    /// <returns>the <see cref="LegacyRoute"/> created</returns>
    public static Route MapLegacyRoute(this AreaRegistrationContext areaRegistrationContext, string name, string url, object routeValues)
    {
        if (areaRegistrationContext == null)
        {
            throw new ArgumentNullException("areaRegistrationContext");
        }

        if (url == null)
        {
            throw new ArgumentNullException("url");
        }

        var route = areaRegistrationContext.Routes.MapLegacyRoute(name, url, routeValues);
        route.DataTokens["area"] = areaRegistrationContext.AreaName; 

        return route;
    } 
}

As an example, inside of our application's Account area, we would now have

public class AccountAreaRegistration : AreaRegistration
{
    public override string AreaName
    {
        get
        {
            return "Account";
        }
    }

    public override void RegisterArea(AreaRegistrationContext context)
    {
        context.MapLegacyRoute("Account/Home/old-route", new { controller = "Home", action = "Index", area = "" });

        context.MapRoute(
            "Account_default",
            "Account/{controller}/{action}/{id}",
            new { controller="Home" , action = "Index", id = UrlParameter.Optional }
        );
    }
}

The above will redirect the URL /Account/Home/old-route to /Home/Index (which, if using the routing in Global.asax.cs defined above would route to /).

If you're using T4MVC (which I highly recommend you do) then you can replace all of the magic string properties with the generated class properties inside of the anonymous objects passed as route values in MapLegacyRoute. One could go even further and define extension methods that take an ActionResult instead of a route values object, much like the way T4MVC overloads the common HtmlHelper methods.

Comments

comments powered by Disqus