Google analytics script

Latest jQuery CDN with code tiggling.

Thursday, 17 March 2011

Removing route values from links/URLs in Asp.net MVC

I didn't really know how to properly title this post to make it easily searchable by those who bump into routing-related issue. Words like arbitrary, ambient, unwanted, unneeded, extra, unrelated etc route values popped into my mind, but I decided to title it as it is now.

One of the main pillars of Asp.net MVC is routing. Many applications can just use default route definition to cover all their scenarios but some applications require it to be a bit more complex. Look at this example:

   1:  routes.MapRoute(
   2:      "CustomerSpecific",
   3:      "Customers/{customerId}/{controller}/{action}/{id}"
   4:      new { controller = "Customers", action = "Index", id = UrlParameter.Optional },
   5:      new { customerId = @"\d+" }
   6:  );
   7:   
   8:  routes.MapRoute(
   9:      "Default",
  10:      "{controller}/{action}/{id}",
  11:      new { controller = "Home", action = "Index", id = UrlParameter.Optional }
  12:  );
Seems fine? Well it does and it works, but you will have problems when you'll be anywhere in http://www.domain.com/Customers/n/... and would like to also generate links to parts of your application that are covered by default route definition (second route). Let me show you why.

URLs and route values

Imagine you're looking at a list of one of your customer's orders: http://www.domain.com/Customers/1/Orders This page displays a list of all orders that are related to customer with CustomerID = 1. Request would be handled by the first route definition yielding following route values:

  • customerId = 1
  • controller = "Orders"
  • action = "Index"
  • id doesn't really exist since it's optional and wasn't provided by this request URL

Every order would have a link to get to its complete details. For instance order with OrderID = 99 details link would be pointing to http://www.domain.com/Customers/1/Orders/Index/99 which would again be handled by the first route definition, but would also define value of the id route variable (which would be 99).

