Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/OpenApi/sample/Endpoints/MapResponsesEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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;
}
}
21 changes: 18 additions & 3 deletions src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -409,9 +409,24 @@ private async Task<OpenApiResponse> 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<IProducesResponseTypeMetadata>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you'd need to check more types. According to my initial PR, description support has been added to more attributes/classes that do not implement this interface:

#58193

Copy link
Contributor

@sander1095 sander1095 Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preferably, we'd not need this call at all, as I think this is the wrong place for this kind of check. See my overall PR review comment for more info.

.Where(m => m.StatusCode == statusCode)
.LastOrDefault()?.Description;

Copy link
Contributor

@sander1095 sander1095 Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't truly say if this is the right approach or not, but I can say that I think we're creating (even more) duplicate code here.

The reason why your bug exists, is because I skipped over this scenario when adding support for response descriptions in minimal API. (Thanks so much for spotting it, and creating a PR!)

There are a lot of places where the response description gets set. In OpenApiGenerator, in OpenApiDocumentService, and in EndpointMetadataApiDescriptionProvider. Compare that to Controllers, where https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs#L134-L135 is the only place I needed to make (significant) changes to get controllers to play nice with the new property.


In this case, this code here looks quite similar to the code I wrote in these 2 PR's (#60539 and #62695 ), but in the last PR I still added more check to deal with the issue of inferred types.

I am not sure if that code also belongs here, but it does look a bit like a copy, which can be a hazard for the future.

description = !string.IsNullOrEmpty(customDescription)
? customDescription
: ReasonPhrases.GetReasonPhrase(statusCode);
}

var response = new OpenApiResponse
{
Description = apiResponseType.Description ?? ReasonPhrases.GetReasonPhrase(statusCode),
Description = description,
Content = new Dictionary<string, OpenApiMediaType>()
};

Expand All @@ -437,9 +452,9 @@ private async Task<OpenApiResponse> 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<ProducesAttribute>()
.SelectMany(attr => attr.ContentTypes);
.SelectMany(attr => attr.ContentTypes) ?? Enumerable.Empty<string>();
foreach (var contentType in explicitContentTypes)
{
response.Content.TryAdd(contentType, new OpenApiMediaType());
Expand Down
16 changes: 15 additions & 1 deletion src/OpenApi/src/Services/OpenApiGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,19 @@ private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointM

var eligibileAnnotations = new Dictionary<int, (Type?, MediaTypeCollection)>();

// Track custom descriptions for each status code
var customDescriptions = new Dictionary<int, string?>();

foreach (var responseMetadata in producesResponseMetadata)
{
var statusCode = responseMetadata.StatusCode;

// Capture custom description if provided
if (!string.IsNullOrEmpty(responseMetadata.Description))
{
customDescriptions[statusCode] = responseMetadata.Description;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this will cause issues in cases where there are multiple of the same status codes, with different descriptions (and/or different response bodies). I think this needs test cases to check how that is dealt with. It has to be in line with existing controller and minimal API logic (do we grab the first or last match?)

My code for this in another place deals with this by checking for status, type and description. Perhaps that is relevant here: https://github.com/dotnet/aspnetcore/pull/62695/files

}

var discoveredTypeAnnotation = responseMetadata.Type;
var discoveredContentTypeAnnotation = new MediaTypeCollection();

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@
<Import Project="$(MvcTestingTargets)" Condition="'$(MvcTestingTargets)' != ''" />

<ItemGroup>
<HelixContent
Include="$(MSBuildProjectDirectory)\Integration\snapshots\**"
LinkBase="$(MSBuildThisFileDirectory)\Integration\snapshots"/>
<HelixContent Include="$(MSBuildProjectDirectory)\Integration\snapshots\**" LinkBase="$(MSBuildThisFileDirectory)\Integration\snapshots" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Treating an empty string as null shouldnt be the case according to the existing Response Description support in Minimal API

RC1:

app.MapGet("/endpoint", [ProducesResponseType<string>(200, Description = "")] () =>
{
    return "Hi!";
});
"/endpoint": {
      "get": {
        "tags": [
          "openapi"
        ],
        "responses": {
          "200": {
            "description": "", // not null!
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Controllers:


[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    [ProducesResponseType<string>(200, Description = "")]
    public IActionResult Get()
    {
        return Ok("hi");
    }
}
  "paths": {
    "/WeatherForecast": {
      "get": {
        "tags": [
          "WeatherForecast"
        ],
        "responses": {
          "200": {
            "description": "", // not null
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              },
              "application/json": {
                "schema": {
                  "type": "string"
                }
              },
              "text/json": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    }

{
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,
Expand Down
Loading