2012-08-29

ASP MVC encoding route values

I’ve recently been using ASP MVC 2 to develop a business application.  As you may already know the ASP MVC routing system works with URLs which look like this

http://localhost/Client/Details/IBM

In standard ASPX apps the URL would look more like this

http://localhost/Client/Details.aspx?code=IBM

The first URL obviously looks much nicer than the 2nd, however it comes at a cost.  What if the “code” of your object is something like “N/A” for “Not applicable”?  You end up with a URL that looks like this

http://localhost/AbsenceCode/Details/N/A

What we really need is to have ASP MVC encode the value “N/A” as “N%2FA”.  The problem is that even if it did do this then by the time ASP MVC receives the value it has already been decoded, so we get a “400 bad request” error.  This is a real pain, because in business we can’t really tell a customer “You can only use characters A to Z, 0 to 9, underscore and minus in your codes” because they will tell you that they have used the code N/A for not-applicable for the past 20 years.

Now if the code had been after the ? in the URL (a query string value) it would get encoded/decoded and there would be no problem, but unfortunately ASP MVC won’t allow you to include ? in your routes.  So I spent today working on a solution to this and it looks like I’ve just cracked it.  It requires the following…

//1: Register a custom value provider in global.asax.cs
protected void Application_Start()
{
EncodedRouteValueProviderFactory.Register();
...
}

//2: Use the following code in your views instead of Html.ActionLink
//this will ensure that all values before the ? query string part of your
//URL are properly encoded

<%: Html.EncodedActionLink(.....) %>
//3: Use this special redirect action when redirecting from a method
return this.EncodedActionLink(.....);


Those are the only changes required to your web app.  In fact they are so simple that you should be able to change your app in seconds with a find/replace in files.



I’ll list all of my code below, there are two hacky bits.  In one place I type cast an IRoute to a Route object so that I can get its URL, and in another place I use reflection so that I can set two properties which have a protected setter.  The trick is to ensure that whenever we produce a URL in our website it encodes any special characters before the ?, we can’t use %xx so I use !xx.  In the above example N/A would become N!2fA, if the value N/A appears after the ? then it will be URL encoded as normal (N%2fA).  When the user navigates to a new page these route values are passed in this specially encoded format so they need to be converted back, so I register my own ValueProvider which looks at the current route (e.g. {controller}/{action}/{make}/{model}/{registration}) and decodes all values which appear in the route only.  This is done only once (the first time any value is requested) so N!2fA will be converted back to N/A as if we had been passed that value to start with.



I only finished working on this last night, but it seems to be working okay in my current app though.  If you spot any bugs in my source or add any missing features make sure to let me know on my gmail.com account (mrpmorris).



//EncodedActionLinkExtensions.cs
using System.Text;
using System.Text.RegularExpressions;
using System.Web.Routing;

namespace System.Web.Mvc.Html
{
public static class EncodedActionLinkExtensions
{
public static MvcHtmlString EncodedActionLink(this HtmlHelper htmlHelper, string linkText, string action)
{
return htmlHelper.EncodedActionLink(linkText, action, (object)null);
}

public static MvcHtmlString EncodedActionLink(this HtmlHelper htmlHelper, string linkText, string action, string controllerName)
{
return htmlHelper.EncodedActionLink(linkText, action, controllerName, (object)null);
}

public static MvcHtmlString EncodedActionLink(this HtmlHelper htmlHelper, string linkText, string action, object explicitRouteValues)
{
object routeValueObj;
if (!htmlHelper.ViewContext.RequestContext.RouteData.Values.TryGetValue("controller", out routeValueObj))
throw new InvalidOperationException("Could not determine controller");

string controllerName = (string)routeValueObj;
return htmlHelper.EncodedActionLink(linkText, action, controllerName, explicitRouteValues);
}

public static MvcHtmlString EncodedActionLink(this HtmlHelper htmlHelper, string linkText, string action, string controllerName, object explicitRouteValues)
{
return htmlHelper.EncodedActionLink(linkText, action, controllerName, new RouteValueDictionary(explicitRouteValues));
}

public static MvcHtmlString EncodedActionLink(this HtmlHelper htmlHelper, string linkText, string action, string controllerName, RouteValueDictionary explicitRouteValues)
{
string url = EncodedUrlHelper.GenerateUrl(
htmlHelper.ViewContext.RequestContext,
controllerName, action, explicitRouteValues);
string result = string.Format("<a href=\"{0}\">{1}</a>", url, linkText);
return MvcHtmlString.Create(result);
}
}
}


