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
{
actionModel.ActionName = methodInfo.Name;
actionModel.ActionName = CanonicalizeActionName(methodInfo.Name);
}
var apiVisibility = attributes.OfType<IApiDescriptionVisibilityProvider>().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;
}
/// <summary>
/// Returns <c>true</c> if the <paramref name="methodInfo"/> is an action. Otherwise <c>false</c>.
/// </summary>

View File

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

View File

@ -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<List<ApiExplorerData>>(responseBody);

View File

@ -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());
}
}
}

View File

@ -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);

View File

@ -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<form action=""/FlushPoint/PageWithoutLayout"" method=
[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>{ title }</title>

View File

@ -417,7 +417,10 @@ Partial that does not specify Layout
</layout-for-viewstart-with-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);

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()
{
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.
public IActionResult PartialsRenderedViaPartialAsync()
{
return View();
return View(nameof(PartialsRenderedViaPartialAsync));
}
}
}