There’s quite some annoyence about how ASP.Net routing handles trailing slashes. I agree, it would have been nice if one could enforce whether or not to use a trailing slash.
I’ve always taken good care of my trailing slashes. Almost every link comes from my custom UrlHelper or extension methods (for convenience – e.g. Model.Product.GetThumbnailUrl()). Anyway, that doesn’t mean it stopped bugging me! For one, external links (SEO) are not under your control, I don’t really know how serious this duplicate content issue is, but I’m not willing to take a chance on it. Another unsolved annoyence is when you add content pages. You always have to think twice about trailing slashes in every little link you include.
This week, I thought it was about time to go for a more generic solution. I started by googling for it and almost ended up using the new IIS7 URL Rewrite Module just like Scott suggested in his post. The main reason I didn’t was because I hate managing things in two places. Especially when their so closely related.
And then I thought: Why not let the system do what you ask it to do? How hard can it be to make the system respect you route mappings? If I map a route with url "{category}/" or "{category}/{product}" I want to make sure category urls end with a slash and products don’t. That simple.
It seemed to me that tuning ASP.Net MVC’s UrlRoutingModule was the most abvious thing to do, so here’s what I’ve done:
public class UrlRoutingModule : System.Web.Routing.UrlRoutingModule
{
private const string SLASH = "/";
public override void PostResolveRequestCache(HttpContextBase context)
{
var routeData = RouteCollection.GetRouteData(context);
var redirecturi = GetRedirectUri(routeData, context.Request.Url);
if (redirecturi != null)
{
context.Response.RedirectPermanently(redirecturi);
return;
}
base.PostResolveRequestCache(context);
}
private static Uri GetRedirectUri(RouteData routeData, Uri requestedUri)
{
if (routeData == null) return null;
var route = routeData.Route as Route;
if (route == null) return null;
var requestedSegments = GetSegmentCount(requestedUri.AbsolutePath);
var mappedSegements = GetSegmentCount(route.Url);
if (requestedSegments == mappedSegements)
{
var slashRequired = route.Url.EndsWith(SLASH);
if (slashRequired && !requestedUri.AbsolutePath.EndsWith(SLASH))
return requestedUri.Append(SLASH);
if (!slashRequired && requestedUri.AbsolutePath.EndsWith(SLASH))
return requestedUri.TrimPathEnd(SLASH[0]);
}
else if (!requestedUri.AbsolutePath.EndsWith(SLASH)) // requestedSegments < mappedSegements
{
return requestedUri.Append(SLASH);
}
return null;
}
private static int GetSegmentCount(string path)
{
return path.Split(SLASH.ToCharArray(), StringSplitOptions.RemoveEmptyEntries).Length;
}
}
Note: If your route ends with optional segments, urls that don’t include them always end up with a trailing slash.
Another note: The Append and TrimPathEnd are Uri extension methods:
public static class UriExtensions
{
public static Uri Append(this Uri uri, string value)
{
return new Uri(GetAbsoluteUriWithoutQuery(uri) + value + uri.Query);
}
public static Uri TrimPathEnd(this Uri uri, params char[] trimChars)
{
return new Uri(GetAbsoluteUriWithoutQuery(uri).TrimEnd(trimChars) + uri.Query);
}
private static string GetAbsoluteUriWithoutQuery(Uri uri)
{
var ret = uri.AbsoluteUri;
if (uri.Query.Length > 0) ret = ret.Substring(0, ret.Length - uri.Query.Length);
return ret;
}
}
One more note:: I’m fully aware that this module alone does not solve the entire trailing slash problem, but minor additional (possibly project-specific) things like a custom UrlHelper (what I did/had to do, I spare you the details) or solutions like this method are easy enough to make it complete.