I'm trying to find a good general purpose way to canonicalize urls in an ASP.NET MVC 2 application. Here's what I've come up with so far:
// Using an authorization filter because it is executed earlier than other filters
public class CanonicalizeAttribute : AuthorizeAttribute
{
public bool ForceLowerCase { get;set; }
public CanonicalizeAttribute()
: base()
{
ForceLowerCase = true;
}
public override void OnAuthorization(AuthorizationContext filterContext)
{
RouteValueDictionary values = ExtractRouteValues(filterContext);
string canonicalUrl = new UrlHelper(filterContext.RequestContext).RouteUrl(values);
if (ForceLowerCase)
canonicalUrl = canonicalUrl.ToLower();
if (filterContext.HttpContext.Request.Url.PathAndQuery != canonicalUrl)
filterContext.Result = new PermanentRedirectResult(canonicalUrl);
}
private static RouteValueDictionary ExtractRouteValues(AuthorizationContext filterContext)
{
var values = filterContext.RouteData.Values.Union(filterContext.RouteData.DataTokens).ToDictionary(x => x.Key, x => x.Value);
var queryString = filterContext.HttpContext.Request.QueryString;
foreach (string key in queryString.Keys)
{
if (!values.ContainsKey(key))
values.Add(key, queryString[key]);
}
return new RouteValueDictionary(values);
}
}
// Redirect result that uses permanent (301) redirect
public class PermanentRedirectResult : RedirectResult
{
public PermanentRedirectResult(string url) : base(url) { }
public override void ExecuteResult(ControllerContext context)
{
context.HttpContext.Response.RedirectPermanent(this.Url);
}
}
Now I can mark up my controllers like this:
[Canonicalize]
public class HomeController : Controller { /* ... */ }
This all appears to work fairly well, but I have the following concerns:
I still have to add the CanonicalizeAttribute
to every controller (or action method) I want canonicalized, when it's hard to think of a situation where I won't want this behaviour. It seems like there should be a way to get this behaviour site-wide, rather than one controller at a time.
The fact that I'm implementing the 'force to lower-case' rule in the filter seems wrong. Surely it would be better to somehow role this up into the route url logic, but I can't think of a way to do this in my routing configuration. I thought of adding @"[a-z]*"
constraints to the controller and action parameters (as well as any other string route parameters), but I think this will cause the routes to not be matched. Also, because the lower-case rule isn't being applied at the route level, it's possible to generate links in my pages that have upper-case letters in them, which seems pretty bad.
Is there something obvious I'm overlooking here?
I have felt the same "itch" regarding the relaxed nature of the default ASP.NET MVC routing, ignoring letter casing, trailing slashes, etc. Like you, I wanted a general solution to the problem, preferably as part of the routing logic in my applications.
After searching the web high and low, finding no useful libraries, I decided to roll one myself. The result is Canonicalize, an open-source class library that complements the ASP.NET routing engine.
You can install the library via NuGet: Install-Package Canonicalize
And in your route registration: routes.Canonicalize().Lowercase();
Besides lowercase, several other URL canonicalization strategies are included in the package. Force www
domain prefix on or off, force a specific host name, a trailing slash, etc. It is also very easy to add custom URL canonicalization strategies, and I am very open to accept patches adding more strategies to the "official" Canonicalize distribution.
I hope you or anyone else will find this helpful, even if the question is a year old :)