diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 0c58956eccce..1d2df05819b1 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -284,9 +284,10 @@ internal static void CalculateResponseFormatForType(ApiResponseType apiResponse, unwrappedType = declaredReturnType.GetGenericArguments()[0]; } - // If the method is declared to return IActionResult or a derived class, that information + // If the method is declared to return IActionResult, IResult or a derived class, that information // isn't valuable to the formatter. - if (typeof(IActionResult).IsAssignableFrom(unwrappedType)) + if (typeof(IActionResult).IsAssignableFrom(unwrappedType) || + typeof(IResult).IsAssignableFrom(unwrappedType)) { return null; } diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index 096445358b96..b65ef5cc4846 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -1,7 +1,8 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; @@ -749,6 +750,20 @@ public void GetApiResponseTypes_HandlesActionWithMultipleContentTypesAndProduces }); } + [Fact] + public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithIResultReturnType() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetIResult)); + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions()); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.False(result.Any()); + } + private static ApiResponseTypeProvider GetProvider() { var mvcOptions = new MvcOptions @@ -794,6 +809,8 @@ public class TestController public ActionResult GetUserLocation(int a, int b) => null; public ActionResult PutModel(string userId, DerivedModel model) => null; + + public IResult GetIResult(int id) => null; } private class TestOutputFormatter : OutputFormatter diff --git a/src/Mvc/Mvc.Core/src/ActionResultOfT.cs b/src/Mvc/Mvc.Core/src/ActionResultOfT.cs index cc942b2aa3d5..8086cadfd0ae 100644 --- a/src/Mvc/Mvc.Core/src/ActionResultOfT.cs +++ b/src/Mvc/Mvc.Core/src/ActionResultOfT.cs @@ -21,7 +21,8 @@ public sealed class ActionResult : IConvertToActionResult /// The value. public ActionResult(TValue value) { - if (typeof(IActionResult).IsAssignableFrom(typeof(TValue))) + if (typeof(IActionResult).IsAssignableFrom(typeof(TValue)) || + typeof(IResult).IsAssignableFrom(typeof(TValue))) { var error = Resources.FormatInvalidTypeTForActionResultOfT(typeof(TValue), "ActionResult"); throw new ArgumentException(error); @@ -36,7 +37,8 @@ public ActionResult(TValue value) /// The . public ActionResult(ActionResult result) { - if (typeof(IActionResult).IsAssignableFrom(typeof(TValue))) + if (typeof(IActionResult).IsAssignableFrom(typeof(TValue)) || + typeof(IResult).IsAssignableFrom(typeof(TValue))) { var error = Resources.FormatInvalidTypeTForActionResultOfT(typeof(TValue), "ActionResult"); throw new ArgumentException(error); diff --git a/src/Mvc/Mvc.Core/src/HttpActionResult.cs b/src/Mvc/Mvc.Core/src/HttpActionResult.cs new file mode 100644 index 000000000000..e8218cfec2c4 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/HttpActionResult.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// An that when executed will produce a response based on the provided. +/// +internal sealed class HttpActionResult : ActionResult +{ + /// + /// Gets the instance of the current . + /// + public IResult Result { get; } + + /// + /// Initializes a new instance of the class with the + /// provided. + /// + /// The instance to be used during the invocation. + public HttpActionResult(IResult result) + { + Result = result; + } + + /// + public override Task ExecuteResultAsync(ActionContext context) + => Result.ExecuteAsync(context.HttpContext); +} diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ActionResultTypeMapper.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ActionResultTypeMapper.cs index 97645af4704e..79d012c251c8 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/ActionResultTypeMapper.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ActionResultTypeMapper.cs @@ -3,6 +3,8 @@ #nullable enable +using Microsoft.AspNetCore.Http; + namespace Microsoft.AspNetCore.Mvc.Infrastructure; internal class ActionResultTypeMapper : IActionResultTypeMapper @@ -35,6 +37,11 @@ public IActionResult Convert(object? value, Type returnType) return converter.Convert(); } + if (value is IResult httpResult) + { + return new HttpActionResult(httpResult); + } + return new ObjectResult(value) { DeclaredType = returnType, diff --git a/src/Mvc/Mvc.Core/test/ActionResultOfTTest.cs b/src/Mvc/Mvc.Core/test/ActionResultOfTTest.cs index 768725145524..e6a4c78cac5e 100644 --- a/src/Mvc/Mvc.Core/test/ActionResultOfTTest.cs +++ b/src/Mvc/Mvc.Core/test/ActionResultOfTTest.cs @@ -30,6 +30,17 @@ public void Constructor_WithActionResult_ThrowsForInvalidType() Assert.Equal($"Invalid type parameter '{typeof(FileStreamResult)}' specified for 'ActionResult'.", ex.Message); } + [Fact] + public void Constructor_WithIResult_ThrowsForInvalidType() + { + // Arrange + var result = new TestResult(); + + // Act & Assert + var ex = Assert.Throws(() => new ActionResult(value: result)); + Assert.Equal($"Invalid type parameter '{typeof(TestResult)}' specified for 'ActionResult'.", ex.Message); + } + [Fact] public void Convert_ReturnsResultIfSet() { @@ -106,4 +117,10 @@ private class BaseItem private class DerivedItem : BaseItem { } + + private class TestResult : IResult + { + public Task ExecuteAsync(HttpContext httpContext) + => Task.CompletedTask; + } } diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs index 74108673950c..16117d17db96 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging.Abstractions; @@ -77,6 +79,46 @@ public void OnProvidersExecuting_AppliesConventions() Assert.Equal(BindingSource.Body, parameterModel.BindingInfo.BindingSource); } + [Fact] + public void OnProvidersExecuting_AppliesConventionsForIResult() + { + // Arrange + var controllerModel = new ControllerModel(typeof(TestApiController).GetTypeInfo(), new[] { new ApiControllerAttribute() }) + { + Selectors = { new SelectorModel { AttributeRouteModel = new AttributeRouteModel() } }, + }; + + var method = typeof(TestApiController).GetMethod(nameof(TestApiController.TestActionWithIResult)); + + var actionModel = new ActionModel(method, Array.Empty()) + { + Controller = controllerModel, + }; + controllerModel.Actions.Add(actionModel); + + var parameter = method.GetParameters()[0]; + var parameterModel = new ParameterModel(parameter, Array.Empty()) + { + Action = actionModel, + }; + actionModel.Parameters.Add(parameterModel); + + var context = new ApplicationModelProviderContext(new[] { controllerModel.ControllerType }); + context.Result.Controllers.Add(controllerModel); + + var provider = GetProvider(); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + // Verify some of the side-effects of executing API behavior conventions. + Assert.True(actionModel.ApiExplorer.IsVisible); + Assert.NotEmpty(actionModel.Filters.OfType()); + Assert.NotEmpty(actionModel.Filters.OfType()); + Assert.Equal(BindingSource.Body, parameterModel.BindingInfo.BindingSource); + } + [Fact] public void Constructor_SetsUpConventions() { @@ -178,5 +220,6 @@ private static ApiBehaviorApplicationModelProvider GetProvider( private class TestApiController : ControllerBase { public IActionResult TestAction(object value) => null; + public IResult TestActionWithIResult(object value) => null; } } diff --git a/src/Mvc/Mvc.Core/test/HttpActionResultTests.cs b/src/Mvc/Mvc.Core/test/HttpActionResultTests.cs new file mode 100644 index 000000000000..b94695646fe8 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/HttpActionResultTests.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.AspNetCore.Mvc; + +public class HttpActionResultTests +{ + [Fact] + public void HttpActionResult_InitializesWithResultsStaticMethods() + { + // Arrange & Act + var httpResult = Mock.Of(); + var result = new HttpActionResult(httpResult); + + // Assert + Assert.Equal(httpResult, result.Result); + } + + [Fact] + public async Task HttpActionResult_InvokesInternalHttpResult() + { + // Arrange + var httpContext = new DefaultHttpContext + { + RequestServices = CreateServices().BuildServiceProvider() + }; + + var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + var httpResult = new Mock(); + httpResult.Setup(s => s.ExecuteAsync(httpContext)) + .Returns(() => Task.CompletedTask) + .Verifiable(); + var result = new HttpActionResult(httpResult.Object); + + // Act + await result.ExecuteResultAsync(context); + + // Assert + httpResult.Verify(); + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + return services; + } +} diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/ActionResultTypeMapperTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/ActionResultTypeMapperTest.cs index b739e829f6d2..280a866c3f8a 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/ActionResultTypeMapperTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/ActionResultTypeMapperTest.cs @@ -1,6 +1,7 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Http; using Moq; namespace Microsoft.AspNetCore.Mvc.Infrastructure; @@ -23,6 +24,37 @@ public void Convert_WithIConvertToActionResult_DelegatesToInterface() Assert.Same(expected, result); } + [Fact] + public void Convert_WithIResult_DelegatesToInterface() + { + // Arrange + var mapper = new ActionResultTypeMapper(); + + var returnValue = Mock.Of(); + + // Act + var result = mapper.Convert(returnValue, returnValue.GetType()); + + // Assert + var httpResult = Assert.IsType(result); + Assert.Same(returnValue, httpResult.Result); + } + + [Fact] + public void Convert_WithIConvertToActionResultAndIResult_DelegatesToInterface() + { + // Arrange + var mapper = new ActionResultTypeMapper(); + + var returnValue = new CustomConvertibleIResult(); + + // Act + var result = mapper.Convert(returnValue, returnValue.GetType()); + + // Assert + Assert.IsType(result); + } + [Fact] public void Convert_WithRegularType_CreatesObjectResult() { @@ -69,4 +101,11 @@ public void GetResultDataType_WithRegularType_ReturnsType() // Assert Assert.Equal(typeof(string), result); } + + private class CustomConvertibleIResult : IConvertToActionResult, IResult + { + public IActionResult Convert() => new EmptyResult(); + + public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); + } } diff --git a/src/Mvc/test/Mvc.FunctionalTests/HttpActionResultTests.cs b/src/Mvc/test/Mvc.FunctionalTests/HttpActionResultTests.cs new file mode 100644 index 000000000000..4314dd4060d5 --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/HttpActionResultTests.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using BasicWebSite.Models; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests; + +public class HttpActionResultTests : IClassFixture> +{ + public HttpActionResultTests(MvcTestFixture fixture) + { + Client = fixture.CreateDefaultClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task ActionCanReturnIResultWithContent() + { + // Arrange + var id = 1; + var url = $"/contact/{nameof(BasicWebSite.ContactApiController.ActionReturningObjectIResult)}/{id}"; + var response = await Client.GetAsync(url); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(id, result.ContactId); + } + + [Fact] + public async Task ActionCanReturnIResultWithStatusCodeOnly() + { + // Arrange + var url = $"/contact/{nameof(BasicWebSite.ContactApiController.ActionReturningStatusCodeIResult)}"; + var response = await Client.GetAsync(url); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NoContent); + } +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/BasicWebSite.csproj b/src/Mvc/test/WebSites/BasicWebSite/BasicWebSite.csproj index d57b78a2bd3b..fcd96bbb5f1f 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/BasicWebSite.csproj +++ b/src/Mvc/test/WebSites/BasicWebSite/BasicWebSite.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -9,12 +9,14 @@ + + diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs index 18cdc433222c..ef218911ccaf 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs @@ -128,6 +128,14 @@ public ActionResult ActionReturningValidationProblemDetails() }); } + [HttpGet("[action]/{id}")] + public IResult ActionReturningObjectIResult(int id) + => Results.Ok(new Contact() { ContactId = id }); + + [HttpGet("[action]")] + public IResult ActionReturningStatusCodeIResult() + => Results.NoContent(); + private class TestModelBinder : IModelBinder { public Task BindModelAsync(ModelBindingContext bindingContext)