//EncodedRedirectToRouteExtensions.cs
using System.Web.Routing;
namespace System.Web.Mvc
{
public static class EncodedRedirectToRouteExtensions
{
public static EncodedRedirectToRouteResult EncodedRedirectToAction(this IController controller, string actionName)
{
return controller.EncodedRedirectToAction(
actionName,
(
string)null, //controllerName,
(RouteValueDictionary)null //routeValues
);
}

public static EncodedRedirectToRouteResult EncodedRedirectToAction(this IController controller, string actionName, object routeValues)
{
return controller.EncodedRedirectToAction(
actionName,
(
string)null, //controllerName,
new RouteValueDictionary(routeValues)
);
}

public static EncodedRedirectToRouteResult EncodedRedirectToAction(this IController controller, string actionName, RouteValueDictionary routeValues)
{
return controller.EncodedRedirectToAction(
actionName,
(
string)null, //controllerName,
routeValues
);
}

public static EncodedRedirectToRouteResult EncodedRedirectToAction(this IController controller, string actionName, string controllerName)
{
return controller.EncodedRedirectToAction(
actionName,
controllerName,
(RouteValueDictionary)
null //routeValues
);
}

public static EncodedRedirectToRouteResult EncodedRedirectToAction(this IController controller, string actionName, string controllerName, object routeValues)
{
return controller.EncodedRedirectToAction(
actionName,
controllerName,
new RouteValueDictionary(routeValues)
);
}

public static EncodedRedirectToRouteResult EncodedRedirectToAction(this IController controller, string actionName, string controllerName, RouteValueDictionary routeValues)
{
RouteValueDictionary dictionary;
if (routeValues != null)
dictionary
= new RouteValueDictionary(routeValues);
else
dictionary
= new RouteValueDictionary();
dictionary[
"controller"] = controllerName;
dictionary[
"action"] = actionName;

var result
= new EncodedRedirectToRouteResult(dictionary);
return result;
}

}
}

//EncodedRedirectToRouteResult.cs
using System.Web.Mvc;
using System.Web.Routing;
namespace System.Web.Mvc
{
public class EncodedRedirectToRouteResult : ActionResult
{
readonly string RouteName;
readonly RouteValueDictionary RouteValues;

public EncodedRedirectToRouteResult(RouteValueDictionary routeValues)
:
this(null, routeValues)
{
}

public EncodedRedirectToRouteResult(string routeName, RouteValueDictionary routeValues)
{
RouteName
= routeName ?? "";
RouteValues
= routeValues != null ? routeValues : new RouteValueDictionary();
}

public override void ExecuteResult(ControllerContext context)
{
string url = EncodedUrlHelper.GenerateUrl(context.RequestContext, null, null, RouteValues);
context.Controller.TempData.Keep();
context.HttpContext.Response.Redirect(url,
false);
}
}
}

//EncodedRouteValueProvider.cs
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Web.Routing;
using System.Reflection;
namespace System.Web.Mvc
{
public class EncodedRouteValueProvider : IValueProvider
{
readonly ControllerContext ControllerContext;
bool Activated = false;

public EncodedRouteValueProvider(ControllerContext controllerContext)
{
ControllerContext
= controllerContext;
}

public bool ContainsPrefix(string prefix)
{
if (!Activated)
DecodeRouteValues();
return false;
}

public ValueProviderResult GetValue(string key)
{
if (!Activated)
DecodeRouteValues();
return null;
}

void DecodeRouteValues()
{
Activated
= true;
var route
= (Route)ControllerContext.RouteData.Route;
string url = route.Url;
var keysToDecode
= new HashSet<string>();
var regex
= new Regex(@"\{.+?\}");
foreach (Match match in regex.Matches(url))
keysToDecode.Add(match.Value.Substring(
1, match.Value.Length - 2));
foreach (string key in keysToDecode)
{
object valueObj = ControllerContext.RequestContext.RouteData.Values[key];
if (valueObj == null)
continue;
string value = valueObj.ToString();
value
= UrlValueEncoderDecoder.DecodeString(value);
ControllerContext.RouteData.Values[key]
= value;
ValueProviderResult valueProviderResult
= ControllerContext.Controller.ValueProvider.GetValue(key);
if (valueProviderResult == null)
continue;
PropertyInfo attemptedValueProperty
= valueProviderResult.GetType().GetProperty("AttemptedValue");
attemptedValueProperty.SetValue(valueProviderResult, value,
null);
PropertyInfo rawValueProperty
= valueProviderResult.GetType().GetProperty("RawValue");
rawValueProperty.SetValue(valueProviderResult, value,
null);
}
}

}
}

//EncodedRouteValueProviderFactory.cs
namespace System.Web.Mvc
{
public class EncodedRouteValueProviderFactory : ValueProviderFactory
{
public override IValueProvider GetValueProvider(ControllerContext controllerContext)
{
return new EncodedRouteValueProvider(controllerContext);
}

public static void Register()
{
ValueProviderFactories.Factories.Insert(
0, new EncodedRouteValueProviderFactory());
}
}
}

