diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/DefaultApplicationModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/DefaultApplicationModelProvider.cs index 4a74df218dbd..6abac4e65ae5 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/DefaultApplicationModelProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/DefaultApplicationModelProvider.cs @@ -296,7 +296,7 @@ internal ActionModel CreateActionModel( } else { - actionModel.ActionName = methodInfo.Name; + actionModel.ActionName = CanonicalizeActionName(methodInfo.Name); } var apiVisibility = attributes.OfType().FirstOrDefault(); @@ -371,6 +371,19 @@ internal ActionModel CreateActionModel( return actionModel; } + private string CanonicalizeActionName(string actionName) + { + const string Suffix = "Async"; + + if (_mvcOptions.SuppressAsyncSuffixInActionNames && + actionName.EndsWith(Suffix, StringComparison.Ordinal)) + { + actionName = actionName.Substring(0, actionName.Length - Suffix.Length); + } + + return actionName; + } + /// /// Returns true if the is an action. Otherwise false. /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs index 88890703c729..19626413b2bb 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs @@ -4,7 +4,9 @@ using System; using System.Collections; using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -209,6 +211,26 @@ public int? MaxValidationDepth } } + /// + /// Gets or sets a value that determines if MVC will remove the suffix "Async" applied to + /// controller action names. + /// + /// is used to construct the route to the action as + /// well as in view lookup. When , MVC will trim the suffix "Async" applied + /// to action method names. + /// For example, the action name for ProductsController.ListProductsAsync will be + /// canonicalized as ListProducts.. Consequently, it will be routeable at + /// /Products/ListProducts with views looked up at /Views/Products/ListProducts.cshtml. + /// + /// + /// This option does not affect values specified using using . + /// + /// + /// + /// The default value is . + /// + public bool SuppressAsyncSuffixInActionNames { get; set; } = true; + IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/DefaultApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/DefaultApplicationModelProviderTest.cs index a28985b65ff8..0acf19ed5ab5 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/DefaultApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/DefaultApplicationModelProviderTest.cs @@ -293,6 +293,66 @@ public void OnProvidersExecuting_UsesBindingSourceSpecifiedOnParameter() }); } + [Fact] + public void OnProvidersExecuting_RemovesAsyncSuffix_WhenOptionIsSet() + { + // Arrange + var options = new MvcOptions(); + var provider = new TestApplicationModelProvider(options, new EmptyModelMetadataProvider()); + var typeInfo = typeof(AsyncActionController).GetTypeInfo(); + var methodInfo = typeInfo.GetMethod(nameof(AsyncActionController.GetPersonAsync)); + + var context = new ApplicationModelProviderContext(new[] { typeInfo }); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var controllerModel = Assert.Single(context.Result.Controllers); + var action = Assert.Single(controllerModel.Actions, a => a.ActionMethod == methodInfo); + Assert.Equal("GetPerson", action.ActionName); + } + + [Fact] + public void OnProvidersExecuting_DoesNotRemoveAsyncSuffix_WhenOptionIsDisabled() + { + // Arrange + var options = new MvcOptions { SuppressAsyncSuffixInActionNames = false }; + var provider = new TestApplicationModelProvider(options, new EmptyModelMetadataProvider()); + var typeInfo = typeof(AsyncActionController).GetTypeInfo(); + var methodInfo = typeInfo.GetMethod(nameof(AsyncActionController.GetPersonAsync)); + + var context = new ApplicationModelProviderContext(new[] { typeInfo }); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var controllerModel = Assert.Single(context.Result.Controllers); + var action = Assert.Single(controllerModel.Actions, a => a.ActionMethod == methodInfo); + Assert.Equal(nameof(AsyncActionController.GetPersonAsync), action.ActionName); + } + + [Fact] + public void OnProvidersExecuting_DoesNotRemoveAsyncSuffix_WhenActionNameIsSpecifiedUsingActionNameAttribute() + { + // Arrange + var options = new MvcOptions(); + var provider = new TestApplicationModelProvider(options, new EmptyModelMetadataProvider()); + var typeInfo = typeof(AsyncActionController).GetTypeInfo(); + var methodInfo = typeInfo.GetMethod(nameof(AsyncActionController.GetAddressAsync)); + + var context = new ApplicationModelProviderContext(new[] { typeInfo }); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var controllerModel = Assert.Single(context.Result.Controllers); + var action = Assert.Single(controllerModel.Actions, a => a.ActionMethod == methodInfo); + Assert.Equal("GetRealAddressAsync", action.ActionName); + } + [Fact] public void CreateControllerModel_DerivedFromControllerClass_HasFilter() { @@ -1774,6 +1834,14 @@ private class MultipleRouteProviderOnActionController public void Edit() { } } + private class AsyncActionController : Controller + { + public Task GetPersonAsync() => null; + + [ActionName("GetRealAddressAsync")] + public Task GetAddressAsync() => null; + } + private class TestApplicationModelProvider : DefaultApplicationModelProvider { public TestApplicationModelProvider() diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs index 8df0fb27e26b..93c89ecd543d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -1398,7 +1398,7 @@ public async Task ApiConvention_ForDeleteActionThatMatchesConvention() // Act var response = await Client.DeleteAsync( - $"ApiExplorerResponseTypeWithApiConventionController/DeleteProductAsync"); + $"ApiExplorerResponseTypeWithApiConventionController/DeleteProduct"); var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); var result = JsonConvert.DeserializeObject>(responseBody); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AsyncActionsTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AsyncActionsTests.cs index dc8e634ef4cf..4a6a1699bf4e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AsyncActionsTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AsyncActionsTests.cs @@ -278,5 +278,37 @@ public async Task CustomAwaitableOfContentResultExceptionAction_ReturnsCorrectEr // Assert Assert.Equal("Action exception message: This is a custom exception.", responseBody); } + + [Fact] + public async Task AsyncSuffixIsIgnored() + { + // Act + var response = await Client.GetAsync("AsyncActions/ActionWithSuffix"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + } + + [Fact] + public async Task ActionIsNotRoutedWithAsyncSuffix() + { + // Act + var response = await Client.GetAsync("AsyncActions/ActionWithSuffixAsync"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ViewLookupWithAsyncSuffix() + { + // Act + var response = await Client.GetAsync("AsyncActions/ActionReturningView"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello world!", content.Trim()); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs index ccb8189ac293..33b3355ce6ce 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs @@ -452,7 +452,7 @@ public async Task ActionMethod_ReturningActionMethodOfT() public async Task ActionMethod_ReturningSequenceOfObjectsWrappedInActionResultOfT() { // Arrange - var url = "ActionResultOfT/GetProductsAsync"; + var url = "ActionResultOfT/GetProducts"; // Act var response = await Client.GetStringAsync(url); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FlushPointTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FlushPointTest.cs index 1ea853683090..ebbba5d9e6c0 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FlushPointTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FlushPointTest.cs @@ -3,7 +3,6 @@ using System.Net.Http; using System.Threading.Tasks; -using Microsoft.AspNetCore.Testing.xunit; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -58,7 +57,7 @@ Inside partial [Theory] [InlineData("PageWithPartialsAndViewComponents", "FlushAsync invoked inside RenderSection")] - [InlineData("PageWithRenderSectionAsync", "FlushAsync invoked inside RenderSectionAsync")] + [InlineData("PageWithRenderSection", "FlushAsync invoked inside RenderSectionAsync")] public async Task FlushPointsAreExecutedForPagesWithComponentsPartialsAndSections(string action, string title) { var expected = $@"{ title } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs index e2c9f2043038..0ef2bb723be9 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs @@ -417,7 +417,10 @@ Partial that does not specify Layout "; // Act - var body = await Client.GetStringAsync("http://localhost/PartialsWithLayout/PartialsRenderedViaPartialAsync"); + var response = await Client.GetAsync("http://localhost/PartialsWithLayout/PartialsRenderedViaPartial"); + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); // Assert Assert.Equal(expected, body.Trim(), ignoreLineEndingDifferences: true); diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/AsyncActionsController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/AsyncActionsController.cs index 446c387009b8..b9a5b688535b 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/AsyncActionsController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/AsyncActionsController.cs @@ -26,6 +26,17 @@ public override void OnActionExecuted(ActionExecutedContext context) } } + public async Task ActionWithSuffixAsync() + { + await Task.Yield(); + return Ok(); + } + + public Task ActionReturningViewAsync() + { + return Task.FromResult(View()); + } + public async void AsyncVoidAction() { await Task.Delay(SimulateDelayMilliseconds); diff --git a/src/Mvc/test/WebSites/BasicWebSite/Views/AsyncActions/ActionReturningView.cshtml b/src/Mvc/test/WebSites/BasicWebSite/Views/AsyncActions/ActionReturningView.cshtml new file mode 100644 index 000000000000..d2d010c8f2f4 --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/Views/AsyncActions/ActionReturningView.cshtml @@ -0,0 +1 @@ +Hello world! \ No newline at end of file diff --git a/src/Mvc/test/WebSites/RazorWebSite/Controllers/PartialsWithLayoutController.cs b/src/Mvc/test/WebSites/RazorWebSite/Controllers/PartialsWithLayoutController.cs index 74e767a04270..070892c74706 100644 --- a/src/Mvc/test/WebSites/RazorWebSite/Controllers/PartialsWithLayoutController.cs +++ b/src/Mvc/test/WebSites/RazorWebSite/Controllers/PartialsWithLayoutController.cs @@ -25,7 +25,7 @@ public IActionResult PartialsRenderedViaRenderPartial() // (b) Partials rendered via PartialAsync can execute Layout. public IActionResult PartialsRenderedViaPartialAsync() { - return View(); + return View(nameof(PartialsRenderedViaPartialAsync)); } } }