Skip to content

Commit 8719642

Browse files
committed
Trim Async suffix on action names
Fixes #4849
1 parent 68f7e3a commit 8719642

File tree

9 files changed

+219
-1
lines changed

9 files changed

+219
-1
lines changed

src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public class HomeController : Controller
1010
[ModelBinder]
1111
public string Id { get; set; }
1212

13-
public IActionResult Index()
13+
public IActionResult IndexAsync()
1414
{
1515
return View();
1616
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ internal class DefaultApplicationModelProvider : IApplicationModelProvider
2222
private readonly IModelMetadataProvider _modelMetadataProvider;
2323
private readonly Func<ActionContext, bool> _supportsAllRequests;
2424
private readonly Func<ActionContext, bool> _supportsNonGetRequests;
25+
private readonly List<IActionModelConvention> _actionModelConventions;
2526

2627
public DefaultApplicationModelProvider(
2728
IOptions<MvcOptions> mvcOptionsAccessor,
@@ -32,6 +33,12 @@ public DefaultApplicationModelProvider(
3233

3334
_supportsAllRequests = _ => true;
3435
_supportsNonGetRequests = context => !string.Equals(context.HttpContext.Request.Method, "GET", StringComparison.OrdinalIgnoreCase);
36+
37+
_actionModelConventions = new List<IActionModelConvention>();
38+
if (_mvcOptions.SuppressAsyncSuffixInActionNames)
39+
{
40+
_actionModelConventions.Add(new SuppressAsyncSuffixInActionNameConvention());
41+
}
3542
}
3643

3744
/// <inheritdoc />
@@ -92,6 +99,11 @@ public void OnProvidersExecuting(ApplicationModelProviderContext context)
9299
actionModel.Parameters.Add(parameterModel);
93100
}
94101
}
102+
103+
foreach (var convention in _actionModelConventions)
104+
{
105+
convention.Apply(actionModel);
106+
}
95107
}
96108
}
97109
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
6+
namespace Microsoft.AspNetCore.Mvc.ApplicationModels
7+
{
8+
/// <summary>
9+
/// <see cref="IActionModelConvention"/> that modifies <see cref="ActionModel.ActionName"/>
10+
/// to remove the Async suffix. <see cref="ActionModel.ActionName"/> is used to construct the
11+
/// route to the action, as well as view lookup.
12+
/// </summary>
13+
public class SuppressAsyncSuffixInActionNameConvention : IActionModelConvention
14+
{
15+
/// <summary>
16+
/// Determines if the convention applies to specified <paramref name="action"/>.
17+
/// Defaults to returning <see langword="true"/>.
18+
/// </summary>
19+
/// <param name="action">The <see cref="ActionModel"/>.</param>
20+
/// <returns><see langword="true"/> if the convention should apply to <paramref name="action"/>.</returns>
21+
protected virtual bool ShouldApply(ActionModel action) => true;
22+
23+
/// <inheritdoc />
24+
public void Apply(ActionModel action)
25+
{
26+
if (action == null)
27+
{
28+
throw new ArgumentNullException(nameof(action));
29+
}
30+
31+
if (!ShouldApply(action))
32+
{
33+
return;
34+
}
35+
36+
const string Suffix = "Async";
37+
38+
if (action.ActionName.EndsWith(Suffix, StringComparison.Ordinal))
39+
{
40+
action.ActionName = action.ActionName.Substring(0, action.ActionName.Length - Suffix.Length);
41+
}
42+
}
43+
}
44+
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,21 @@ public int? MaxValidationDepth
209209
}
210210
}
211211

