Skip to content

Commit d72b3c2

Browse files
authored
Skip IResult in metadata if it implements IEndpointMetadataProvider (#63157)
1 parent 8d2a495 commit d72b3c2

7 files changed

+161
-5
lines changed

src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,10 @@ internal static Dictionary<int, ApiResponseType> ReadResponseMetadata(
231231

232232
foreach (var metadata in responseMetadata)
233233
{
234-
// `IResult` metadata inserted for awaitable types should
235-
// not be considered for response metadata.
236-
if (typeof(IResult).IsAssignableFrom(metadata.Type))
234+
// Skip IResult types that implement IEndpointMetadataProvider (built-in framework types like TypedResults)
235+
// since they handle their own metadata population. Custom IResult implementations that don't implement
236+
// IEndpointMetadataProvider should be included in response metadata for API documentation.
237+
if (typeof(IResult).IsAssignableFrom(metadata.Type) && typeof(IEndpointMetadataProvider).IsAssignableFrom(metadata.Type))
237238
{
238239
continue;
239240
}

src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,7 @@ public void GetApiResponseTypes_HandlesActionWithMultipleContentTypesAndProduces
811811
}
812812

813813
[Fact]
814-
public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithIResultReturnType()
814+
public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithBuiltIResultReturnType()
815815
{
816816
// Arrange
817817
var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetIResult));
@@ -824,6 +824,23 @@ public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithIResultReturnT
824824
Assert.False(result.Any());
825825
}
826826

827+
[Fact]
828+
public void GetApiResponseTypes_ReturnResponseType_IfActionHasCustomIResultReturnTypeInMetadata()
829+
{
830+
// Arrange
831+
var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetCustomIResult));
832+
actionDescriptor.EndpointMetadata = [new ProducesResponseTypeMetadata(200, typeof(MyResponse))];
833+
var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions());
834+
835+
// Act
836+
var result = provider.GetApiResponseTypes(actionDescriptor);
837+
838+
// Assert
839+
var response = Assert.Single(result);
840+
Assert.Equal(typeof(MyResponse), response.Type);
841+
Assert.Equal(200, response.StatusCode);
842+
}
843+
827844
private static ApiResponseTypeProvider GetProvider()
828845
{
829846
var mvcOptions = new MvcOptions
@@ -871,6 +888,18 @@ public class TestController
871888
public ActionResult<DerivedModel> PutModel(string userId, DerivedModel model) => null;
872889

873890
public IResult GetIResult(int id) => null;
891+
892+
public MyResponse GetCustomIResult() => new MyResponse { Content = "Test Content" };
893+
}
894+
895+
public class MyResponse : IResult
896+
{
897+
public required string Content { get; set; }
898+
899+
public Task ExecuteAsync(HttpContext httpContext)
900+
{
901+
return httpContext.Response.WriteAsJsonAsync(this);
902+
}
874903
}
875904

876905
private class TestOutputFormatter : OutputFormatter

src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,32 @@ async Task<Results<Created<InferredJsonClass>, ProblemHttpResult>> () =>
628628
Assert.Empty(badRequestResponseType.ApiResponseFormats);
629629
}
630630

631+
[Fact]
632+
public void ResponseProducesMetadataWithIResultImplementor()
633+
{
634+
var apiDescription = GetApiDescription(
635+
[ProducesResponseType(typeof(CustomIResultImplementor), StatusCodes.Status200OK)] () => new CustomIResultImplementor { Content = "Hello, World!" });
636+
637+
var okResponseType = Assert.Single(apiDescription.SupportedResponseTypes);
638+
639+
Assert.Equal(200, okResponseType.StatusCode);
640+
Assert.Equal(typeof(CustomIResultImplementor), okResponseType.Type);
641+
Assert.Equal(typeof(CustomIResultImplementor), okResponseType.ModelMetadata?.ModelType);
642+
643+
var okResponseFormat = Assert.Single(okResponseType.ApiResponseFormats);
644+
Assert.Equal("application/json", okResponseFormat.MediaType);
645+
}
646+
647+
public class CustomIResultImplementor : IResult
648+
{
649+
public required string Content { get; set; }
650+
651+
public Task ExecuteAsync(HttpContext httpContext)
652+
{
653+
return httpContext.Response.WriteAsJsonAsync(this);
654+
}
655+
}
656+
631657
[Fact]
632658
public void AddsFromRouteParameterAsPath()
633659
{

src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,20 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild
3939
schemas.MapPost("/child", (ChildObject child) => Results.Ok(child));
4040
schemas.MapPatch("/json-patch", (JsonPatchDocument patchDoc) => Results.NoContent());
4141
schemas.MapPatch("/json-patch-generic", (JsonPatchDocument<ParentObject> patchDoc) => Results.NoContent());
42-
42+
schemas.MapGet("/custom-iresult", () => new CustomIResultImplementor { Content = "Hello world!" })
43+
.Produces<CustomIResultImplementor>(200);
4344
return endpointRouteBuilder;
4445
}
4546

47+
public class CustomIResultImplementor : IResult
48+
{
49+
public required string Content { get; set; }
50+
public Task ExecuteAsync(HttpContext httpContext)
51+
{
52+
return Task.CompletedTask;
53+
}
54+
}
55+
4656
public sealed class Category
4757
{
4858
public required string Name { get; set; }

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,25 @@
551551
}
552552
}
553553
}
554+
},
555+
"/schemas-by-ref/custom-iresult": {
556+
"get": {
557+
"tags": [
558+
"Sample"
559+
],
560+
"responses": {
561+
"200": {
562+
"description": "OK",
563+
"content": {
564+
"application/json": {
565+
"schema": {
566+
"$ref": "#/components/schemas/CustomIResultImplementor"
567+
}
568+
}
569+
}
570+
}
571+
}
572+
}
554573
}
555574
},
556575
"components": {
@@ -631,6 +650,17 @@
631650
}
632651
}
633652
},
653+
"CustomIResultImplementor": {
654+
"required": [
655+
"content"
656+
],
657+
"type": "object",
658+
"properties": {
659+
"content": {
660+
"type": "string"
661+
}
662+
}
663+
},
634664
"Item": {
635665
"type": "object",
636666
"properties": {

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,25 @@
551551
}
552552
}
553553
}
554+
},
555+
"/schemas-by-ref/custom-iresult": {
556+
"get": {
557+
"tags": [
558+
"Sample"
559+
],
560+
"responses": {
561+
"200": {
562+
"description": "OK",
563+
"content": {
564+
"application/json": {
565+
"schema": {
566+
"$ref": "#/components/schemas/CustomIResultImplementor"
567+
}
568+
}
569+
}
570+
}
571+
}
572+
}
554573
}
555574
},
556575
"components": {
@@ -631,6 +650,17 @@
631650
}
632651
}
633652
},
653+
"CustomIResultImplementor": {
654+
"required": [
655+
"content"
656+
],
657+
"type": "object",
658+
"properties": {
659+
"content": {
660+
"type": "string"
661+
}
662+
}
663+
},
634664
"Item": {
635665
"type": "object",
636666
"properties": {

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,25 @@
10771077
}
10781078
}
10791079
},
1080+
"/schemas-by-ref/custom-iresult": {
1081+
"get": {
1082+
"tags": [
1083+
"Sample"
1084+
],
1085+
"responses": {
1086+
"200": {
1087+
"description": "OK",
1088+
"content": {
1089+
"application/json": {
1090+
"schema": {
1091+
"$ref": "#/components/schemas/CustomIResultImplementor"
1092+
}
1093+
}
1094+
}
1095+
}
1096+
}
1097+
}
1098+
},
10801099
"/responses/200-add-xml": {
10811100
"get": {
10821101
"tags": [
@@ -1410,6 +1429,17 @@
14101429
}
14111430
}
14121431
},
1432+
"CustomIResultImplementor": {
1433+
"required": [
1434+
"content"
1435+
],
1436+
"type": "object",
1437+
"properties": {
1438+
"content": {
1439+
"type": "string"
1440+
}
1441+
}
1442+
},
14131443
"IFormFile": {
14141444
"type": "string",
14151445
"format": "binary"

0 commit comments

Comments
 (0)