From 173b2f91fba18ecbb1f85ef3f7625aa6dd1ace71 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Tue, 12 Feb 2019 13:30:38 -0800 Subject: [PATCH] Trim Async suffix on action names (#7420) Fixes https://github.com/aspnet/AspNetCore/issues/4849 --- .../DefaultApplicationModelProvider.cs | 15 +++- .../MvcOptions.cs | 22 ++++++ .../DefaultApplicationModelProviderTest.cs | 68 +++++++++++++++++++ .../ApiExplorerTest.cs | 2 +- .../AsyncActionsTests.cs | 32 +++++++++ .../BasicTests.cs | 2 +- .../FlushPointTest.cs | 3 +- .../ViewEngineTests.cs | 5 +- .../Controllers/AsyncActionsController.cs | 11 +++ .../AsyncActions/ActionReturningView.cshtml | 1 + .../PartialsWithLayoutController.cs | 2 +- 11 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 src/Mvc/test/WebSites/BasicWebSite/Views/AsyncActions/ActionReturningView.cshtml 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 4a74df218d..6abac4e65a 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 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels } else { - actionModel.ActionName = methodInfo.Name; + actionModel.ActionName = CanonicalizeActionName(methodInfo.Name); } var apiVisibility = attributes.OfType().FirstOrDefault(); @@ -371,6 +371,19 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels 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 88890703c7..19626413b2 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 @@ namespace Microsoft.AspNetCore.Mvc } } + /// + /// 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 a28985b65f..0acf19ed5a 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 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels }); } + [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 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels 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 8df0fb27e2..93c89ecd54 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // 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 dc8e634ef4..4a6a1699bf 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // 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 ccb8189ac2..33b3355ce6 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests 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 1ea8536830..ebbba5d9e6 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 @@ After flush inside partial
{ title } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs index e2c9f20430..0ef2bb723b 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 446c387009..b9a5b68853 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/AsyncActionsController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/AsyncActionsController.cs @@ -26,6 +26,17 @@ namespace BasicWebSite.Controllers } } + 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 0000000000..d2d010c8f2 --- /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 74e767a042..070892c747 100644 --- a/src/Mvc/test/WebSites/RazorWebSite/Controllers/PartialsWithLayoutController.cs +++ b/src/Mvc/test/WebSites/RazorWebSite/Controllers/PartialsWithLayoutController.cs @@ -25,7 +25,7 @@ namespace RazorWebSite.Controllers // (b) Partials rendered via PartialAsync can execute Layout. public IActionResult PartialsRenderedViaPartialAsync() { - return View(); + return View(nameof(PartialsRenderedViaPartialAsync)); } } }