212+
/// <summary>
213+
/// Gets or sets a value that determines if MVC will ignore the suffix "Async" applied to
214+
/// controller action names.
215+
/// <para>
216+
/// When <see langword="true"/>, MVC will strip action names of the Async suffix.
217+
/// Action names are used for routing as well as view lookup. For example, the action
218+
/// action <c>ProductsController.ListProductsAsync</c> will be routeable at <c>/Products/ListProducts</c>
219+
/// with views looked up at <c>/Views/Products/ListProducts.cshtml</c>.
220+
/// </para>
221+
/// </summary>
222+
/// <value>
223+
/// The default value is <c>true</c>.
224+
/// </value>
225+
public bool SuppressAsyncSuffixInActionNames { get; set; } = true;
226+
212227
IEnumerator<ICompatibilitySwitch> IEnumerable<ICompatibilitySwitch>.GetEnumerator() => _switches.GetEnumerator();
213228

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

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,44 @@ 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+
304+
var context = new ApplicationModelProviderContext(new[] { typeInfo });
305+
306+
// Act
307+
provider.OnProvidersExecuting(context);
308+
309+
// Assert
310+
var controllerModel = Assert.Single(context.Result.Controllers);
311+
var action = Assert.Single(controllerModel.Actions);
312+
Assert.Equal("GetPerson", action.ActionName);
313+
}
314+
315+
[Fact]
316+
public void OnProvidersExecuting_DoesNotRemoveAsyncSuffix_WhenOptionIsDisabled()
317+
{
318+
// Arrange
319+
var options = new MvcOptions { SuppressAsyncSuffixInActionNames = false };
320+
var provider = new TestApplicationModelProvider(options, new EmptyModelMetadataProvider());
321+
var typeInfo = typeof(AsyncActionController).GetTypeInfo();
322+
323+
var context = new ApplicationModelProviderContext(new[] { typeInfo });
324+
325+
// Act
326+
provider.OnProvidersExecuting(context);
327+
328+
// Assert
329+
var controllerModel = Assert.Single(context.Result.Controllers);
330+
var action = Assert.Single(controllerModel.Actions);
331+
Assert.Equal(nameof(AsyncActionController.GetPersonAsync), action.ActionName);
332+
}
333+
296334
[Fact]
297335
public void CreateControllerModel_DerivedFromControllerClass_HasFilter()
298336
{
@@ -1774,6 +1812,11 @@ private class MultipleRouteProviderOnActionController
17741812
public void Edit() { }
17751813
}
17761814

1815+
private class AsyncActionController : Controller
1816+
{
1817+
public IActionResult GetPersonAsync() => null;
1818+
}
1819+
17771820
private class TestApplicationModelProvider : DefaultApplicationModelProvider
17781821
{
17791822
public TestApplicationModelProvider()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using Xunit;
6+
7+
namespace Microsoft.AspNetCore.Mvc.ApplicationModels
8+
{
9+
public class SuppressAsyncSuffixInActionNameConventionTest
10+
{
11+
private readonly ActionModel ActionModel = new ActionModel(typeof(object).GetMethod(nameof(string.ToString)), Array.Empty<object>());
12+
13+
[Fact]
14+
public void Apply_RemovesSuffix()
15+
{
16+
// Arrange
17+
ActionModel.ActionName = "ListItemsAsync";
18+
var convention = new SuppressAsyncSuffixInActionNameConvention();
19+
20+
// Act
21+
convention.Apply(ActionModel);
22+
23+
// Assert
24+
Assert.Equal("ListItems", ActionModel.ActionName);
25+
}
26+
27+
[Fact]
28+
public void Apply_NoOpsIfNameDoesNotEndWithSuffix()
29+
{
30+
// Arrange
31+
ActionModel.ActionName = "ListItems";
32+
var convention = new SuppressAsyncSuffixInActionNameConvention();
33+
34+
// Act
35+
convention.Apply(ActionModel);
36+
37+
// Assert
38+
Assert.Equal("ListItems", ActionModel.ActionName);
39+
}
40+
41+
[Fact]
42+
public void Apply_NoOpsIfShouldApplyReturnsFalse()
43+
{
44+
// Arrange
45+
ActionModel.ActionName = "ListItemsAsync";
46+
var convention = new TestSuppressAsyncSuffixInActionNameConvention();
47+
48+
// Act
49+
convention.Apply(ActionModel);
50+
51+
// Assert
52+
Assert.Equal("ListItemsAsync", ActionModel.ActionName);
53+
}
54+
55+
private class TestSuppressAsyncSuffixInActionNameConvention : SuppressAsyncSuffixInActionNameConvention
56+
{
57+
protected override bool ShouldApply(ActionModel action) => false;
58+
}
59+
}
60+
}

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 ActionCannotBeRoutedWithAsyncSuffix()
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/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 IActionResult ActionReturningViewAsync()
36+
{
37+
return 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!

0 commit comments

Comments
 (0)