diff --git a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs index b2f9cac4f6a6..4d62e438a239 100644 --- a/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs @@ -1,6 +1,8 @@ // 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.Mvc; + public static class ResponseEndpoints { public static IEndpointRouteBuilder MapResponseEndpoints(this IEndpointRouteBuilder endpointRouteBuilder) @@ -17,6 +19,19 @@ public static IEndpointRouteBuilder MapResponseEndpoints(this IEndpointRouteBuil responses.MapGet("/triangle", () => new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 }); responses.MapGet("/shape", Shape () => new Triangle { Color = "blue", Sides = 4 }); + // Test custom descriptions using ProducesResponseType attribute + responses.MapGet("/custom-description-attribute", + [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/html", + Description = "Custom description using attribute")] + () => "Hello World"); + + // Also test with .WithMetadata approach + responses.MapGet("/custom-description-extension-method", () => "Hello World") + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, null, ["text/html"]) + { + Description = "Custom description using extension method" + }); + return endpointRouteBuilder; } } diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index f342f5b7943b..e51510baf78a 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -409,9 +409,24 @@ private async Task GetResponseAsync( IOpenApiSchemaTransformer[] schemaTransformers, CancellationToken cancellationToken) { + // Check for custom description from ProducesResponseTypeMetadata if ApiResponseType.Description is null + var description = apiResponseType.Description; + if (string.IsNullOrEmpty(description)) + { + // Look for custom description in endpoint metadata + var customDescription = apiDescription.ActionDescriptor.EndpointMetadata? + .OfType() + .Where(m => m.StatusCode == statusCode) + .LastOrDefault()?.Description; + + description = !string.IsNullOrEmpty(customDescription) + ? customDescription + : ReasonPhrases.GetReasonPhrase(statusCode); + } + var response = new OpenApiResponse { - Description = apiResponseType.Description ?? ReasonPhrases.GetReasonPhrase(statusCode), + Description = description, Content = new Dictionary() }; @@ -437,9 +452,9 @@ private async Task GetResponseAsync( // MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer // looks for when generating ApiResponseFormats above so we need to pull the content // types defined there separately. - var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata + var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata? .OfType() - .SelectMany(attr => attr.ContentTypes); + .SelectMany(attr => attr.ContentTypes) ?? Enumerable.Empty(); foreach (var contentType in explicitContentTypes) { response.Content.TryAdd(contentType, new OpenApiMediaType()); diff --git a/src/OpenApi/src/Services/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs index 6257f7fa2fea..165458bbdfb7 100644 --- a/src/OpenApi/src/Services/OpenApiGenerator.cs +++ b/src/OpenApi/src/Services/OpenApiGenerator.cs @@ -116,10 +116,19 @@ private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointM var eligibileAnnotations = new Dictionary(); + // Track custom descriptions for each status code + var customDescriptions = new Dictionary(); + foreach (var responseMetadata in producesResponseMetadata) { var statusCode = responseMetadata.StatusCode; + // Capture custom description if provided + if (!string.IsNullOrEmpty(responseMetadata.Description)) + { + customDescriptions[statusCode] = responseMetadata.Description; + } + var discoveredTypeAnnotation = responseMetadata.Type; var discoveredContentTypeAnnotation = new MediaTypeCollection(); @@ -204,10 +213,15 @@ private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointM responseContent[contentType] = new OpenApiMediaType(); } + // Use custom description if available, otherwise fall back to default + var description = customDescriptions.TryGetValue(statusCode, out var customDesc) && !string.IsNullOrEmpty(customDesc) + ? customDesc + : GetResponseDescription(statusCode); + responses[statusCode.ToString(CultureInfo.InvariantCulture)] = new OpenApiResponse { Content = responseContent, - Description = GetResponseDescription(statusCode) + Description = description }; } return responses; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index 96a3be6747cf..6814f6907b6e 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -85,6 +85,44 @@ } } } + }, + "/responses/custom-description-attribute": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "Custom description using attribute", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/responses/custom-description-extension-method": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "Custom description using extension method", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } } }, "components": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index 45a4660aa78c..7e815860f91b 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -85,6 +85,44 @@ } } } + }, + "/responses/custom-description-attribute": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "Custom description using attribute", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/responses/custom-description-extension-method": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "Custom description using extension method", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } } }, "components": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index eec2cfe16702..3dadbe1d44d3 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt @@ -1398,6 +1398,44 @@ } } }, + "/responses/custom-description-attribute": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "Custom description using attribute", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/responses/custom-description-extension-method": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "Custom description using extension method", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/getbyidandname/{id}/{name}": { "get": { "tags": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Microsoft.AspNetCore.OpenApi.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Microsoft.AspNetCore.OpenApi.Tests.csproj index 96a5342b6407..a01c1fca6489 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Microsoft.AspNetCore.OpenApi.Tests.csproj +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Microsoft.AspNetCore.OpenApi.Tests.csproj @@ -36,9 +36,7 @@ - + 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 01f7ddff1df0..acab6cf6573e 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 @@ -439,4 +439,31 @@ await VerifyOpenApiDocument(builder, document => }); }); } + + [Fact] + public async Task GetOpenApiResponse_WithMetadata_UsesCustomDescriptionFromProducesResponseTypeMetadata() + { + // Arrange + var builder = CreateBuilder(); + + const string customDescription = "Custom description"; + + // Act + builder.MapGet("/api/todos", () => "Hello World") + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, null, new[] { "text/html" }) + { + Description = customDescription + }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal(customDescription, response.Value.Description); + var content = Assert.Single(response.Value.Content); + Assert.Equal("text/html", content.Key); + }); + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiGeneratorTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiGeneratorTests.cs index 0dc6f3f6e53d..b5cf16f8cc5d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiGeneratorTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiGeneratorTests.cs @@ -1022,6 +1022,114 @@ public void DoesNotGenerateRequestBodyWhenInferredBodyDisabled() Assert.Null(operation.RequestBody); } + [Fact] + public void MixedCustomAndDefaultResponseDescriptionsAreAppliedCorrectly() + { + const string customOkDescription = "Custom success response"; + + var operation = GetOpenApiOperation(() => "", additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(StatusCodes.Status200OK, null, new[] { "application/json" }) + { + Description = customOkDescription + }, + new ProducesResponseTypeMetadata(StatusCodes.Status400BadRequest, null, new[] { "application/json" }) // No custom description - should use default + }); + + Assert.Equal(2, operation.Responses.Count); + + var okResponse = operation.Responses["200"]; + Assert.Equal(customOkDescription, okResponse.Description); + + var badRequestResponse = operation.Responses["400"]; + Assert.Equal("Bad Request", badRequestResponse.Description); // Default reason phrase + } + + [Fact] + public void EmptyCustomDescriptionFallsBackToDefault() + { + var operation = GetOpenApiOperation(() => "", additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(StatusCodes.Status200OK, null, new[] { "application/json" }) + { + Description = "" // Empty string should fall back to default + } + }); + + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); // Should use default, not empty string + } + + [Fact] + public void NullCustomDescriptionFallsBackToDefault() + { + var operation = GetOpenApiOperation(() => "", additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(StatusCodes.Status200OK, null, new[] { "application/json" }) + { + Description = null // Explicit null should fall back to default + } + }); + + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); // Should use default + } + + [Fact] + public void MultipleMetadataWithSameStatusCodePreservesLastDescription() + { + const string firstDescription = "First description"; + const string secondDescription = "Second description"; + + var operation = GetOpenApiOperation(() => "", additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(string), new[] { "text/plain" }) + { + Description = firstDescription + }, + new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(InferredJsonClass), new[] { "application/json" }) + { + Description = secondDescription + } + }); + + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal(secondDescription, response.Value.Description); // Should use the last one + } + + [Fact] + public void CustomDescriptionWorksWithVariousStatusCodes() + { + const string createdDescription = "Resource was created successfully"; + const string notFoundDescription = "The requested resource was not found"; + const string serverErrorDescription = "An internal server error occurred"; + + var operation = GetOpenApiOperation(() => "", additionalMetadata: new[] + { + new ProducesResponseTypeMetadata(StatusCodes.Status201Created, null, new[] { "application/json" }) + { + Description = createdDescription + }, + new ProducesResponseTypeMetadata(StatusCodes.Status404NotFound, null, new[] { "application/json" }) + { + Description = notFoundDescription + }, + new ProducesResponseTypeMetadata(StatusCodes.Status500InternalServerError, null, new[] { "application/json" }) + { + Description = serverErrorDescription + } + }); + + Assert.Equal(3, operation.Responses.Count); + + Assert.Equal(createdDescription, operation.Responses["201"].Description); + Assert.Equal(notFoundDescription, operation.Responses["404"].Description); + Assert.Equal(serverErrorDescription, operation.Responses["500"].Description); + } + private static OpenApiOperation GetOpenApiOperation( Delegate action, string pattern = null,