Here’s a sample of a technique that might be of general interest. It came up retrofitting a particular url structure onto an existing ASP.NET application originally built with the Web Client Software Factory. I came into the project fairly late in the game and the ROI for migrating to MVC would be nearly zero, so instead a technique was used which welds System.Web.Routing onto a web forms app in a fairly transparent fashion.

Putting Lipstick on a Web Site

Putting Lipstick on a Web Site

To start with there are a few ground rules and goals to establish. One is that a 301 permanent redirect is desirable from older urls onto new routed urls. Another is that not all urls in the app need to be fixed “in place” in the original html response assuming the 301 redirects will keep the resulting urls canonical. Third is that it’s highly desirable to be able to calculate the new route given an old url server-side, for such things as the site map and updating the links on pages which are particularly significant.

Another consideration is that if a site is currently using Request.QueryString values and relies on postback, both of those should continue to work transparently. A final consideration is the range of possible values in a piece of the route could be driven by a database or source which is non-static or has a huge number of possible distinct values. At least a number too large for adding multiple routes or combining into regex restriction.

Downloads: retrofit.zip source code and sample project.

It turns out the System.Web.Routing module is particularly well suited for this, especially because of the bidirectional nature of it’s routes. The attached solution has a sample web app and a class library named Retrofit.Routing which provides an example of using System.Web.Routing in this way.

First of all is the class Retrofit.Routing.WebFormsRouteHandler which implements IRouteHandler. You’ll find in it’s implementation the method GetHttpHandler which will use BuildManager.CreateInstanceFromVirtualPath to obtain an instance of the targetted web page. The web page is targetted with the RouteData Value parameter named “VirtualPath”, much like in an MVC app the controller is targetted with a RouteData Value named “Controller”. The other thing this class does is call HttpContext.RewritePath to extend the number of values present on the QueryString.

You won’t use the WebFormsRouteHandler directly, instead you’ll create an instance of WebFormsRoute. This WebFormsRoute is a descendant of the standard Route class and is used in almost exactly the same way.


public static void RegisterRoutes(RouteCollection routes)
{
    // enable routing of existing files so WebFormsRoute has the chance redirect old urls
    routes.RouteExistingFiles = true;

    // matches customer with a data-driven constraint
    routes.Add(new WebFormsRoute(
        "Customer/{*CompanyName}",
        new RouteValueDictionary(new {VirtualPath = "~/Contacts/Customer.aspx"}),
        new RouteValueDictionary(new {CompanyName = new ValidCompanyNameConstraint()})
    ));

    // matches orders requests
    routes.Add(new WebFormsRoute(
        "Customer/Orders/{OrderID}",
        new RouteValueDictionary(new {VirtualPath = "~/Orders/Details.aspx"})
    ));

}

As we can see defaults can be provided, which is how the value for VirtualPath is defaulted in when a request pattern is matched. The values like {CompanyName} and {OrderID} which are matched above will become CompanyName= and OrderID= querystring parameters passed to the aspx page as it runs, so the code behind does not need to be modified for those cases.

There’s even an example above of how you would provide an IRouteConstraint class if you would like to dynamically restrict the set of urls a route is allowed to match.

public class ValidCompanyNameConstraint : IRouteConstraint
{
    public ValidCompanyNameConstraint()
    {
        // pre-load valid company names
        // in a real app this would be queries each call to match,
        // or it would be re-cached when the data had changed.

        var data = new NorthwindDataContext();
        CachedNames = data.Customers.ToDictionary(x => x.CompanyName, x => x.CustomerID);
    }

    private Dictionary<string, string> CachedNames { get; set; }

    public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
    {
        var parameterValue = Convert.ToString(values[parameterName]);
        return CachedNames.ContainsKey(parameterValue);
    }
}

It’s a contrived example, but it is just a sample after all.

And the WebFormsRoute is performing one other task related to the need for 301 redirects. As requests are coming in it’s not only matching the request against the new url pattern, it’s also testing the request url against the old virtual path and query string parameters needed.

    // do the work to check if the incoming request could also produce
    // a meaningful route to redirect towards
    var probeVirtualPathData = TestVirtualPath(httpContext, virtualPath);
    if (probeVirtualPathData == null)
        return null;

    // the request is on a page which this route controls, 301 redirect
    // onto the correct location
    return new RouteData(this, new PermanentRewriteHandler(probeVirtualPathData.VirtualPath));
}

If the incoming request is match for the old url structure then it takes advantage System.Web.Routing’s ability to calculate the correct url in the new pattern. This new url is used as the parameter of a handler which will return a 301 permanent redirect to keep the browser’s address bar clean-looking and to ensure web crawlers are indexing the site with at this single, canonical location.

The rewrite handler itself is a pretty simple device.

public class PermanentRewriteHandler : IRouteHandler, IHttpHandler
{
    private readonly string _path;

    public PermanentRewriteHandler(string path)
    {
        _path = path;
    }

    public IHttpHandler GetHttpHandler(RequestContext requestContext)
    {
        return this;
    }

    public bool IsReusable
    {
        get { return false; }
    }

    public void ProcessRequest(HttpContext context)
    {
        context.Response.Buffer = true;
        context.Response.Redirect("~/" + _path, false);
        context.Response.StatusCode = 301;
        context.Response.StatusDescription = "Moved Permanently";
    }
}

At this point nearly all of the goals are met. You can route incoming requests on new urls onto existing WebForms pages, it will 301 redirect incoming old urls onto the new location, the route values are converted to querystring values transparently, and POST to the WebForms life-cycle will pass through unimpeded on the old or new urls.

The final piece of the puzzle is the need to return the new urls in links returned from pages on the server without relying on the 301 redirects. That’s done easily enough with an extension method Page.ResolveRouteUrl that is used in the same way as Page.ResolveUrl.

public static string ResolveRouteUrl(this Page page, string relativeUrl)
{
    var url = relativeUrl;

    var virtualPathData = GetVirtualPathData(relativeUrl);
    if (virtualPathData != null)
    {
        url = "~/" + virtualPathData.VirtualPath;
    }

    return page.ResolveUrl(url);
}

And it’s used like this:

Company: <asp:HyperLink ID="HyperLink1" runat="server"
                NavigateUrl='<%# Page.ResolveRouteUrl("~/Contacts/Customer.aspx?CompanyName=" + Eval("CompanyName")) %>' Text='<%# Eval("CompanyName") %>'></asp:HyperLink>

Downloads: retrofit.zip source code and sample project.