Skip to content

Skip IResult in metadata if it implements IEndpointMetadataProvider #63157

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 7, 2025
Merged
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
7 changes: 4 additions & 3 deletions src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,10 @@ internal static Dictionary<int, ApiResponseType> ReadResponseMetadata(

foreach (var metadata in responseMetadata)
{
// `IResult` metadata inserted for awaitable types should
// not be considered for response metadata.
if (typeof(IResult).IsAssignableFrom(metadata.Type))
// Skip IResult types that implement IEndpointMetadataProvider (built-in framework types like TypedResults)
// since they handle their own metadata population. Custom IResult implementations that don't implement
// IEndpointMetadataProvider should be included in response metadata for API documentation.
if (typeof(IResult).IsAssignableFrom(metadata.Type) && typeof(IEndpointMetadataProvider).IsAssignableFrom(metadata.Type))
{
continue;
}
Expand Down
31 changes: 30 additions & 1 deletion src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,7 @@ public void GetApiResponseTypes_HandlesActionWithMultipleContentTypesAndProduces
}

[Fact]
public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithIResultReturnType()
public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithBuiltIResultReturnType()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetIResult));
Expand All @@ -824,6 +824,23 @@ public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithIResultReturnT
Assert.False(result.Any());
}

[Fact]
public void GetApiResponseTypes_ReturnResponseType_IfActionHasCustomIResultReturnTypeInMetadata()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetCustomIResult));
actionDescriptor.EndpointMetadata = [new ProducesResponseTypeMetadata(200, typeof(MyResponse))];
var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions());

// Act
var result = provider.GetApiResponseTypes(actionDescriptor);

// Assert
var response = Assert.Single(result);
Assert.Equal(typeof(MyResponse), response.Type);
Assert.Equal(200, response.StatusCode);
}

private static ApiResponseTypeProvider GetProvider()
{
var mvcOptions = new MvcOptions
Expand Down Expand Up @@ -871,6 +888,18 @@ public class TestController
public ActionResult<DerivedModel> PutModel(string userId, DerivedModel model) => null;

public IResult GetIResult(int id) => null;

public MyResponse GetCustomIResult() => new MyResponse { Content = "Test Content" };
}

public class MyResponse : IResult
{
public required string Content { get; set; }

public Task ExecuteAsync(HttpContext httpContext)
{
return httpContext.Response.WriteAsJsonAsync(this);
}
}

private class TestOutputFormatter : OutputFormatter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,32 @@ async Task<Results<Created<InferredJsonClass>, ProblemHttpResult>> () =>
Assert.Empty(badRequestResponseType.ApiResponseFormats);
}

[Fact]
public void ResponseProducesMetadataWithIResultImplementor()
{
var apiDescription = GetApiDescription(
[ProducesResponseType(typeof(CustomIResultImplementor), StatusCodes.Status200OK)] () => new CustomIResultImplementor { Content = "Hello, World!" });

var okResponseType = Assert.Single(apiDescription.SupportedResponseTypes);

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(CustomIResultImplementor), okResponseType.Type);
Assert.Equal(typeof(CustomIResultImplementor), okResponseType.ModelMetadata?.ModelType);

var okResponseFormat = Assert.Single(okResponseType.ApiResponseFormats);
Assert.Equal("application/json", okResponseFormat.MediaType);
}

public class CustomIResultImplementor : IResult
{
public required string Content { get; set; }

public Task ExecuteAsync(HttpContext httpContext)
{
return httpContext.Response.WriteAsJsonAsync(this);
}
}

[Fact]
public void AddsFromRouteParameterAsPath()
{
Expand Down
12 changes: 11 additions & 1 deletion src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,20 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild
schemas.MapPost("/child", (ChildObject child) => Results.Ok(child));
schemas.MapPatch("/json-patch", (JsonPatchDocument patchDoc) => Results.NoContent());
schemas.MapPatch("/json-patch-generic", (JsonPatchDocument<ParentObject> patchDoc) => Results.NoContent());

schemas.MapGet("/custom-iresult", () => new CustomIResultImplementor { Content = "Hello world!" })
.Produces<CustomIResultImplementor>(200);
return endpointRouteBuilder;
}

public class CustomIResultImplementor : IResult
{
public required string Content { get; set; }
public Task ExecuteAsync(HttpContext httpContext)
{
return Task.CompletedTask;
}
}

public sealed class Category
{
public required string Name { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,25 @@
}
}
}
},
"/schemas-by-ref/custom-iresult": {
"get": {
"tags": [
"Sample"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomIResultImplementor"
}
}
}
}
}
}
}
},
"components": {
Expand Down Expand Up @@ -631,6 +650,17 @@
}
}
},
"CustomIResultImplementor": {
"required": [
"content"
],
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"Item": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,25 @@
}
}
}
},
"/schemas-by-ref/custom-iresult": {
"get": {
"tags": [
"Sample"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomIResultImplementor"
}
}
}
}
}
}
}
},
"components": {
Expand Down Expand Up @@ -631,6 +650,17 @@
}
}
},
"CustomIResultImplementor": {
"required": [
"content"
],
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"Item": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,25 @@
}
}
},
"/schemas-by-ref/custom-iresult": {
"get": {
"tags": [
"Sample"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomIResultImplementor"
}
}
}
}
}
}
},
"/responses/200-add-xml": {
"get": {
"tags": [
Expand Down Expand Up @@ -1410,6 +1429,17 @@
}
}
},
"CustomIResultImplementor": {
"required": [
"content"
],
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"IFormFile": {
"type": "string",
"format": "binary"
Expand Down
Loading