Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,9 @@ private static void AddSupportedResponseTypes(
apiResponseType.ApiResponseFormats.Add(defaultResponseFormat);
}

// We set the Description to the LAST non-null value we find that matches the status code.
apiResponseType.Description ??= responseMetadataTypes.LastOrDefault(x => x.StatusCode == apiResponseType.StatusCode && x.Type == apiResponseType.Type && x.Description is not null)?.Description;

if (!supportedResponseTypes.Any(existingResponseType => existingResponseType.StatusCode == apiResponseType.StatusCode))
{
supportedResponseTypes.Add(apiResponseType);
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