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: );
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: }
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: }
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: }) %>
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.
- 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. - 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
- 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
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: }
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: );
Why not do something like the following in your GetVirtualPath method:
ReplyDeletevar 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?
Found this post with "unwanted". Thanks!
ReplyDeleteThanks Kijana. My words at the top of this post apparently helped. And I hope content helped you as well.
DeleteHi, thanks for article. Found out that (at least) in MVC 4 it's possible to do like this:
ReplyDelete1: 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
What about if I need a custom url of
ReplyDeletehttp://www.mydomain.com.br/company/details
to
http://www.mydomain.com.br/company
first question is it possible?
if yes, how?
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.
DeleteThis 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.
I am building a ASP.NET MVC application. In the RouteConfig.cs I am adding MapRoutes as follows:
ReplyDeletevar 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?
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.
DeleteTwo 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.
nice
ReplyDeleteIs this class file applicable for the web forms routing?
ReplyDeleteI 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.
DeleteI have trid with the Web-form but got stuck at some point , can you help me out with it please?
Delete