Skip to content

Setting the Description in a ProducesResponseTypeAttribute works correctly for Minimal API #60539

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
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ private static void AddSupportedResponseTypes(
apiResponseType.ApiResponseFormats.Add(defaultResponseFormat);
}

apiResponseType.Description ??= GetMatchingResponseTypeDescription(responseProviderMetadataTypes.Values, apiResponseType);

if (!supportedResponseTypes.Any(existingResponseType => existingResponseType.StatusCode == apiResponseType.StatusCode))
{
supportedResponseTypes.Add(apiResponseType);
Expand All @@ -395,6 +397,22 @@ private static void AddSupportedResponseTypes(

supportedResponseTypes.Add(defaultApiResponseType);
}

static string? GetMatchingResponseTypeDescription(IEnumerable<ApiResponseType> responseMetadataTypes, ApiResponseType apiResponseType)
{
// We set the Description to the LAST non-null value we find that matches the status code.
string? matchingDescription = null;
foreach (var metadata in responseMetadataTypes)
{
if (metadata.StatusCode == apiResponseType.StatusCode &&
metadata.Type == apiResponseType.Type &&
metadata.Description is not null)
{
matchingDescription = metadata.Description;
}
}
return matchingDescription;
}
}

private static ApiResponseType CreateDefaultApiResponseType(Type responseType)
Expand Down
60 changes: 60 additions & 0 deletions src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,66 @@ public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem()
});
}

[Fact]
public void GetApiResponseTypes_ReturnsDescriptionFromProducesResponseType()
{
// Arrange

const string expectedOkDescription = "All is well";
const string expectedBadRequestDescription = "Invalid request";
const string expectedNotFoundDescription = "Something was not found";

var actionDescriptor = GetControllerActionDescriptor(
typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController),
nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase));

actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new[]
{
new ProducesResponseTypeAttribute(200) { Description = expectedOkDescription},
new ProducesResponseTypeAttribute(400) { Description = expectedBadRequestDescription },
new ProducesResponseTypeAttribute(404) { Description = expectedNotFoundDescription },
});

var provider = GetProvider();

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

// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.Equal(200, responseType.StatusCode);
Assert.Equal(typeof(BaseModel), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Equal(expectedOkDescription, responseType.Description);
Assert.Collection(
responseType.ApiResponseFormats,
format =>
{
Assert.Equal("application/json", format.MediaType);
Assert.IsType<TestOutputFormatter>(format.Formatter);
});
},
responseType =>
{
Assert.Equal(400, responseType.StatusCode);
Assert.Equal(typeof(void), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Empty(responseType.ApiResponseFormats);
Assert.Equal(expectedBadRequestDescription, responseType.Description);
},
responseType =>
{
Assert.Equal(404, responseType.StatusCode);
Assert.Equal(typeof(void), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Empty(responseType.ApiResponseFormats);
Assert.Equal(expectedNotFoundDescription, responseType.Description);
});
}

[ApiConventionType(typeof(DefaultApiConventions))]
public class GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController : ControllerBase
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,119 @@ public void AddsMultipleResponseFormatsForTypedResults()
Assert.Empty(badRequestResponseType.ApiResponseFormats);
}

[Fact]
public void AddsResponseDescription()
{
const string expectedCreatedDescription = "A new item was created";
const string expectedBadRequestDescription = "Validation failed for the request";

var apiDescription = GetApiDescription(
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
() => TypedResults.Created("https://example.com", new TimeSpan()));

Assert.Equal(2, apiDescription.SupportedResponseTypes.Count);

var createdResponseType = apiDescription.SupportedResponseTypes[0];

Assert.Equal(201, createdResponseType.StatusCode);
Assert.Equal(typeof(TimeSpan), createdResponseType.Type);
Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedCreatedDescription, createdResponseType.Description);

var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats);
Assert.Equal("application/json", createdResponseFormat.MediaType);

var badRequestResponseType = apiDescription.SupportedResponseTypes[1];

Assert.Equal(400, badRequestResponseType.StatusCode);
Assert.Equal(typeof(void), badRequestResponseType.Type);
Assert.Equal(typeof(void), badRequestResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description);
}

[Fact]
public void WithEmptyMethodBody_AddsResponseDescription()
{
const string expectedCreatedDescription = "A new item was created";
const string expectedBadRequestDescription = "Validation failed for the request";

var apiDescription = GetApiDescription(
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
() => new InferredJsonClass());

Assert.Equal(3, apiDescription.SupportedResponseTypes.Count);

var rdfInferredResponseType = apiDescription.SupportedResponseTypes[0];

Assert.Equal(200, rdfInferredResponseType.StatusCode);
Assert.Equal(typeof(InferredJsonClass), rdfInferredResponseType.Type);
Assert.Equal(typeof(InferredJsonClass), rdfInferredResponseType.ModelMetadata?.ModelType);

var rdfInferredResponseFormat = Assert.Single(rdfInferredResponseType.ApiResponseFormats);
Assert.Equal("application/json", rdfInferredResponseFormat.MediaType);
Assert.Null(rdfInferredResponseType.Description); // There is no description set for the default "200" code, so we expect it to be null.

var createdResponseType = apiDescription.SupportedResponseTypes[1];

Assert.Equal(201, createdResponseType.StatusCode);
Assert.Equal(typeof(TimeSpan), createdResponseType.Type);
Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedCreatedDescription, createdResponseType.Description);

var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats);
Assert.Equal("application/json", createdResponseFormat.MediaType);

var badRequestResponseType = apiDescription.SupportedResponseTypes[2];

Assert.Equal(400, badRequestResponseType.StatusCode);
Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.Type);
Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description);

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

