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.

12 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
  4. What about if I need a custom url of

    http://www.mydomain.com.br/company/details

    to

    http://www.mydomain.com.br/company

    first question is it possible?
    if yes, how?

    ReplyDelete
    Replies
    1. You haven't given enough information about your problem. Please copy how you define your routes. By the info you've given everything seems possible.
      This particular blog post of mine solves a problem when you have several different routes with differently named route parameters. When creating routes on one route pointing to another these mismatched parameters are also being transferred across.

      Delete
  5. I am building a ASP.NET MVC application. In the RouteConfig.cs I am adding MapRoutes as follows:

    var appModel = new AppModel();
    var apps = appModel.GetAppNames();
    foreach (var appName in apps)
    {
    routes.MapRoute(
    name: "application" + appName,
    url: appName + "/{controller}/{action}/{id}",
    defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
    );
    }
    When I go to page, for example http://localhost/appName1/Dashboard I have a link on my view that points to http://localhost/appName1/Home/About.

    After that, when I go to another page - http://localhost/appName2/Dashboard, my link on my view still points to http://localhost/appName1/Home/About(and it should be http://localhost/appName2/Home/About). I am building the link with "< a href='@Url.Action("About", "Home")' >about". How to solve this?

    ReplyDelete
    Replies
    1. You're getting these invalid links because when routing builds your links it has a match in the first route map. All your links on pages and created using either @Url.Action(action, controller) or @Html.ActionLink(text, action, controller) will always point to your first application.

      Two suggestions for you
      1. Have just a single route map, but provide app as a parameter {app}/{controller}/{action}/{id} or
      2. Create links using named routes using @Url.RouteUrl where you provide route name along with parameters

      I would go with #1 because current page's app parameter would be used with link generation and you could simply use the same @Url.Action(action,controller) link URL generation call. but maybe there are other subtleties to your application and you should know better which of these scenarios would work better in your case.

      Delete
  6. Is this class file applicable for the web forms routing?

    ReplyDelete
    Replies
    1. I haven't used Web Forms since MVC came out and back then Web Forms didn't support routing. Nowadays things are different. Looking at WF routing it seems this could as well be used there albeit with few modifications. The underlaying mech of routing seems to be very similar if not identical to MVC/WebAPI.

      Delete
    2. I have trid with the Web-form but got stuck at some point , can you help me out with it please?

      Delete

This is a fully moderated comments section. All spam comments are marked as spam without exception. Don't even try.

Note: only a member of this blog may post a comment.