Skip to content

Commit 173b2f9

Browse files
authored
Trim Async suffix on action names (#7420)
Fixes #4849
1 parent 6827bb7 commit 173b2f9

File tree

11 files changed

+156
-7
lines changed

11 files changed

+156
-7
lines changed

src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/DefaultApplicationModelProvider.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ internal ActionModel CreateActionModel(
296296
}
297297
else
298298
{
299-
actionModel.ActionName = methodInfo.Name;
299+
actionModel.ActionName = CanonicalizeActionName(methodInfo.Name);
300300
}
301301

302302
var apiVisibility = attributes.OfType<IApiDescriptionVisibilityProvider>().FirstOrDefault();
@@ -371,6 +371,19 @@ internal ActionModel CreateActionModel(
371371
return actionModel;
372372
}
373373

374+
private string CanonicalizeActionName(string actionName)
375+
{
376+
const string Suffix = "Async";
377+
378+
if (_mvcOptions.SuppressAsyncSuffixInActionNames &&
379+
actionName.EndsWith(Suffix, StringComparison.Ordinal))
380+
{
381+
actionName = actionName.Substring(0, actionName.Length - Suffix.Length);
382+
}
383+
384+
return actionName;
385+
}
386+
374387
/// <summary>
375388
/// Returns <c>true</c> if the <paramref name="methodInfo"/> is an action. Otherwise <c>false</c>.
376389
/// </summary>

src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
using System;
55
using System.Collections;
66
using System.Collections.Generic;
7+
using Microsoft.AspNetCore.Mvc.Abstractions;
78
using Microsoft.AspNetCore.Mvc.ApplicationModels;
9+
using Microsoft.AspNetCore.Mvc.Controllers;
810
using Microsoft.AspNetCore.Mvc.Filters;
911
using Microsoft.AspNetCore.Mvc.Formatters;
1012
using Microsoft.AspNetCore.Mvc.Infrastructure;
@@ -209,6 +211,26 @@ public int? MaxValidationDepth
209211
}
210212
}
211213

214+
/// <summary>
215+
/// Gets or sets a value that determines if MVC will remove the suffix "Async" applied to
216+
/// controller action names.
217+
/// <para>
218+
/// <see cref="ControllerActionDescriptor.ActionName"/> is used to construct the route to the action as
219+
/// well as in view lookup. When <see langword="true"/>, MVC will trim the suffix "Async" applied
220+
/// to action method names.
221+
/// For example, the action name for <c>ProductsController.ListProductsAsync</c> will be
222+
/// canonicalized as <c>ListProducts.</c>. Consequently, it will be routeable at
223+
/// <c>/Products/ListProducts</c> with views looked up at <c>/Views/Products/ListProducts.cshtml</c>.
224+
/// </para>
225+
/// <para>
226+
/// This option does not affect values specified using using <see cref="ActionNameAttribute"/>.
227+
/// </para>
228+
/// </summary>
229+
/// <value>
230+
/// The default value is <see langword="true"/>.
231+
/// </value>
232+
public bool SuppressAsyncSuffixInActionNames { get; set; } = true;
233+
212234
IEnumerator<ICompatibilitySwitch> IEnumerable<ICompatibilitySwitch>.GetEnumerator() => _switches.GetEnumerator();
213235

214236
IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator();

src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/DefaultApplicationModelProviderTest.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,66 @@ public void OnProvidersExecuting_UsesBindingSourceSpecifiedOnParameter()
293293
});
294294
}
295295