I should point out that this example with customers and orders may not be best, since orders have their own identity space which means that we can get to order details by using default route as well (http://www.domain.com/orders/index/99). A better example would be a sub entity where each instance can't be determined without knowing its parent entity. If you know of a better real world example you're welcome to leave a comment below.

Controller actions

Previous two URLs would be handled by this controller action

   1:  public class OrdersController : Controller
   2:  {
   3:      public ActionResult Index(int? customerId, int? id)
   4:      {
   5:          if (!customerId.HasValue)
   6:          {
   7:              // return a view that displays all orders of all customers
   8:              var orders = /* code that returns all orders */
   9:              return View("AllOrders", orders)
  10:          }
  11:          else
  12:          {
  13:              if (!id.HasValue)
  14:              {
  15:                  // return a list of all orders of a particular customer
  16:                  var orders = /* code that returns all customer orders */
  17:                  return View("CustomerOrders", orders);
  18:              }
  19:              else
  20:              {
  21:                  // return details of a particular customer order
  22:                  var order = /* code that returns order details */
  23:                  return View("Order", order)
  24:              }
  25:          }
  26:      }
  27:  }
This works even though a single action method is branched therefore being hard to maintain and more prone to errors/bugs. This single multi-faceted action can be further split into three completely trivial actions where each of them provides just one single trivial functionality:
   1:  public class OrdersController : Controller
   2:  {
   3:      /// <summary>Displays a list of orders of all customers.</summary>
   4:      public ActionResult Index()
   5:      {
   6:          // return a view that displays all orders of all customers
   7:          var orders = /* code that returns all orders */
   8:          return View("AllOrders", orders)
   9:      }
  10:   
  11:      /// <summary>Displays a list of customer related orders.</summary>
  12:      [RequiresRouteValues("customerId")]
  13:      [RejectsRouteValues("id")]
  14:      public ActionResult Index(int customerId)
  15:      {
  16:          // return a list of all orders of a particular customer
  17:          var orders = /* code that returns all customer orders */
  18:          return View("CustomerOrders", orders);
  19:      }
  20:   
  21:      /// <summary>Displays particular customer order details.</summary>
  22:      [RequiresRouteValues("customerId, id")]
  23:      public ActionResult Index(int customerId, int id)
  24:      {
  25:          // return details of a particular customer order
  26:          var order = /* code that returns order details */
  27:          return View("Order", order)
  28:      }
  29:  }
As you can see I've used two custom attributes that are actually controller action selectors. I've written a blog post about Asp.net MVC code maintainability improvements in the past. It talks about custom controller action method selectors. You can find code of one of these attributes in the linked post. By understanding the functionality of one, it's rather simple to write the other one as well. But that is beyond the scope of this post.

So where's the problem?

The problem becomes apparent when any of the views that were handled by the first route definition has links that should be generated (and later handled) by the second route definition. Lets say that order details view (http://www.domain.com/Customers/1/Orders/Index/99) has a link to all products. the problem is that following action links don't point to where we want them to.

   1:  // http://www.domain.com/Customers/1/Products
   2:  <%= Html.ActionLink("All products", "Index", "Products") %>
   3:   
   4:  // http://www.domain.com/Customers/1/Products
   5:  <%= Html.ActionLink("All products", "Index", "Products", new { id = string.Empty}) %>
   6:   
   7:  // http://www.domain.com/Products?customerId=1
   8:  <%= Html.ActionLink("All products", "Index", "Products", new { customerId = string.Empty}) %>
   9:   
  10:  // http://www.domain.com/Products?customerId=1
  11:  <%= Html.ActionLink("All products", "Index", "Products", new {
  12:      customerId = string.Empty,
  13:      id = string.Empty
  14:  }) %>
The last two come close, but we can see that customerId route value has been appended regardless of setting it to string.Empty. The reason being code of the internal class System.Web.Routing.ParsedRoute. Its method Bind() works correctly for our first route because customerId is part of route URL definition, but in case of the second route it's not and it injects existing values back into local RouteValueDictionary acceptedValues variable. Check its code with .Net Reflector if you need to know exactly how it works.

Possible solutions?

I haven't given too much thought to this framework code whether it has a bug and could be solved in a different way, but there are possible solutions to this problem.

  1. write custom Html/Url method extensions that would create URLs without those ambient values - the downside is that we'd have to overhaul all our views and change URL/link generation with our custom method extension.
  2. write a custom action result filter attribute that would remove any route values that we'd like to be removed - this means that these values won't exist for all URLs in the result view being generated
  3. write a custom route class that can remove any ambient route values that were part of routes defined before it - we'll only need to change our routing definition everything else stays the same
The last one seems optimal.

Custom route class

The idea is to write a custom route class that can define certain route values that will be removed/ignored at URL generation that happens each time when we want to create a URL or an action link. This is the code of such class.

   1:  public class RouteWithExclusions : Route
   2:  {
   3:      #region Properties
   4:   
   5:      /// <summary>
   6:      /// Gets route values that are excluded when route generates URLs.
   7:      /// </summary>
   8:      /// <value>Route values that should be excluded.</value>
   9:      public ReadOnlyCollection<string> ExcludedRouteValuesNames { get;; private set;; }
  10:   
  11:      #endregion
  12:   
  13:      #region Constructor
  14:   
  15:      /// <summary>
  16:      /// Initializes a new instance of the <see cref="RouteWithExclusions"/> class.
  17:      /// </summary>
  18:      /// <param name="url">Route URL definition.</param>
  19:      /// <param name="routeHandler">Route handler instance.</param>
  20:      /// <param name="commaSeparatedRouteValueNames">Comma separated string with route values that should be removed when generating URLs.</param>
  21:      [SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "0#")]
  22:      public RouteWithExclusions(string url, IRouteHandler routeHandler, string commaSeparatedRouteValueNames)
  23:          : this(url, routeHandler, (commaSeparatedRouteValueNames ?? string.Empty).Split(','))
  24:      {
  25:          // does nothing
  26:      }
  27:   
  28:      /// <summary>
  29:      /// Initializes a new instance of the <see cref="RouteWithExclusions"/> class.
  30:      /// </summary>
  31:      /// <param name="url">Route URL definition.</param>
  32:      /// <param name="routeHandler">Route handler instance.</param>
  33:      /// <param name="excludeRouteValuesNames">Route values to remove when generating URLs.</param>
  34:      [SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "0#")]
  35:      public RouteWithExclusions(string url, IRouteHandler routeHandler, params string[] excludeRouteValuesNames)
  36:          : base(url, routeHandler)
  37:      {
  38:          this.ExcludedRouteValuesNames = new ReadOnlyCollection<string>(excludeRouteValuesNames.Select<string, string>(val => val.Trim()).ToList());
  39:      }
  40:   
  41:      #endregion
  42:   
  43:      #region Route overrides
  44:   
  45:      /// <summary>
  46:      /// Returns information about the requested route.
  47:      /// </summary>
  48:      /// <param name="httpContext">An object that encapsulates information about the HTTP request.</param>
  49:      /// <returns>
  50:      /// An object that contains the values from the route definition.
  51:      /// </returns>
  52:      public override RouteData GetRouteData(HttpContextBase httpContext)
  53:      {
  54:          return base.GetRouteData(httpContext);
  55:      }
  56:   
  57:      /// <summary>
  58:      /// Returns information about the URL that is associated with the route.
  59:      /// </summary>
  60:      /// <param name="requestContext">An object that encapsulates information about the requested route.</param>
  61:      /// <param name="values">An object that contains the parameters for a route.</param>
  62:      /// <returns>
  63:      /// An object that contains information about the URL that is associated with the route.
  64:      /// </returns>
  65:      public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
  66:      {
  67:          if (requestContext == null)
  68:          {
  69:              throw new ArgumentNullException("requestContext");
  70:          }
  71:   
  72:          // create new route data and include only non-excluded values
  73:          RouteData excludedRouteData = new RouteData(this, this.RouteHandler);
  74:   
  75:          // add route values
  76:          requestContext.RouteData.Values
  77:              .Where(pair => !this.ExcludedRouteValuesNames.Contains(pair.Key, StringComparer.OrdinalIgnoreCase))
  78:              .ToList()
  79:              .ForEach(pair => excludedRouteData.Values.Add(pair.Key, pair.Value));
  80:          // add data tokens
  81:          requestContext.RouteData.DataTokens
  82:              .ToList()
  83:              .ForEach(pair => excludedRouteData.DataTokens.Add(pair.Key, pair.Value));
  84:   
  85:          // intermediary request context
  86:          RequestContext currentContext = new RequestContext(new HttpContextWrapper(HttpContext.Current), excludedRouteData);
  87:   
  88:          // create new URL route values and include only none-excluded values
  89:          RouteValueDictionary excludedRouteValues = new RouteValueDictionary(
  90:              values
  91:                  .Where(v => !this.ExcludedRouteValuesNames.Contains(v.Key, StringComparer.OrdinalIgnoreCase))
  92:                  .ToDictionary(pair => pair.Key, pair => pair.Value)
  93:          );
  94:   
  95:          VirtualPathData result = base.GetVirtualPath(currentContext, excludedRouteValues);
  96:          return result;
  97:      }
  98:   
  99:      #endregion
 100:  }

