diff --git a/src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs b/src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs index 6b46d0a62bca..5654976b8510 100644 --- a/src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs +++ b/src/Mvc/Mvc.Core/src/Routing/ActionEndpointFactory.cs @@ -353,11 +353,13 @@ private static void AddActionDataToBuilder( // Add metadata inferred from the parameter and/or return type before action-specific metadata. // MethodInfo *should* never be null given a ControllerActionDescriptor, but this is unenforced. + var metadataCountBeforePopulating = builder.Metadata.Count; if (controllerActionDescriptor?.MethodInfo is not null) { EndpointMetadataPopulator.PopulateMetadata(controllerActionDescriptor.MethodInfo, builder); } + var metadataCountAfterPopulating = builder.Metadata.Count; // Add action-specific metadata early so it has a low precedence if (action.EndpointMetadata != null) { @@ -369,6 +371,29 @@ private static void AddActionDataToBuilder( builder.Metadata.Add(action); + if (metadataCountAfterPopulating != metadataCountBeforePopulating) + { + action.EndpointMetadata ??= []; + HashSet producesResponseMetadataByAttribute = []; + foreach (var endpointMetadataInAttributes in action.EndpointMetadata) + { + if (endpointMetadataInAttributes is IProducesResponseTypeMetadata metadataInAttributes + && !producesResponseMetadataByAttribute.Contains(metadataInAttributes.StatusCode)) + { + producesResponseMetadataByAttribute.Add(metadataInAttributes.StatusCode); + } + } + + foreach (var endpointMetadata in builder.Metadata) + { + if (endpointMetadata is IProducesResponseTypeMetadata metadata + && !producesResponseMetadataByAttribute.Contains(metadata.StatusCode)) + { + action.EndpointMetadata.Add(metadata); + } + } + } + // MVC guarantees that when two of it's endpoints have the same route name they are equivalent. // // The case for this looks like: diff --git a/src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs b/src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs index 2ffebf5af979..0c67a689f82e 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs @@ -3,13 +3,12 @@ using System.Net; using System.Net.Http; +using System.Reflection; using ApiExplorerWebSite; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.InternalTesting; using Newtonsoft.Json; -using Microsoft.Extensions.Logging; -using System.Reflection; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Mvc.FunctionalTests; @@ -1565,6 +1564,84 @@ public async Task ApiExplorer_LogsInvokedDescriptionProvidersOnStartup() Assert.Contains(TestSink.Writes, w => w.Message.Equals("Executing API description provider 'JsonPatchOperationsArrayProvider' from assembly Microsoft.AspNetCore.Mvc.NewtonsoftJson v42.42.42.42.", StringComparison.Ordinal)); } + [Fact] + public async Task ApiExplorer_SupportsIResults() + { + // Act + var response = await Client.GetAsync($"http://localhost/ApiExplorerHttpResultsController/GetOfT"); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + //Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(int).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.False(responseType.IsDefaultResponse); + }, + responseType => + { + Assert.Equal(typeof(DateTime).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.False(responseType.IsDefaultResponse); + }); + } + + [Fact] + public async Task ApiExplorer_SupportsIResultsWhenNoType() + { + // Act + var response = await Client.GetAsync($"http://localhost/ApiExplorerHttpResultsController/GetWithNotFoundWithNoType"); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + //Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(int).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.False(responseType.IsDefaultResponse); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.False(responseType.IsDefaultResponse); + }); + } + + [Fact] + public async Task ApiExplorer_SupportsIResultsWhenDifferentProduceType() + { + // Act + var response = await Client.GetAsync($"http://localhost/ApiExplorerHttpResultsController/GetWithDifferentProduceType"); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + //Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(long).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.False(responseType.IsDefaultResponse); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.False(responseType.IsDefaultResponse); + }); + } + private IEnumerable GetSortedMediaTypes(ApiExplorerResponseType apiResponseType) { return apiResponseType.ResponseFormats diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerWebSite.csproj b/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerWebSite.csproj index b1fcdbf3ffe6..a0f36112855c 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerWebSite.csproj +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerWebSite.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerHttpResultsController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerHttpResultsController.cs new file mode 100644 index 000000000000..c860936ac70d --- /dev/null +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerHttpResultsController.cs @@ -0,0 +1,36 @@ +// 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.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace ApiExplorerWebSite.Controllers; + +public class ApiExplorerHttpResultsController : Controller +{ + [HttpGet("ApiExplorerHttpResultsController/GetOfT")] + public Results, NotFound> Get() + { + return Random.Shared.Next() % 2 == 0 + ? TypedResults.Ok(0) + : TypedResults.NotFound(DateTime.Now); + } + + [HttpGet("ApiExplorerHttpResultsController/GetWithNotFoundWithNoType")] + public async Task, NotFound>> GetWithNotFoundWithNoType() + { + await Task.Delay(1); + return Random.Shared.Next() % 2 == 0 + ? TypedResults.Ok(0) + : TypedResults.NotFound(); + } + [ProducesResponseType(200, Type = typeof(long))] + [HttpGet("ApiExplorerHttpResultsController/GetWithDifferentProduceType")] + public async Task, NotFound>> GetWithDifferentProduceType() + { + await Task.Delay(1); + return Random.Shared.Next() % 2 == 0 + ? TypedResults.Ok(0) + : TypedResults.NotFound(); + } +} diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index cf1fed79abb2..dd82526c7500 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; [ApiController] @@ -17,6 +18,15 @@ public string GetByIdAndName(RouteParamsContainer paramsContainer) return paramsContainer.Id + "_" + paramsContainer.Name; } + [HttpGet] + [Route("/getbyidandnameWithIResults/{id}/{name}")] + public Results, NotFound> GetByIdAndNameWithIResults(RouteParamsContainer paramsContainer) + { + return Random.Shared.Next() % 2 == 0 ? + TypedResults.Ok(paramsContainer.Id + "_" + paramsContainer.Name) + : TypedResults.NotFound(); + } + [HttpPost] [Route("/forms")] public IActionResult PostForm([FromForm] MvcTodo todo) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 5f8abe054fd2..a174d458a1b5 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -54,6 +54,49 @@ } } }, + "/getbyidandnameWithIResults/{id}/{name}": { + "get": { + "tags": [ + "Test" + ], + "parameters": [ + { + "name": "Id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Name", + "in": "path", + "required": true, + "schema": { + "minLength": 5, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "minLength": 5, + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + } + }, "/forms": { "post": { "tags": [