Search code examples
c#asp.netasp.net-mvc-5url-routingasp.net-mvc-routing

Attribute route shows up as query string


I am using attribute routing and this is how I mapped one of my create actions:

[Route("Create/{companyId:guid?}/{branchId:guid?}/{departmentId:guid?}")]
[Route("Create/{companyId:guid?}/{departmentId:guid?}")]
public async Task<ActionResult> Create(Guid? companyId, Guid? branchId, Guid? departmentId)

I also tried with two different actions that map to the same action name, like this:

[Route("Create/{companyId:guid?}/{branchId:guid?}/{departmentId:guid?}")]
public async Task<ActionResult> Create(Guid? companyId, Guid? branchId, Guid? departmentId)


[Route("Create/{companyId:guid?}/{departmentId:guid?}")]
[ActionName("Create")]
public async Task<ActionResult> CreateWithDepartmentOnly(Guid? companyId, Guid? departmentId)

It's set up like this because there might not be a branchId and you can't have an empty parameter.

Works OK when only the companyIdis provided, or when both the companyId and the departmentId are provided.

When the branchId is provided, that ID is added as query string, not as a routing parameter. This happens when I had two different actions and also doesn't matter whether the departmentId is provided.

My code works in all scenarios, I just want to clean up the URL and not have any query strings there. Any solution or workarounds?

EDIT 1

I tried both of Slicksim's ideas.

  • Setting the order of the routes had no effect
  • Naming the routes did the trick, but only if all 3 IDs are provided, leaving out the departmentId which is the last parameter should have no effect, but instead the branchId got ignored

EDIT 2

The logic goes like this:

  • A Company can have branches and/or departments
  • A Branch can have departments
  • A Department can have departments
  • A Department doesn't have to have a branch

Depending on where you're creating the the department from, those IDs are in the route. Is this a viable solution?

[Route("Create/{companyId:guid}")]
[Route("Create/{companyId:guid}/{branchId:guid?}")]
[Route("Create/{companyId:guid}/{departmentId:guid?}")]
[Route("Create/{companyId:guid}/{branchId:guid?}/{departmentId:guid?}")]
public async Task<ActionResult> Create(Guid companyId, Guid? branchId, Guid? departmentId)

Are the routes 2 and 3 going to work since they both have same number and type of parameters. If the Id is placed in the correctly named parameters in the action then I'm golden.


Solution

  • Check the following:

    1. Make sure you have placed a call to routes.MapMvcAttributeRoutes() in your RouteConfig (or somewhere in your application startup).
    2. Make sure you have placed the call to routes.MapMvcAttributeRoutes() before the default route.

    public class RouteConfig
    {
        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
    
            // Add this to get attribute routes to work
            routes.MapMvcAttributeRoutes();
    
            routes.MapRoute(
                name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
            );
        }
    }
    

    Also, you are shooting yourself in the foot by making the parameters optional. Only 1 optional parameter can be used on a route and it must be the right most parameter. The second route here is an unreachable execution path because all 3 of the first parameters are optional.

    [Route("Create/{companyId:guid?}/{branchId:guid?}/{departmentId:guid?}")]
    [Route("Create/{companyId:guid?}/{departmentId:guid?}")]
    

    Technically, you can do what (I think) you want with a single route.

    [Route("Create/{companyId:guid}/{departmentId:guid}/{branchId:guid?}")]
    public async Task<ActionResult> Create(Guid companyId, Guid departmentId, Guid? branchId)
    

    Which will match

    /Create/someCompanyId/someDepartmentId/someBranchId
    /Create/someCompanyId/someDepartmentId
    

    If you do in fact want all parameters to be optional, the best way is to use the query string. Routes aren't designed for that sort of thing, but if you really want to go there using routing, see this answer.

    Answer to Edit 2

    There is nothing wrong with removing the default route. However, your business logic is a bit too complex for the default routing to work. You are correct in your assessment that the 2nd and 3rd routes are indistinguishable from one another (even if you made the parameters required) because the patterns are the same.

    But .NET routing is more flexible than what MapRoute or the Route attribute can provide alone.

    Also, if these URLs are Internet facing (not behind a login), this is not an SEO friendly approach. It would be far better for search engine placement to use company, branch, department, and subdepartment names in the URL.

    Whether or not you decide to stick with the GUID scheme, you have a unique set of URLs, which is all that is required to get a match. You would need to load each of the possible URLs into a cache that you can check against the incoming request, and then you can supply the route values manually by creating a custom RouteBase as in this answer. You just need to change the PageInfo object to hold all 3 of your GUID values and update the routes accordingly. Do note however it would be better (less logic to implement) if you did a table join and came up with a single GUID to represent each URL as in the linked answer.

    public class PageInfo
    {
        public PageInfo()
        {
            CompanyId = Guid.Empty;
            BranchId = Guid.Empty;
            DepartmentId = Guid.Empty;
        }
    
        // VirtualPath should not have a leading slash
        // example: events/conventions/mycon
        public string VirtualPath { get; set; }
        public Guid CompanyId { get; set; }
        public Guid BranchId { get; set; }
        public Guid DepartmentId { get; set; }
    }
    

    In GetRouteData

    result.Values["controller"] = "CustomPage";
    result.Values["action"] = "Details";
    
    if (!page.CompanyId.Equals(Guid.Empty))
    {
        result.Values["companyId"] = page.CompanyId;
    }
    
    if (!page.BranchId.Equals(Guid.Empty))
    {
        result.Values["branchId"] = page.BranchId;
    }
    
    if (!page.DepartmentId.Equals(Guid.Empty))
    {
        result.Values["departmentId"] = page.DepartmentId;
    }
    

    In TryFindMatch

    Guid companyId = Guid.Empty;
    Guid branchId = Guid.Empty;
    Guid departmentId = Guid.Empty;
    
    Guid.TryParse(Convert.ToString(values["companyId"]), out companyId);
    Guid.TryParse(Convert.ToString(values["branchId"]), out branchId);
    Guid.TryParse(Convert.ToString(values["departmentId"]), out departmentId);
    
    var controller = Convert.ToString(values["controller"]);
    var action = Convert.ToString(values["action"]);
    
    
    if (action == "Details" && controller == "CustomPage")
    {
        page = pages
            .Where(x => x.CompanyId.Equals(companyId) && 
                x.BranchId.Equals(branchId) && 
                x.DepartmentId.Equals(departmentId))
            .FirstOrDefault();
        if (page != null)
        {
            return true;
        }
    }
    return false;