//EncodedUrlHelper.cs
using System.Text;
using System.Text.RegularExpressions;
using System.Web.Mvc;
namespace System.Web.Routing
{
public static class EncodedUrlHelper
{
public static string GenerateUrl(
RequestContext requestContext,
string controllerName,
string action,
RouteValueDictionary explicitRouteValues)
{
if (requestContext == null)
throw new ArgumentNullException("RequestContext");

var newRouteValues
= RouteHelper.GetRouteValueDictionary(
requestContext, controllerName, action, explicitRouteValues);
var route
= RouteHelper.GetRoute(requestContext, controllerName, action, newRouteValues);
string url = route.Url;
//Replace the {values} in the main part of the URL with request values
var regex = new Regex(@"\{.+?\}");
url
= regex.Replace(url,
match
=>
{
string key = match.Value.Substring(1, match.Value.Length - 2);
object value;
if (!newRouteValues.TryGetValue(key, out value))
throw new ArgumentNullException("Cannot reconcile value for key: " + key);
string replaceWith;
if (value == UrlParameter.Optional)
replaceWith
= "";
else
replaceWith
= UrlValueEncoderDecoder.EncodeObject(value);
explicitRouteValues.Remove(key);
return replaceWith;
});

//2: Add additional values after the ?
explicitRouteValues.Remove("controller");
explicitRouteValues.Remove(
"action");
var urlBuilder
= new StringBuilder();
urlBuilder.Append(
"/" + url);
string separator = "?";
foreach (var kvp in explicitRouteValues)
{
if (kvp.Value != UrlParameter.Optional)
{
urlBuilder.AppendFormat(
"{0}{1}={2}", separator, kvp.Key, kvp.Value == null ? "" : HttpUtility.UrlEncode(kvp.Value.ToString()));
separator
= "&";
}
}
return urlBuilder.ToString();
}
}
}

//RouteHelper.cs
namespace System.Web.Routing
{
public static class RouteHelper
{
public static RouteValueDictionary GetRouteValueDictionary(
RequestContext requestContext,
string controllerName,
string action,
RouteValueDictionary explicitRouteValues)
{
var newRouteValues
= new RouteValueDictionary();
var route
= GetRoute(requestContext, controllerName, action, explicitRouteValues);
MergeValues(route.Defaults, newRouteValues);
MergeValues(requestContext.RouteData.Values, newRouteValues);
if (explicitRouteValues != null)
MergeValues(explicitRouteValues, newRouteValues);
if (controllerName != null)
newRouteValues[
"controller"] = controllerName;
if (action != null)
newRouteValues[
"action"] = action;
return newRouteValues;
}

public static Route GetRoute(
RequestContext requestContext,
string controllerName,
string action,
RouteValueDictionary explicitRouteValues
)
{
var routeValues
= new RouteValueDictionary(requestContext.RouteData.Values);
if (explicitRouteValues != null)
MergeValues(explicitRouteValues, routeValues);
if (controllerName != null)
routeValues[
"controller"] = controllerName;
if (action != null)
routeValues[
"action"] = action;
var virtualPath
= RouteTable.Routes.GetVirtualPath(requestContext, routeValues);
return (Route)virtualPath.Route;
}

static void MergeValues(RouteValueDictionary routeValues, RouteValueDictionary result)
{
foreach (var kvp in routeValues)
{
if (kvp.Value != null)
result[kvp.Key]
= kvp.Value;
else
{
object value;
if (!result.TryGetValue(kvp.Key, out value))
result[kvp.Key]
= null;
}
}
}
}
}

//UrlValueEncoderDecoder.cs
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace System.Web.Mvc
{
public static class UrlValueEncoderDecoder
{
static HashSet<char> ValidChars;

static UrlValueEncoderDecoder()
{
string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.";
ValidChars
= new HashSet<char>(chars.ToCharArray());
}

public static string EncodeObject(object value)
{
if (value == null)
return null;
return EncodeString(value.ToString());
}

public static string EncodeString(string value)
{
if (value == null)
return null;
var resultBuilder
= new StringBuilder();
foreach (char currentChar in value.ToCharArray())
if (ValidChars.Contains(currentChar))
resultBuilder.Append(currentChar);
else
{
byte[] bytes = System.Text.UnicodeEncoding.UTF8.GetBytes(currentChar.ToString());
foreach (byte currentByte in bytes)
resultBuilder.AppendFormat(
"${0:x2}", currentByte);
}
string result = resultBuilder.ToString();
//Special case, use + for spaces as it is shorter and spaces are common
return result.Replace("$20", "+");
}

public static string DecodeString(string value)
{
if (value == null)
return value;
//Special case, change + back to a space
value = value.Replace("+", " ");
var regex
= new Regex(@"\$[0-9a-fA-F]{2}");
value
= regex.Replace(value,
match
=>
{
string hexCode = match.Value.Substring(1, 2);
byte byteValue = byte.Parse(hexCode, NumberStyles.AllowHexSpecifier);
string decodedChar = System.Text.UnicodeEncoding.UTF8.GetString(new byte[] { byteValue });
return decodedChar;
});
return value;
}
}
}

1 comment:

Raúl Vázquez said...

Is there an easier solution for asp mvc 4?