Skip to content

Fix ProducesResponseType's Description not being set for Minimal API's when attribute and inferred types aren't an exact match #62695

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
Show file tree
Hide file tree
Changes from 10 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
Original file line number Diff line number Diff line change
Expand Up @@ -404,15 +404,28 @@ private static void AddSupportedResponseTypes(
string? matchingDescription = null;
foreach (var metadata in responseMetadataTypes)
{
if (metadata.StatusCode == apiResponseType.StatusCode &&
metadata.Type == apiResponseType.Type &&
metadata.Description is not null)
if (metadata?.StatusCode == apiResponseType?.StatusCode &&
TypesAreCompatible(apiResponseType?.Type, metadata?.Type) &&
metadata?.Description is not null)
{
matchingDescription = metadata.Description;
}
}
return matchingDescription;
}

static bool TypesAreCompatible(Type? apiResponseType, Type? metadaType)
{
// We need to a special check for cases where the inferred type is different than the one specified in attributes.
// For example, an endpoint that defines [ProducesResponseType<IEnumerable<WeatherForecast>>],
// but the endpoint returns weatherForecasts.ToList(). Because List<> is a different type than IEnumerable<>, it would incorrectly return false.
// Currently, we do a "simple" bidirectional check to see if the types are assignable to each other.
// This isn't very thorough, but it works for most cases.
// For more information, check the related bug: https://github.com/dotnet/aspnetcore/issues/60518
return apiResponseType == metadaType ||
metadaType?.IsAssignableFrom(apiResponseType) == true ||
apiResponseType?.IsAssignableFrom(metadaType) == true;
}
}

private static ApiResponseType CreateDefaultApiResponseType(Type responseType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,158 @@ public void AddsResponseDescription()
Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description);
}

[Fact]
public void AddsResponseDescription_WorksWithGenerics()
{
const string expectedOkDescription = "The weather forecast for the next 5 days.";

var apiDescription = GetApiDescription([ProducesResponseType<GenericClass<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
() => new GenericClass<TimeSpan> { Value = new TimeSpan() });

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

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(GenericClass<TimeSpan>), okResponseType.Type);
Assert.Equal(typeof(GenericClass<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedOkDescription, okResponseType.Description);

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

[Fact]
public void AddsResponseDescription_WorksWithGenericsAndTypedResults()
{
const string expectedOkDescription = "The weather forecast for the next 5 days.";

var apiDescription = GetApiDescription([ProducesResponseType<GenericClass<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
() => TypedResults.Ok(new GenericClass<TimeSpan> { Value = new TimeSpan() }));

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

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(GenericClass<TimeSpan>), okResponseType.Type);
Assert.Equal(typeof(GenericClass<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedOkDescription, okResponseType.Description);

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

[Fact]
public void AddsResponseDescription_WorksWithCollections()
{
const string expectedOkDescription = "The weather forecast for the next 5 days.";

var apiDescription = GetApiDescription([ProducesResponseType<IEnumerable<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
() => new List<TimeSpan> { new() });

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

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(List<TimeSpan>), okResponseType.Type); // We use List as the inferred type has higher priority than those set by metadata (attributes)
Assert.Equal(typeof(List<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedOkDescription, okResponseType.Description);

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

[Fact]
public void AddsResponseDescription_WorksWithCollectionsAndTypedResults()
{
const string expectedOkDescription = "The weather forecast for the next 5 days.";

var apiDescription = GetApiDescription([ProducesResponseType<IEnumerable<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
() => TypedResults.Ok(new List<TimeSpan> { new() }));

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

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(List<TimeSpan>), okResponseType.Type); // We use List as the inferred type has higher priority than those set by metadata (attributes)
Assert.Equal(typeof(List<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedOkDescription, okResponseType.Description);

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

[Fact]
public void AddsResponseDescription_WorksWithCollectionsWhereInnerTypeIsInEndpoint()
{
const string expectedOkDescription = "The weather forecast for the next 5 days.";

var apiDescription = GetApiDescription([ProducesResponseType<List<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
() => new List<TimeSpan> { new() }.AsEnumerable());

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

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(IEnumerable<TimeSpan>), okResponseType.Type); // We use IEnumerable<TimeSpan> as the inferred type has higher priority than those set by metadata (attributes)
Assert.Equal(typeof(IEnumerable<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedOkDescription, okResponseType.Description);

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

[Fact]
public void AddsResponseDescription_WorksWithCollectionsAndTypedResultsWhereInnerTypeIsInEndpoint()
{
const string expectedOkDescription = "The weather forecast for the next 5 days.";

var apiDescription = GetApiDescription([ProducesResponseType<List<TimeSpan>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
() => TypedResults.Ok(new List<TimeSpan> { new() }.AsEnumerable()));

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

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(IEnumerable<TimeSpan>), okResponseType.Type); // We use IEnumerable<TimeSpan> as the inferred type has higher priority than those set by metadata (attributes)
Assert.Equal(typeof(IEnumerable<TimeSpan>), okResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedOkDescription, okResponseType.Description);

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

[Fact]
public void AddsResponseDescription_WorksWithCollectionsWhereInnerTypeIsInEndpointAndIsBaseClass()
{
const string expectedOkDescription = "The weather forecast for the next 5 days.";

var apiDescription = GetApiDescription([ProducesResponseType<List<GenericClass<string>>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
() => new List<BaseClass> { new() }.AsEnumerable());

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

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(IEnumerable<BaseClass>), okResponseType.Type); // We use IEnumerable<BaseClass> as the inferred type has higher priority than those set by metadata (attributes)
Assert.Equal(typeof(IEnumerable<BaseClass>), okResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedOkDescription, okResponseType.Description);

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

[Fact]
public void AddsResponseDescription_WorksWithCollectionsAndTypedResultsWhereInnerTypeIsInEndpointAndIsBaseClass()
{
const string expectedOkDescription = "The weather forecast for the next 5 days.";

var apiDescription = GetApiDescription([ProducesResponseType<List<GenericClass<string>>>(StatusCodes.Status200OK, Description = expectedOkDescription)]
() => TypedResults.Ok(new List<BaseClass> { new() }.AsEnumerable()));

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

Assert.Equal(200, okResponseType.StatusCode);
Assert.Equal(typeof(IEnumerable<BaseClass>), okResponseType.Type); // We use IEnumerable<BaseClass> as the inferred type has higher priority than those set by metadata (attributes)
Assert.Equal(typeof(IEnumerable<BaseClass>), okResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedOkDescription, okResponseType.Description);

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

[Fact]
public void WithEmptyMethodBody_AddsResponseDescription()
{
Expand Down Expand Up @@ -1814,4 +1966,7 @@ private class TestServiceProvider : IServiceProvider
return null;
}
}

private class GenericClass<TType> : BaseClass { public required TType Value { get; set; } }
private class BaseClass { }
}
Loading