Trim Async suffix on action names (#7420)

Fixes https://github.com/aspnet/AspNetCore/issues/4849
This commit is contained in:
Pranav K 2019-02-12 13:30:38 -08:00 committed by GitHub
parent 6827bb7443
commit 173b2f91fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 156 additions and 7 deletions

View File

@ -296,7 +296,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
} }
else else
{ {
actionModel.ActionName = methodInfo.Name; actionModel.ActionName = CanonicalizeActionName(methodInfo.Name);
} }
var apiVisibility = attributes.OfType<IApiDescriptionVisibilityProvider>().FirstOrDefault(); var apiVisibility = attributes.OfType<IApiDescriptionVisibilityProvider>().FirstOrDefault();
@ -371,6 +371,19 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
return actionModel; 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;
}
/// <summary> /// <summary>
/// Returns <c>true</c> if the <paramref name="methodInfo"/> is an action. Otherwise <c>false</c>. /// Returns <c>true</c> if the <paramref name="methodInfo"/> is an action. Otherwise <c>false</c>.
/// </summary> /// </summary>

View File

@ -4,7 +4,9 @@
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Infrastructure;
@ -209,6 +211,26 @@ namespace Microsoft.AspNetCore.Mvc
} }
} }
/// <summary>
/// Gets or sets a value that determines if MVC will remove the suffix "Async" applied to
/// controller action names.
/// <para>
/// <see cref="ControllerActionDescriptor.ActionName"/> is used to construct the route to the action as
/// well as in view lookup. When <see langword="true"/>, MVC will trim the suffix "Async" applied
/// to action method names.
/// For example, the action name for <c>ProductsController.ListProductsAsync</c> will be
/// canonicalized as <c>ListProducts.</c>. Consequently, it will be routeable at
/// <c>/Products/ListProducts</c> with views looked up at <c>/Views/Products/ListProducts.cshtml</c>.
/// </para>
/// <para>
/// This option does not affect values specified using using <see cref="ActionNameAttribute"/>.
/// </para>
/// </summary>
/// <value>
/// The default value is <see langword="true"/>.
/// </value>
public bool SuppressAsyncSuffixInActionNames { get; set; } = true;
IEnumerator<ICompatibilitySwitch> IEnumerable<ICompatibilitySwitch>.GetEnumerator() => _switches.GetEnumerator(); IEnumerator<ICompatibilitySwitch> IEnumerable<ICompatibilitySwitch>.GetEnumerator() => _switches.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator();

View File

@ -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] [Fact]
public void CreateControllerModel_DerivedFromControllerClass_HasFilter() public void CreateControllerModel_DerivedFromControllerClass_HasFilter()
{ {
@ -1774,6 +1834,14 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
public void Edit() { } public void Edit() { }
} }
private class AsyncActionController : Controller
{
public Task<IActionResult> GetPersonAsync() => null;
[ActionName("GetRealAddressAsync")]
public Task<IActionResult> GetAddressAsync() => null;
}
private class TestApplicationModelProvider : DefaultApplicationModelProvider private class TestApplicationModelProvider : DefaultApplicationModelProvider
{ {
public TestApplicationModelProvider() public TestApplicationModelProvider()

View File

@ -1398,7 +1398,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// Act // Act
var response = await Client.DeleteAsync( var response = await Client.DeleteAsync(
$"ApiExplorerResponseTypeWithApiConventionController/DeleteProductAsync"); $"ApiExplorerResponseTypeWithApiConventionController/DeleteProduct");
var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(responseBody); var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(responseBody);

View File

@ -278,5 +278,37 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// Assert // Assert
Assert.Equal("Action exception message: This is a custom exception.", responseBody); 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());
}
} }
} }

View File

@ -452,7 +452,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
public async Task ActionMethod_ReturningSequenceOfObjectsWrappedInActionResultOfT() public async Task ActionMethod_ReturningSequenceOfObjectsWrappedInActionResultOfT()
{ {
// Arrange // Arrange
var url = "ActionResultOfT/GetProductsAsync"; var url = "ActionResultOfT/GetProducts";
// Act // Act
var response = await Client.GetStringAsync(url); var response = await Client.GetStringAsync(url);

View File

@ -3,7 +3,6 @@
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Testing.xunit;
using Xunit; using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests namespace Microsoft.AspNetCore.Mvc.FunctionalTests
@ -58,7 +57,7 @@ After flush inside partial<form action=""/FlushPoint/PageWithoutLayout"" method=
[Theory] [Theory]
[InlineData("PageWithPartialsAndViewComponents", "FlushAsync invoked inside RenderSection")] [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) public async Task FlushPointsAreExecutedForPagesWithComponentsPartialsAndSections(string action, string title)
{ {
var expected = $@"<title>{ title }</title> var expected = $@"<title>{ title }</title>

View File

@ -417,7 +417,10 @@ Partial that does not specify Layout
</layout-for-viewstart-with-layout>"; </layout-for-viewstart-with-layout>";
// Act // 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
Assert.Equal(expected, body.Trim(), ignoreLineEndingDifferences: true); Assert.Equal(expected, body.Trim(), ignoreLineEndingDifferences: true);

View File

@ -26,6 +26,17 @@ namespace BasicWebSite.Controllers
} }
} }
public async Task<IActionResult> ActionWithSuffixAsync()
{
await Task.Yield();
return Ok();
}
public Task<IActionResult> ActionReturningViewAsync()
{
return Task.FromResult<IActionResult>(View());
}
public async void AsyncVoidAction() public async void AsyncVoidAction()
{ {
await Task.Delay(SimulateDelayMilliseconds); await Task.Delay(SimulateDelayMilliseconds);

View File

@ -0,0 +1 @@
Hello world!

View File

@ -25,7 +25,7 @@ namespace RazorWebSite.Controllers
// (b) Partials rendered via PartialAsync can execute Layout. // (b) Partials rendered via PartialAsync can execute Layout.
public IActionResult PartialsRenderedViaPartialAsync() public IActionResult PartialsRenderedViaPartialAsync()
{ {
return View(); return View(nameof(PartialsRenderedViaPartialAsync));
} }
} }
} }