Search code examples
c#asp.netasp.net-mvcrazorroutes

@Url.RouteUrl returns misleading/wrong path for a controller-action (unless the 'routeName' parameter is explicitly specified)


Assume we have two controllers ControllerA and ControllerB in an MVC4 project placed like so:

  Controllers
  |
  \- MvcApi
  |   |
  |   \- ControllerB.cs
  |
  \- ControllerA.cs

The concept is that ControllerB is an mvc-controller which -however- serves html-razor-partial-views purely via ajax-calls. ControllerA on the other hand is a salt-of-the-earth mvc controller. In RouteConfig.cs we have:

public class MvcRouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "MvcApi",
            url: "mvcapi/{controller}/{action}/{id}",
            defaults: new { id = UrlParameter.Optional },
            namespaces: new[] { typeof(BController).Namespace } //<- "Project.Views.MvcApi"
        );

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "AController", action = "Index", id = UrlParameter.Optional },
            namespaces: new[] { typeof(AController).Namespace } //<- "Project.Views"
        );
    }
}

We now try to auto-generate a url pointing to an action of the AController like so:

 @(Url.RouteUrl(routeValues: new { controller = "A", action = nameof(AController.SomeActionNameHere) }))

Given the configuration shown above we would expect the above statement to result in:

"/A/SomeActionNameHere"

However what we do get is:

"/mvcapi/A/SomeActionNameHere"

This ofcourse works in runtime however it's completely misleading ("just because it's working it doesn't mean it's right"). We can enforce the desired behaviour by specifying the route-name explicitly:

@(Url.RouteUrl(
     routeName: "Default",  // <- this "fixes" the issue
     routeValues: new { controller = "A", action = nameof(AController.SomeActionNameHere) })
) 

This does indeed return the desired url:

"/A/SomeActionNameHere"

However I am buffled by the fact that the asp.net infrastructure gets clogged so easily and ends up needing so much "hand-holding" in order for it to figure out that AController lives directly under the "Views" namespace and not under "Views.MvcApi". One would expect that issuying a simple:

 @(Url.RouteUrl(routeValues: new { controller = "A", action = nameof(AController.SomeActionNameHere) }))

Would auto-magically return the correct url straight away. What's going on?

Addendum: I've also inspected the 2 web.config files of the project and can't spot anything fishy. If you think that there might be something there bug me and I will update this post with the needed snippets for you to inspect them.


Solution

  • Every request to build a url goes through the collection of defined routes and uses the first one that meets the requirements. You have defined two routes. Both o them requires {controller}, {action} and {id} which is exactly what you provide (ID is optional). During this process it doesn't check whether the controller or action exists, it's your responsibility to make sure your mapping won't create url that doesn't exists. And since your MvcApi route is the first in the list, MVC uses this definition to build the URL for you.

    In order to avoid this behavior, you can leave out the {controller} parameter completely and change it to this:

    routes.MapRoute(
        name: "MvcApi",
        url: "mvcapi/BController/{action}/{id}",
        defaults: new { id = UrlParameter.Optional },
        namespaces: new[] { typeof(BController).Namespace } //<- "Project.Views.MvcApi"
    );
    
    routes.MapRoute(
        name: "Default",
        url: "AController/{action}/{id}",
        defaults: new { action = "Index", id = UrlParameter.Optional },
        namespaces: new[] { typeof(AController).Namespace } //<- "Project.Views"
    );
    

    Or for example use contsraints

    routes.MapRoute(
        name: "Default",
        url: "AController/{action}/{id}",
        defaults: new { action = "Index", id = UrlParameter.Optional },
        constraints: new { controller = @"(BController|SomeOtherControllerName)" },
        namespaces: new[] { typeof(AController).Namespace } //<- "Project.Views"
    );
    

    That will help you to add more rules to the route.

    You can also play with the order of the mapped routes if it helps.

    Or you can keep using the name of the route. That's absolutely valid way how to tell MVC which URL you want to render.