296+
[Fact]
297+
public void OnProvidersExecuting_RemovesAsyncSuffix_WhenOptionIsSet()
298+
{
299+
// Arrange
300+
var options = new MvcOptions();
301+
var provider = new TestApplicationModelProvider(options, new EmptyModelMetadataProvider());
302+
var typeInfo = typeof(AsyncActionController).GetTypeInfo();
303+
var methodInfo = typeInfo.GetMethod(nameof(AsyncActionController.GetPersonAsync));
304+
305+
var context = new ApplicationModelProviderContext(new[] { typeInfo });
306+
307+
// Act
308+
provider.OnProvidersExecuting(context);
309+
310+
// Assert
311+
var controllerModel = Assert.Single(context.Result.Controllers);
312+
var action = Assert.Single(controllerModel.Actions, a => a.ActionMethod == methodInfo);
313+
Assert.Equal("GetPerson", action.ActionName);
314+
}
315+
316+
[Fact]
317+
public void OnProvidersExecuting_DoesNotRemoveAsyncSuffix_WhenOptionIsDisabled()
318+
{
319+
// Arrange
320+
var options = new MvcOptions { SuppressAsyncSuffixInActionNames = false };
321+
var provider = new TestApplicationModelProvider(options, new EmptyModelMetadataProvider());
322+
var typeInfo = typeof(AsyncActionController).GetTypeInfo();
323+
var methodInfo = typeInfo.GetMethod(nameof(AsyncActionController.GetPersonAsync));
324+
325+
var context = new ApplicationModelProviderContext(new[] { typeInfo });
326+
327+
// Act
328+
provider.OnProvidersExecuting(context);
329+
330+
// Assert
331+
var controllerModel = Assert.Single(context.Result.Controllers);
332+
var action = Assert.Single(controllerModel.Actions, a => a.ActionMethod == methodInfo);
333+
Assert.Equal(nameof(AsyncActionController.GetPersonAsync), action.ActionName);
334+
}
335+
336+
[Fact]
337+
public void OnProvidersExecuting_DoesNotRemoveAsyncSuffix_WhenActionNameIsSpecifiedUsingActionNameAttribute()
338+
{
339+
// Arrange
340+
var options = new MvcOptions();
341+
var provider = new TestApplicationModelProvider(options, new EmptyModelMetadataProvider());
342+
var typeInfo = typeof(AsyncActionController).GetTypeInfo();
343+
var methodInfo = typeInfo.GetMethod(nameof(AsyncActionController.GetAddressAsync));
344+
345+
var context = new ApplicationModelProviderContext(new[] { typeInfo });
346+
347+
// Act
348+
provider.OnProvidersExecuting(context);
349+
350+
// Assert
351+
var controllerModel = Assert.Single(context.Result.Controllers);
352+
var action = Assert.Single(controllerModel.Actions, a => a.ActionMethod == methodInfo);
353+
Assert.Equal("GetRealAddressAsync", action.ActionName);
354+
}
355+
296356
[Fact]
297357
public void CreateControllerModel_DerivedFromControllerClass_HasFilter()
298358
{
@@ -1774,6 +1834,14 @@ private class MultipleRouteProviderOnActionController
17741834
public void Edit() { }
17751835
}
17761836

1837+
private class AsyncActionController : Controller
1838+
{
1839+
public Task<IActionResult> GetPersonAsync() => null;
1840+
1841+
[ActionName("GetRealAddressAsync")]
1842+
public Task<IActionResult> GetAddressAsync() => null;
1843+
}
1844+
17771845
private class TestApplicationModelProvider : DefaultApplicationModelProvider
17781846
{
17791847
public TestApplicationModelProvider()

src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1398,7 +1398,7 @@ public async Task ApiConvention_ForDeleteActionThatMatchesConvention()
13981398

13991399
// Act
14001400
var response = await Client.DeleteAsync(
1401-
$"ApiExplorerResponseTypeWithApiConventionController/DeleteProductAsync");
1401+
$"ApiExplorerResponseTypeWithApiConventionController/DeleteProduct");
14021402
var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
14031403
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(responseBody);
14041404

