Showing posts with label ASP.NET Routing. Show all posts
Showing posts with label ASP.NET Routing. Show all posts

Sunday, October 6, 2013

ASP.NET MVC: Current route values implicitly used by Url.Action

I really don't like when I work with statically-typed language like C# and still need to rely on strings or other loosely-typed constructs for things that may be strongly-typed. ASP.NET MVC went its way towards strongly-typing and now offers things like strongly-typed view models, but still lacks official built-in support for building internal links and URLs without using plain controller and action strings.

Fortunately, here is where MVC Futures comes in (available on NuGet for MVC 3 or 4). It offers a nice expression-based extension methods to build links (on HtmlHelper):

Html.ActionLink<TheController>(c => c.TheAction(theParam))

and URLs (on UrlHelper):

Url.Action<TheController>(c => c.TheAction(theParam))

It's working fine and it protects me from working with those ugly string-based methods and it is also a very convenient way to specify the target action parameters - in definitely nicer way then manually building the RouteValuesCollection required by those traditional methods.

But there is one not well-known feature of MVC (at least I haven't heard a lot about it) that for me seems to be disturbing here - MVC is reusing the route values from the current request when building URLs and no other value specified. This maybe makes some sense for the string-based methods (you don't need to build those RouteValuesCollections over and over again), but it makes no sense for the expression-based approach as the compiler enforces specifying all the parameters explicitly in order to build a lambda expression that compiles.

And here comes the nasty bug I've recently spent some hours on - passing null value is like not passing a value at all. MVC puts null under the appropriate key in the RouteValuesCollection, it then goes down to Route.GetVirtualPath method, which also takes the current route values from the RequestContext. And then the evil happens - the low-level System.Web.Routing's method ParsedRoute.Bind ignores the null value and - bang - it takes the value from the current request, if any accidentally matches by the parameter name.

It means that when trying to build an URL that passes parameter param equal to null and the current request accidentally have parameter named also param (regardless of its type or existence of any logical connection), the request's param value will be passed instead our explicitly demanded null value. And I can see no way to pass null in this case.

Actually, the bug (or feature?) exists in case of string-based URL builder methods, too. But here it is much more visible and obviously wrong.

The only way to fix that strange implicit parameter inheritance by name I know is to work around it - either by removing the name collision by renaming one of the parameters (yuck!) or by using own extension method.

I've created my own extension method to generate URLs/links that has the same signature as the buggy ones and placed it in the namespace more visible than those replaced (you'd better check your usings carefully). Here is the UrlHelper extension I have - HtmlHelper implementation generally calls UrlHelper and wraps its result in a link, so I'll omit it here. The method calls the same methods as the original method being replaced, but it tweaks the RequestContext instance: instead of passing the instance available in UrlHelper (which contains those conflicting route values from the current request), I'm creating new RequestContext reusing Route and IRouteHandler instances from the current request, leaving the actual route values empty. This way there's no possibility for the current values to "infect" our URL building process anymore. Interesting is that RouteData constructor doesn't enforce the values being set, anyway.

public static string Action<TController>(this UrlHelper helper, Expression<Action<TController>> action)
    where TController : Controller
{
    // we need to recreate RequestContext without values, to override the MVC "feature"
    // that replaces null specified in action with value inherited from current request
    var currentRouteData = helper.RequestContext.RouteData;
    var fixedRouteData = new RouteData(currentRouteData.Route, currentRouteData.RouteHandler);
    var fixedRequestContext = new RequestContext(helper.RequestContext.HttpContext, fixedRouteData);

    var valuesFromExpr = Microsoft.Web.Mvc.Internal.ExpressionHelper.GetRouteValuesFromExpression(action);
    var vpd = helper.RouteCollection.GetVirtualPathForArea(fixedRequestContext, valuesFromExpr);
    return vpd == null ? null : vpd.VirtualPath;
}

Well, it seems to be yet another example of the fact that null is rather vague and problematic thing. It can represent many different notions, depending on the context and implementation - like no value, empty value, inherited value etc. Althouth ASP.NET MVC treats null as "inherit the value" not only in this case, I'd always prefer being explicit about that kind of behaviors.

Saturday, March 31, 2012

ASP.NET MVC and overlapping routes

ASP.NET routing in MVC allows us to define how different URLs are mapped to the controllers, actions, action parameters etc. It is quite simple and in some cases it seems even too simple. In our MVC application we had a requirement that we should accept these two path patterns:

{controller}/{action}
{controller}.aspx/{action}

The first one is the default, we want our generated links to go this route. The second one is legacy, but it's required to work correctly. We could've set up some kind of redirects from old route to the default one, but we've thought it'll be easier to define a separate route in our application that will map to the same controllers as the default route.

Easier said than done. The first attempt looked like that:

routes.MapRoute("Default", "{controller}/{action}", 
new { controller = "Home", action = "Index" });
routes.MapRoute("Legacy", "{controller}.aspx/{action}",
new { controller = "Home", action = "Index" });

Seems trivial, but doesn't work. When requested Home.aspx, the application failed to find the controller named "Home.aspx". Well, the default route eagerly matched the controller variable and missed the fact that the second route seems to be better. Now I remember, the docs clearly state that finding the route stops on first match and we should arrange our routes from most specific to most generic ones.

OK, let's then change the order of our routes, so that we'll catch legacy ones first:

routes.MapRoute("Legacy", "{controller}.aspx/{action}", 
new { controller = "Home", action = "Index" });
routes.MapRoute("Default", "{controller}/{action}",
new { controller = "Home", action = "Index" });

Looks like working, both "Home.aspx" and "Home" are mapped to HomeController. But now all our links generated by MVC helpers (like Html.ActionLink) have .aspx extension. We don't want to expose this route as it is for backwards compatibility only. I've found the explanation and the insight I needed in Craig Stuntz's article. Generally, when building an URL, the helpers' behavior is similiar to parsing scenario. The first route that can be satisfied with route values given is chosen. We're passing controller and action values to Html.ActionLink, so the first route matches.

But Craig's article got me on the right track:

routes.MapRoute("Legacy", "{controller}.{extension}/{action}", 
new { controller = "Home", action = "Index" }, new { extension = "aspx" });
routes.MapRoute("Default", "{controller}/{action}",
new { controller = "Home", action = "Index" });

I've modified the first, legacy route, introduced the extension variable in place of "aspx" and defined a constraint in the fourth parameter, stating that extension variable have to be equal to aspx. This way only URLs like Home.aspx match the first route and ActionLink helpers don't use it (unless a route value named extension and equal to "aspx" is passed).