/// <summary>
/// Setting the description grabs the LAST description.
// To validate this, we add multiple ProducesResponseType to validate that it only grabs the LAST ONE.
/// </summary>
[Fact]
public void AddsResponseDescription_UsesLastOne()
{
const string expectedCreatedDescription = "A new item was created";
const string expectedBadRequestDescription = "Validation failed for the request";

var apiDescription = GetApiDescription(
[ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "First description")] // The first item is an int, not a timespan, shouldn't match
[ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "Second description")] // Not a timespan AND not the final item, shouldn't match
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] // This is the last item, which should match
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = "First description")]
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
() => TypedResults.Created("https://example.com", new TimeSpan()));

Assert.Equal(2, apiDescription.SupportedResponseTypes.Count);

var createdResponseType = apiDescription.SupportedResponseTypes[0];

Assert.Equal(201, createdResponseType.StatusCode);
Assert.Equal(typeof(TimeSpan), createdResponseType.Type);
Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedCreatedDescription, createdResponseType.Description);

var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats);
Assert.Equal("application/json", createdResponseFormat.MediaType);

var badRequestResponseType = apiDescription.SupportedResponseTypes[1];

Assert.Equal(400, badRequestResponseType.StatusCode);
Assert.Equal(typeof(void), badRequestResponseType.Type);
Assert.Equal(typeof(void), badRequestResponseType.ModelMetadata?.ModelType);
Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description);
}

[Fact]
public void AddsResponseFormatsForTypedResultWithoutReturnType()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,11 @@ await VerifyOpenApiDocument(builder, document =>
});
}

/// <remarks>
/// Regression test for https://github.com/dotnet/aspnetcore/issues/60518
/// </remarks>
[Fact]
public async Task GetOpenApiResponse_UsesDescriptionSetByUser()
public async Task GetOpenApiResponse_WithEmptyMethodBody_UsesDescriptionSetByUser()
{
// Arrange
var builder = CreateBuilder();
Expand All @@ -315,8 +318,8 @@ public async Task GetOpenApiResponse_UsesDescriptionSetByUser()
const string expectedBadRequestDescription = "Validation failed for the request";

// Act
builder.MapGet("/api/todos",
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)]
builder.MapPost("/api/todos",
[ProducesResponseType<Todo>(StatusCodes.Status200OK, Description = expectedCreatedDescription)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
() =>
{ });
Expand All @@ -328,7 +331,41 @@ await VerifyOpenApiDocument(builder, document =>
Assert.Collection(operation.Responses.OrderBy(r => r.Key),
response =>
{
Assert.Equal("201", response.Key);
Assert.Equal("200", response.Key);
Assert.Equal(expectedCreatedDescription, response.Value.Description);
},
response =>
{
Assert.Equal("400", response.Key);
Assert.Equal(expectedBadRequestDescription, response.Value.Description);
});
});
}

[Fact]
public async Task GetOpenApiResponse_UsesDescriptionSetByUser()
{
// Arrange
var builder = CreateBuilder();

const string expectedCreatedDescription = "A new todo item was created";
const string expectedBadRequestDescription = "Validation failed for the request";

// Act
builder.MapPost("/api/todos",
[ProducesResponseType<Todo>(StatusCodes.Status200OK, Description = expectedCreatedDescription)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
() =>
{ return TypedResults.Ok(new Todo(1, "Lorem", true, DateTime.UtcNow)); }); // This code doesn't return Bad Request, but that doesn't matter for this test.

// Assert
await VerifyOpenApiDocument(builder, document =>
{
var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
Assert.Collection(operation.Responses.OrderBy(r => r.Key),
response =>
{
Assert.Equal("200", response.Key);
Assert.Equal(expectedCreatedDescription, response.Value.Description);
},
response =>
Expand All @@ -346,8 +383,42 @@ public async Task GetOpenApiResponse_UsesStatusCodeReasonPhraseWhenExplicitDescr
var builder = CreateBuilder();

// Act
builder.MapGet("/api/todos",
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = null)] // Explicitly set to NULL
builder.MapPost("/api/todos",
[ProducesResponseType<Todo>(StatusCodes.Status200OK, Description = null)] // Explicitly set to NULL
[ProducesResponseType(StatusCodes.Status400BadRequest)] // Omitted, meaning it should be NULL
() =>
{ return TypedResults.Ok(new Todo(1, "Lorem", true, DateTime.UtcNow)); }); // This code doesn't return Bad Request, but that doesn't matter for this test.

// Assert
await VerifyOpenApiDocument(builder, document =>
{
var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
Assert.Collection(operation.Responses.OrderBy(r => r.Key),
response =>
{
Assert.Equal("200", response.Key);
Assert.Equal("OK", response.Value.Description);
},
response =>
{
Assert.Equal("400", response.Key);
Assert.Equal("Bad Request", response.Value.Description);
});
});
}

/// <remarks>
/// Regression test for https://github.com/dotnet/aspnetcore/issues/60518
/// </remarks>
[Fact]
public async Task GetOpenApiResponse_WithEmptyMethodBody_UsesStatusCodeReasonPhraseWhenExplicitDescriptionIsMissing()
{
// Arrange
var builder = CreateBuilder();

// Act
builder.MapPost("/api/todos",
[ProducesResponseType<Todo>(StatusCodes.Status200OK, Description = null)] // Explicitly set to NULL
[ProducesResponseType(StatusCodes.Status400BadRequest)] // Omitted, meaning it should be NULL
() =>
{ });
Expand All @@ -359,8 +430,8 @@ await VerifyOpenApiDocument(builder, document =>
Assert.Collection(operation.Responses.OrderBy(r => r.Key),
response =>
{
Assert.Equal("201", response.Key);
Assert.Equal("Created", response.Value.Description);
Assert.Equal("200", response.Key);
Assert.Equal("OK", response.Value.Description);
},
response =>
{
Expand Down
Loading