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 => {