Using this custom route

To make things even more convenient we can write an extension method to map our custom route to application routes table. So let's write additional MapRoute extension methods that do just that:

   1:  public static class RouteCollectionExtensions
   2:      {
   3:          /// <summary>
   4:          /// Maps the specified URL route and sets route value names to remove and default route values.
   5:          /// </summary>
   6:          /// <param name="routes">A collection of routes for the application.</param>
   7:          /// <param name="name">The name of the route to map.</param>
   8:          /// <param name="url">The URL pattern for the route.</param>
   9:          /// <param name="routeValueNames">Comma separated string with route value names that should be removed when route generates URLs.</param>
  10:          /// <param name="defaults">An object that contains default route values.</param>
  11:          /// <returns>A reference to the mapped <see cref="RouteWithExclusions"/> object instance.</returns>
  12:          [SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#")]
  13:          public static Route MapRoute(this RouteCollection routes, string name, string url, string routeValueNames, object defaults)
  14:          {
  15:              if (routes == null)
  16:              {
  17:                  throw new ArgumentNullException("routes");
  18:              }
  19:              if (url == null)
  20:              {
  21:                  throw new ArgumentNullException("url");
  22:              }
  23:   
  24:              Route item = new RouteWithExclusions(url, new MvcRouteHandler(), routeValueNames) {
  25:                  Defaults = new RouteValueDictionary(defaults),
  26:                  DataTokens = new RouteValueDictionary()
  27:              };
  28:              routes.Add(name, item);
  29:              return item;
  30:          }
  31:   
  32:          /// <summary>
  33:          /// Maps the specified URL route and sets route value names to remove, default route values and constraints.
  34:          /// </summary>
  35:          /// <param name="routes">A collection of routes for the application.</param>
  36:          /// <param name="name">The name of the route to map.</param>
  37:          /// <param name="url">The URL pattern for the route.</param>
  38:          /// <param name="routeValueNames">Comma separated string with route value names that should be removed when route generates URLs.</param>
  39:          /// <param name="defaults">An object that contains default route values.</param>
  40:          /// <param name="constraints">A set of expressions that specify values for the url parameter.</param>
  41:          /// <returns>A reference to the mapped <see cref="RouteWithExclusions"/> object instance.</returns>
  42:          [SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#")]
  43:          public static Route MapRoute(this RouteCollection routes, string name, string url, string routeValueNames, object defaults, object constraints)
  44:          {
  45:              if (routes == null)
  46:              {
  47:                  throw new ArgumentNullException("routes");
  48:              }
  49:              if (url == null)
  50:              {
  51:                  throw new ArgumentNullException("url");
  52:              }
  53:   
  54:              Route item = new RouteWithExclusions(url, new MvcRouteHandler(), routeValueNames) {
  55:                  Defaults = new RouteValueDictionary(defaults),
  56:                  Constraints = new RouteValueDictionary(constraints),
  57:                  DataTokens = new RouteValueDictionary()
  58:              };
  59:              routes.Add(name, item);
  60:              return item;
  61:          }
  62:      }
Now that we've prepared everything we need to change routing so any arbitrary/ambient/unwanted/unneeded/extra/unrelated route values will get removed from generated URLs:
   1:  routes.MapRoute(
   2:      "CustomerSpecific",
   3:      "Customers/{customerId}/{controller}/{action}/{id}"
   4:      new { controller = "Customers", action = "Index", id = UrlParameter.Optional },
   5:      new { customerId = @"\d+" }
   6:  );
   7:   
   8:  routes.MapRoute(
   9:      "Default",
  10:      "{controller}/{action}/{id}",
  11:      "customerId", // exclude these route values (comma separated list)
  12:      new { controller = "Home", action = "Index", id = UrlParameter.Optional }
  13:  );
Feel free to leave a comment below.

4 comments:

  1. Why not do something like the following in your GetVirtualPath method:

    var filteredValues = new RouteValueDictionary(values);

    foreach (var key in excludeKeys)
    {
    filteredValues.Remove(key);
    }

    return base.GetVirtualPath(requestContext, filteredValues);

    This worked for me (MVC3). Is there a real need to wrap the request?

    ReplyDelete
  2. Found this post with "unwanted". Thanks!

    ReplyDelete
    Replies
    1. Thanks Kijana. My words at the top of this post apparently helped. And I hope content helped you as well.

      Delete
  3. Hi, thanks for article. Found out that (at least) in MVC 4 it's possible to do like this:
    1: routes.MapRoute(
    2: "CustomerSpecific",
    3: "Customers/{customerId}/{controller}/{action}/{id}"
    4: new { controller = "Customers", action = "Index", id = UrlParameter.Optional },
    5: new { customerId = @"\d+" }
    6: );
    7:
    8: routes.MapRoute(
    9: "Default",
    10: "{controller}/{action}/{id}",
    11: new { controller = "Home", action = "Index", id = UrlParameter.Optional, customerId = string.Empty } //<- this prevent adding query parameter of customerId
    12: );

    Now generating link with customerId = string.Empty will not add query parameter "customerId"..

    Html.ActionLink("All products", "Index", "Products", new {
    12: customerId = string.Empty,
    13: id = string.Empty
    14: })

    -br, Alexey

    ReplyDelete