2

WebAPI 2 intelligently handles the Async suffix on action methods. For example, if I create a default WebAPI project it will route to the correct action regardless of the suffix. I.e.:

http://host/api/controller/action      - SUCCEEDS
http://host/api/controller/actionAsync - SUCCEEDS

However, if I create an equivalent controller using MVC 5 the behavior is different:

http://host/controller/actionAsync - SUCCEEDS
http://host/controller/action      - FAILS - 404

The fact that it fails with a 404 when the Async suffix isn't present is surprising. Nevertheless, I tried to add a route to handle it, but it still fails:

routes.MapRoute(
    name: "DefaultAsync",
    url: "{controller}/{action}Async/{id}",
    defaults: new {controller = "Home", action = "Index", id = UrlParameter.Optional}
    );

Here's the MVC and WebAPI controllers that I used to test (based on a new MVC/WebAPI project with default routes):

public class SampleDto { public string Name; public int Age; }

public class SampleMvcController : Controller
{
    public Task<JsonResult> GetJsonAsync()
    {
        // Illustration Only. This is not a good usage of an Async method,
        // in reality I'm calling out to an Async service.
        return Task.FromResult(
            Json(new SampleDto { Name="Foo", Age = 42}, JsonRequestBehavior.AllowGet));
    }
}

public class SampleWebApiController : ApiController
{
    public Task<SampleDto> GetJsonAsync()
    {
        return Task.FromResult(new SampleDto {Name="Bar", Age=24});
    }
}

As I'm in the middle of making a bunch of methods async, I'd prefer not to specify an action name. The routing documentation suggests that it can pick up literals which can separate segments, but I haven't had any luck yet.

UPDATE: The problem is that the Action as retrieved by MVC contains the Async suffix, but no corresponding action (or action name) exists on the controller. The piece that matches the action, MyAction, doesn't identify MyActionAsync as a match.

In retrospect, that's why the route doesn't work. It attempts to identify the action as ending with Async but leave off the Async suffix from the action used in matching, which is not what I wanted to do. It would be useful in the event that I wanted to create only a MyAction method (that was async but didn't follow the Async naming convention) and have it map to the corresponding MyAction method.

7
  • possibly related stackoverflow.com/questions/22664924/… Commented Feb 24, 2016 at 21:31
  • Have you placed the route you posted before the default route? Commented Feb 24, 2016 at 21:48
  • @NightOwl888 - Yes, it's ordered before. I've updated to the post to better describe the actual problem. Commented Feb 24, 2016 at 22:10
  • So, I take it what you said initially, that your xxxAsync action is succeeding is incorrect? Your update directly contradicts what you said initially. Commented Feb 25, 2016 at 12:38
  • 1
    @NightOwl888 - No, it's correct. It succeeds (i.e., returns a 200) when the URL contains <action>Async but not when the URL contains only <action>. My goal is to make MVC see the method <action>Async as corresponding to the <action> retrieved from the URL so I don't need to either (a) decorate countless methods with the action name, or (b) violate the Async naming conventions. Commented Feb 25, 2016 at 16:37

2 Answers 2

1

Initially I was sorely disappointed that the AsyncController type accomplishes exactly what we're looking for out-of-the-box. But apparently it's old-hat and is only still around for "backwards compatibility with MVC3."

So what I ended up doing was making a custom AsyncControllerActionInvoker and assigning it to a custom controller's ActionInvoker. The CustomAsyncControllerActionInvoker overrides the BeginInvokeAction method to see if an action method ending in "Async" for the appropriate action exists (ex. you pass in "Index" it looks for "IndexAsync"). If it does, invoke that one instead, otherwise, continue on as you were.

public class HomeController : CustomController
{
    public async Task<ActionResult> IndexAsync()
    {
        ViewBag.Header = "I am a page header."
        var model = new List<int> {1, 2, 3, 4, 5};
        await Task.Run(() => "I'm a task");
        return View(model);
    }
    public ActionResult About()
    {
        return View();
    }
}

public class CustomController : Controller
{
    public CustomController()
    {
        ActionInvoker = new CustomAsyncControllerActionInvoker();
    }
}

public class CustomAsyncControllerActionInvoker : AsyncControllerActionInvoker
{
    public override IAsyncResult BeginInvokeAction(ControllerContext controllerContext, string actionName, AsyncCallback callback, object state)
    {
        var asyncAction = FindAction(controllerContext, GetControllerDescriptor(controllerContext), $"{actionName}Async");
        return asyncAction != null
            ? base.BeginInvokeAction(controllerContext, $"{actionName}Async", callback, state)
            : base.BeginInvokeAction(controllerContext, actionName, callback, state);
    }
}

Alas, navigating to /Home/Index properly calls the IndexAsync() method and serves up the Index.cshtml (not IndexAsync.cshtml) view appropriately. Routes to synchronous actions (ex. "About") are handled as normal.

Sign up to request clarification or add additional context in comments.

Comments

0

Instead of trying to put a literal together with a placeholder, you should make a constraint to ensure your action name ends with Async.

routes.MapRoute(
    name: "DefaultAsync",
    url: "{controller}/{action}/{id}",
    defaults: new { controller = "Home", action = "IndexAsync", id = UrlParameter.Optional },
    constraints: new { action = @".*?Async" }
);

routes.MapRoute(
    name: "Default",
    url: "{controller}/{action}/{id}",
    defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);

3 Comments

The constraint regex seems off as that's looking for zero or more literal dots.
@KalebPederson I do not think so, it means between zero and unlimited characters, however i think ".+Async" would have been better. What do you think @NightOwl888?
The post was edited after my original comment. It is reasonable now.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.