src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AsyncActionsTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,5 +278,37 @@ public async Task CustomAwaitableOfContentResultExceptionAction_ReturnsCorrectEr
278278
// Assert
279279
Assert.Equal("Action exception message: This is a custom exception.", responseBody);
280280
}
281+
282+
[Fact]
283+
public async Task AsyncSuffixIsIgnored()
284+
{
285+
// Act
286+
var response = await Client.GetAsync("AsyncActions/ActionWithSuffix");
287+
288+
// Assert
289+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
290+
}
291+
292+
[Fact]
293+
public async Task ActionIsNotRoutedWithAsyncSuffix()
294+
{
295+
// Act
296+
var response = await Client.GetAsync("AsyncActions/ActionWithSuffixAsync");
297+
298+
// Assert
299+
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
300+
}
301+
302+
[Fact]
303+
public async Task ViewLookupWithAsyncSuffix()
304+
{
305+
// Act
306+
var response = await Client.GetAsync("AsyncActions/ActionReturningView");
307+
308+
// Assert
309+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
310+
var content = await response.Content.ReadAsStringAsync();
311+
Assert.Equal("Hello world!", content.Trim());
312+
}
281313
}
282314
}

src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ public async Task ActionMethod_ReturningActionMethodOfT()
452452
public async Task ActionMethod_ReturningSequenceOfObjectsWrappedInActionResultOfT()
453453
{
454454
// Arrange
455-
var url = "ActionResultOfT/GetProductsAsync";
455+
var url = "ActionResultOfT/GetProducts";
456456

457457
// Act
458458
var response = await Client.GetStringAsync(url);

src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FlushPointTest.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System.Net.Http;
55
using System.Threading.Tasks;
6-
using Microsoft.AspNetCore.Testing.xunit;
76
using Xunit;
87

98
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
@@ -58,7 +57,7 @@ Inside partial
5857

5958
[Theory]
6059
[InlineData("PageWithPartialsAndViewComponents", "FlushAsync invoked inside RenderSection")]
61-
[InlineData("PageWithRenderSectionAsync", "FlushAsync invoked inside RenderSectionAsync")]
60+
[InlineData("PageWithRenderSection", "FlushAsync invoked inside RenderSectionAsync")]
6261
public async Task FlushPointsAreExecutedForPagesWithComponentsPartialsAndSections(string action, string title)
6362
{
6463
var expected = $@"<title>{ title }</title>

src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,10 @@ Partial that does not specify Layout
417417
</layout-for-viewstart-with-layout>";
418418

419419
// Act
420-
var body = await Client.GetStringAsync("http://localhost/PartialsWithLayout/PartialsRenderedViaPartialAsync");
420+
var response = await Client.GetAsync("http://localhost/PartialsWithLayout/PartialsRenderedViaPartial");
421+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
422+
423+
var body = await response.Content.ReadAsStringAsync();
421424

422425
// Assert
423426
Assert.Equal(expected, body.Trim(), ignoreLineEndingDifferences: true);

src/Mvc/test/WebSites/BasicWebSite/Controllers/AsyncActionsController.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ public override void OnActionExecuted(ActionExecutedContext context)
2626
}
2727
}
2828

29+
public async Task<IActionResult> ActionWithSuffixAsync()
30+
{
31+
await Task.Yield();
32+
return Ok();
33+
}
34+
35+
public Task<IActionResult> ActionReturningViewAsync()
36+
{
37+
return Task.FromResult<IActionResult>(View());
38+
}
39+
2940
public async void AsyncVoidAction()
3041
{
3142
await Task.Delay(SimulateDelayMilliseconds);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hello world!

src/Mvc/test/WebSites/RazorWebSite/Controllers/PartialsWithLayoutController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public IActionResult PartialsRenderedViaRenderPartial()
2525
// (b) Partials rendered via PartialAsync can execute Layout.
2626
public IActionResult PartialsRenderedViaPartialAsync()
2727
{
28-
return View();
28+
return View(nameof(PartialsRenderedViaPartialAsync));
2929
}
3030
}
3131
}

0 commit comments

Comments
 (0)