diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 175c35fb0567..c920ea01d8aa 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -375,6 +375,8 @@ private static void AddSupportedResponseTypes( apiResponseType.ApiResponseFormats.Add(defaultResponseFormat); } + apiResponseType.Description ??= GetMatchingResponseTypeDescription(responseProviderMetadataTypes.Values, apiResponseType); + if (!supportedResponseTypes.Any(existingResponseType => existingResponseType.StatusCode == apiResponseType.StatusCode)) { supportedResponseTypes.Add(apiResponseType); @@ -395,6 +397,22 @@ private static void AddSupportedResponseTypes( supportedResponseTypes.Add(defaultApiResponseType); } + + static string? GetMatchingResponseTypeDescription(IEnumerable responseMetadataTypes, ApiResponseType apiResponseType) + { + // We set the Description to the LAST non-null value we find that matches the status code. + string? matchingDescription = null; + foreach (var metadata in responseMetadataTypes) + { + if (metadata.StatusCode == apiResponseType.StatusCode && + metadata.Type == apiResponseType.Type && + metadata.Description is not null) + { + matchingDescription = metadata.Description; + } + } + return matchingDescription; + } } private static ApiResponseType CreateDefaultApiResponseType(Type responseType) diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index b65ef5cc4846..c58b6ed4e27b 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -186,6 +186,66 @@ public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem() }); } + [Fact] + public void GetApiResponseTypes_ReturnsDescriptionFromProducesResponseType() + { + // Arrange + + const string expectedOkDescription = "All is well"; + const string expectedBadRequestDescription = "Invalid request"; + const string expectedNotFoundDescription = "Something was not found"; + + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new[] + { + new ProducesResponseTypeAttribute(200) { Description = expectedOkDescription}, + new ProducesResponseTypeAttribute(400) { Description = expectedBadRequestDescription }, + new ProducesResponseTypeAttribute(404) { Description = expectedNotFoundDescription }, + }); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Equal(expectedOkDescription, responseType.Description); + Assert.Collection( + responseType.ApiResponseFormats, + format => + { + Assert.Equal("application/json", format.MediaType); + Assert.IsType(format.Formatter); + }); + }, + responseType => + { + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + Assert.Equal(expectedBadRequestDescription, responseType.Description); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + Assert.Equal(expectedNotFoundDescription, responseType.Description); + }); + } + [ApiConventionType(typeof(DefaultApiConventions))] public class GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController : ControllerBase { diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 337865ec5f79..9a5233a709a1 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -300,6 +300,119 @@ public void AddsMultipleResponseFormatsForTypedResults() Assert.Empty(badRequestResponseType.ApiResponseFormats); } + [Fact] + public void AddsResponseDescription() + { + const string expectedCreatedDescription = "A new item was created"; + const string expectedBadRequestDescription = "Validation failed for the request"; + + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)] + () => TypedResults.Created("https://example.com", new TimeSpan())); + + Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); + + var createdResponseType = apiDescription.SupportedResponseTypes[0]; + + Assert.Equal(201, createdResponseType.StatusCode); + Assert.Equal(typeof(TimeSpan), createdResponseType.Type); + Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType); + Assert.Equal(expectedCreatedDescription, createdResponseType.Description); + + var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats); + Assert.Equal("application/json", createdResponseFormat.MediaType); + + var badRequestResponseType = apiDescription.SupportedResponseTypes[1]; + + Assert.Equal(400, badRequestResponseType.StatusCode); + Assert.Equal(typeof(void), badRequestResponseType.Type); + Assert.Equal(typeof(void), badRequestResponseType.ModelMetadata?.ModelType); + Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description); + } + + [Fact] + public void WithEmptyMethodBody_AddsResponseDescription() + { + const string expectedCreatedDescription = "A new item was created"; + const string expectedBadRequestDescription = "Validation failed for the request"; + + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)] + () => new InferredJsonClass()); + + Assert.Equal(3, apiDescription.SupportedResponseTypes.Count); + + var rdfInferredResponseType = apiDescription.SupportedResponseTypes[0]; + + Assert.Equal(200, rdfInferredResponseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), rdfInferredResponseType.Type); + Assert.Equal(typeof(InferredJsonClass), rdfInferredResponseType.ModelMetadata?.ModelType); + + var rdfInferredResponseFormat = Assert.Single(rdfInferredResponseType.ApiResponseFormats); + Assert.Equal("application/json", rdfInferredResponseFormat.MediaType); + Assert.Null(rdfInferredResponseType.Description); // There is no description set for the default "200" code, so we expect it to be null. + + var createdResponseType = apiDescription.SupportedResponseTypes[1]; + + Assert.Equal(201, createdResponseType.StatusCode); + Assert.Equal(typeof(TimeSpan), createdResponseType.Type); + Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType); + Assert.Equal(expectedCreatedDescription, createdResponseType.Description); + + var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats); + Assert.Equal("application/json", createdResponseFormat.MediaType); + + var badRequestResponseType = apiDescription.SupportedResponseTypes[2]; + + Assert.Equal(400, badRequestResponseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.Type); + Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.ModelMetadata?.ModelType); + Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description); + + var badRequestResponseFormat = Assert.Single(badRequestResponseType.ApiResponseFormats); + Assert.Equal("application/json", badRequestResponseFormat.MediaType); + } + + /// + /// Setting the description grabs the LAST description. + // To validate this, we add multiple ProducesResponseType to validate that it only grabs the LAST ONE. + /// + [Fact] + public void AddsResponseDescription_UsesLastOne() + { + const string expectedCreatedDescription = "A new item was created"; + const string expectedBadRequestDescription = "Validation failed for the request"; + + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "First description")] // The first item is an int, not a timespan, shouldn't match + [ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "Second description")] // Not a timespan AND not the final item, shouldn't match + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] // This is the last item, which should match + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = "First description")] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)] + () => TypedResults.Created("https://example.com", new TimeSpan())); + + Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); + + var createdResponseType = apiDescription.SupportedResponseTypes[0]; + + Assert.Equal(201, createdResponseType.StatusCode); + Assert.Equal(typeof(TimeSpan), createdResponseType.Type); + Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType); + Assert.Equal(expectedCreatedDescription, createdResponseType.Description); + + var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats); + Assert.Equal("application/json", createdResponseFormat.MediaType); + + var badRequestResponseType = apiDescription.SupportedResponseTypes[1]; + + Assert.Equal(400, badRequestResponseType.StatusCode); + Assert.Equal(typeof(void), badRequestResponseType.Type); + Assert.Equal(typeof(void), badRequestResponseType.ModelMetadata?.ModelType); + Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description); + } + [Fact] public void AddsResponseFormatsForTypedResultWithoutReturnType() { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs index 61ef643260b6..a33f2220d33f 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs @@ -305,8 +305,11 @@ await VerifyOpenApiDocument(builder, document => }); } + /// + /// Regression test for https://github.com/dotnet/aspnetcore/issues/60518 + /// [Fact] - public async Task GetOpenApiResponse_UsesDescriptionSetByUser() + public async Task GetOpenApiResponse_WithEmptyMethodBody_UsesDescriptionSetByUser() { // Arrange var builder = CreateBuilder(); @@ -315,8 +318,8 @@ public async Task GetOpenApiResponse_UsesDescriptionSetByUser() const string expectedBadRequestDescription = "Validation failed for the request"; // Act - builder.MapGet("/api/todos", - [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] + builder.MapPost("/api/todos", + [ProducesResponseType(StatusCodes.Status200OK, Description = expectedCreatedDescription)] [ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)] () => { }); @@ -328,7 +331,41 @@ await VerifyOpenApiDocument(builder, document => Assert.Collection(operation.Responses.OrderBy(r => r.Key), response => { - Assert.Equal("201", response.Key); + Assert.Equal("200", response.Key); + Assert.Equal(expectedCreatedDescription, response.Value.Description); + }, + response => + { + Assert.Equal("400", response.Key); + Assert.Equal(expectedBadRequestDescription, response.Value.Description); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_UsesDescriptionSetByUser() + { + // Arrange + var builder = CreateBuilder(); + + const string expectedCreatedDescription = "A new todo item was created"; + const string expectedBadRequestDescription = "Validation failed for the request"; + + // Act + builder.MapPost("/api/todos", + [ProducesResponseType(StatusCodes.Status200OK, Description = expectedCreatedDescription)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)] + () => + { return TypedResults.Ok(new Todo(1, "Lorem", true, DateTime.UtcNow)); }); // This code doesn't return Bad Request, but that doesn't matter for this test. + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + Assert.Collection(operation.Responses.OrderBy(r => r.Key), + response => + { + Assert.Equal("200", response.Key); Assert.Equal(expectedCreatedDescription, response.Value.Description); }, response => @@ -346,8 +383,42 @@ public async Task GetOpenApiResponse_UsesStatusCodeReasonPhraseWhenExplicitDescr var builder = CreateBuilder(); // Act - builder.MapGet("/api/todos", - [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = null)] // Explicitly set to NULL + builder.MapPost("/api/todos", + [ProducesResponseType(StatusCodes.Status200OK, Description = null)] // Explicitly set to NULL + [ProducesResponseType(StatusCodes.Status400BadRequest)] // Omitted, meaning it should be NULL + () => + { return TypedResults.Ok(new Todo(1, "Lorem", true, DateTime.UtcNow)); }); // This code doesn't return Bad Request, but that doesn't matter for this test. + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + Assert.Collection(operation.Responses.OrderBy(r => r.Key), + response => + { + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + }, + response => + { + Assert.Equal("400", response.Key); + Assert.Equal("Bad Request", response.Value.Description); + }); + }); + } + + /// + /// Regression test for https://github.com/dotnet/aspnetcore/issues/60518 + /// + [Fact] + public async Task GetOpenApiResponse_WithEmptyMethodBody_UsesStatusCodeReasonPhraseWhenExplicitDescriptionIsMissing() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api/todos", + [ProducesResponseType(StatusCodes.Status200OK, Description = null)] // Explicitly set to NULL [ProducesResponseType(StatusCodes.Status400BadRequest)] // Omitted, meaning it should be NULL () => { }); @@ -359,8 +430,8 @@ await VerifyOpenApiDocument(builder, document => Assert.Collection(operation.Responses.OrderBy(r => r.Key), response => { - Assert.Equal("201", response.Key); - Assert.Equal("Created", response.Value.Description); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); }, response => { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy