Skip to content

Commit 170f046

Browse files
authored
Fix ProducesResponseType's Description not being set for Minimal API's when attribute and inferred types aren't an exact match (#62695)
1 parent 84cd99a commit 170f046

File tree

2 files changed

+119
-1
lines changed

2 files changed

+119
-1
lines changed

src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,14 +405,28 @@ private static void AddSupportedResponseTypes(
405405
foreach (var metadata in responseMetadataTypes)
406406
{
407407
if (metadata.StatusCode == apiResponseType.StatusCode &&
408-
metadata.Type == apiResponseType.Type &&
408+
TypesAreCompatible(apiResponseType.Type, metadata.Type) &&
409409
metadata.Description is not null)
410410
{
411411
matchingDescription = metadata.Description;
412412
}
413413
}
414414
return matchingDescription;
415415
}
416+
417+
static bool TypesAreCompatible(Type? apiResponseType, Type? metadataType)
418+
{
419+
// We need to a special check for cases where the inferred type is different than the one specified in attributes.
420+
// For example, an endpoint that defines [ProducesResponseType<IEnumerable<WeatherForecast>>],
421+
// but the endpoint returns weatherForecasts.ToList(). Because List<> is a different type than IEnumerable<>, it would incorrectly set OpenAPI metadata incorrectly.
422+
// We use a conservative unidirectional check where the attribute type must be assignable from the inferred type.
423+
// This handles inheritance (BaseClass ← DerivedClass) and interface implementation (IEnumerable<T> ← List<T>).
424+
// This should be sufficient, as it's more common to specify an interface or base class type in the attribute and a concrete type in the endpoint implementation,
425+
// compared to doing the opposite.
426+
// For more information, check the related bug: https://github.com/dotnet/aspnetcore/issues/60518
427+
return apiResponseType == metadataType ||
428+
metadataType?.IsAssignableFrom(apiResponseType) == true;
429+
}
416430
}
417431

418432
private static ApiResponseType CreateDefaultApiResponseType(Type responseType)

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,108 @@ public void AddsResponseDescription()
331331
Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description);
332332
}
333333

334+
[Fact]
335+
public void AddsResponseDescription_WorksWithGenerics()
336+
{
337+
const string expectedOkDescription = "The weather forecast for the next 5 days.";
338+
339+
var apiDescription = GetApiDescription([ProducesResponseType<GenericClass<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
340+
() => new GenericClass<TimeSpan> { Value = new TimeSpan() });
341+
342+
var okResponseType = Assert.Single(apiDescription.SupportedResponseTypes);
343+
344+
Assert.Equal(200, okResponseType.StatusCode);
345+
Assert.Equal(typeof(GenericClass<TimeSpan>), okResponseType.Type);
346+
Assert.Equal(typeof(GenericClass<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
347+
Assert.Equal(expectedOkDescription, okResponseType.Description);
348+
349+
var createdOkFormat = Assert.Single(okResponseType.ApiResponseFormats);
350+
Assert.Equal("application/json", createdOkFormat.MediaType);
351+
}
352+
353+
[Fact]
354+
public void AddsResponseDescription_WorksWithGenericsAndTypedResults()
355+
{
356+
const string expectedOkDescription = "The weather forecast for the next 5 days.";
357+
358+
var apiDescription = GetApiDescription([ProducesResponseType<GenericClass<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
359+
() => TypedResults.Ok(new GenericClass<TimeSpan> { Value = new TimeSpan() }));
360+
361+
var okResponseType = Assert.Single(apiDescription.SupportedResponseTypes);
362+
363+
Assert.Equal(200, okResponseType.StatusCode);
364+
Assert.Equal(typeof(GenericClass<TimeSpan>), okResponseType.Type);
365+
Assert.Equal(typeof(GenericClass<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
366+
Assert.Equal(expectedOkDescription, okResponseType.Description);
367+
368+
var createdOkFormat = Assert.Single(okResponseType.ApiResponseFormats);
369+
Assert.Equal("application/json", createdOkFormat.MediaType);
370+
}
371+
372+
[Fact]
373+
public void AddsResponseDescription_WorksWithCollections()
374+
{
375+
const string expectedOkDescription = "The weather forecast for the next 5 days.";
376+
377+
var apiDescription = GetApiDescription([ProducesResponseType<IEnumerable<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
378+
() => new List<TimeSpan> { new() });
379+
380+
var okResponseType = Assert.Single(apiDescription.SupportedResponseTypes);
381+
382+
Assert.Equal(200, okResponseType.StatusCode);
383+
Assert.Equal(typeof(List<TimeSpan>), okResponseType.Type); // We use List as the inferred type has higher priority than those set by metadata (attributes)
384+
Assert.Equal(typeof(List<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
385+
Assert.Equal(expectedOkDescription, okResponseType.Description);
386+
387+
var createdOkFormat = Assert.Single(okResponseType.ApiResponseFormats);
388+
Assert.Equal("application/json", createdOkFormat.MediaType);
389+
}
390+
391+
[Fact]
392+
public void AddsResponseDescription_WorksWithCollectionsAndTypedResults()
393+
{
394+
const string expectedOkDescription = "The weather forecast for the next 5 days.";
395+
396+
var apiDescription = GetApiDescription([ProducesResponseType<IEnumerable<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
397+
() => TypedResults.Ok(new List<TimeSpan> { new() }));
398+
399+
var okResponseType = Assert.Single(apiDescription.SupportedResponseTypes);
400+
401+
Assert.Equal(200, okResponseType.StatusCode);
402+
Assert.Equal(typeof(List<TimeSpan>), okResponseType.Type); // We use List as the inferred type has higher priority than those set by metadata (attributes)
403+
Assert.Equal(typeof(List<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
404+
Assert.Equal(expectedOkDescription, okResponseType.Description);
405+
406+
var createdOkFormat = Assert.Single(okResponseType.ApiResponseFormats);
407+
Assert.Equal("application/json", createdOkFormat.MediaType);
408+
}
409+
410+
/// <summary>
411+
/// EndpointMetadataApiDescriptionProvider performs a one way type check for discovering response types to match the description that's set in [ProducesResponseType]
412+
/// The reason we do a one-way check instead of a bidirectional check is to prevent too many positive matches.
413+
/// </summary>
414+
/// <remarks>
415+
/// Example: If we did a bidirectional check, we would match something scenarios like this, which can cause confusion:
416+
/// [ProducesResponseType<string>(StatusCodes.Status200OK, Description = "Returned with a string")] -> TypedResults.Ok(new object())
417+
/// This would match because object is assignable to string,
418+
/// but it doesn't make sense to add the Description to the object type because the attribute says we should return a string.
419+
///
420+
/// This test documents this desired behavior and will fail if the behavior changes, so the developer can double check if their change is intentional.
421+
/// </summary>
422+
[Fact]
423+
public void AddsResponseDescription_ShouldFailWhenInferredTypeIsNotDirectlyAssignableToAttributeType()
424+
{
425+
var apiDescription = GetApiDescription([ProducesResponseType<string>(StatusCodes.Status200OK, Description = "Only returned with a string")]
426+
() => TypedResults.Ok(new object()));
427+
428+
var okResponseType = Assert.Single(apiDescription.SupportedResponseTypes);
429+
430+
Assert.Equal(200, okResponseType.StatusCode);
431+
Assert.Equal(typeof(object), okResponseType.Type);
432+
Assert.Equal(typeof(object), okResponseType.ModelMetadata?.ModelType);
433+
Assert.Null(okResponseType.Description);
434+
}
435+
334436
[Fact]
335437
public void WithEmptyMethodBody_AddsResponseDescription()
336438
{
@@ -1814,4 +1916,6 @@ private class TestServiceProvider : IServiceProvider
18141916
return null;
18151917
}
18161918
}
1919+
1920+
private class GenericClass<TType> { public required TType Value { get; set; } }
18171921
}

0 commit comments

Comments
 (0)