Skip to content

Commit 2dadec3

Browse files
authoredMar 11, 2025
Setting the Description in a ProducesResponseTypeAttribute works correctly for Minimal API (#60539)
* Improve existing test * Add more tests for regression * Add tests which now succeed * Add more tests * Set the description to the final one encountered and add tests * Use local method as requested by PR comment * Fix comment
1 parent f25dc7b commit 2dadec3

File tree

4 files changed

+270
-8
lines changed

4 files changed

+270
-8
lines changed
 

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

+18
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,8 @@ private static void AddSupportedResponseTypes(
375375
apiResponseType.ApiResponseFormats.Add(defaultResponseFormat);
376376
}
377377

378+
apiResponseType.Description ??= GetMatchingResponseTypeDescription(responseProviderMetadataTypes.Values, apiResponseType);
379+
378380
if (!supportedResponseTypes.Any(existingResponseType => existingResponseType.StatusCode == apiResponseType.StatusCode))
379381
{
380382
supportedResponseTypes.Add(apiResponseType);
@@ -395,6 +397,22 @@ private static void AddSupportedResponseTypes(
395397

396398
supportedResponseTypes.Add(defaultApiResponseType);
397399
}
400+
401+
static string? GetMatchingResponseTypeDescription(IEnumerable<ApiResponseType> responseMetadataTypes, ApiResponseType apiResponseType)
402+
{
403+
// We set the Description to the LAST non-null value we find that matches the status code.
404+
string? matchingDescription = null;
405+
foreach (var metadata in responseMetadataTypes)
406+
{
407+
if (metadata.StatusCode == apiResponseType.StatusCode &&
408+
metadata.Type == apiResponseType.Type &&
409+
metadata.Description is not null)
410+
{
411+
matchingDescription = metadata.Description;
412+
}
413+
}
414+
return matchingDescription;
415+
}
398416
}
399417

400418
private static ApiResponseType CreateDefaultApiResponseType(Type responseType)

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

+60
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,66 @@ public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem()
186186
});
187187
}
188188

189+
[Fact]
190+
public void GetApiResponseTypes_ReturnsDescriptionFromProducesResponseType()
191+
{
192+
// Arrange
193+
194+
const string expectedOkDescription = "All is well";
195+
const string expectedBadRequestDescription = "Invalid request";
196+
const string expectedNotFoundDescription = "Something was not found";
197+
198+
var actionDescriptor = GetControllerActionDescriptor(
199+
typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController),
200+
nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase));
201+
202+
actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new[]
203+
{
204+
new ProducesResponseTypeAttribute(200) { Description = expectedOkDescription},
205+
new ProducesResponseTypeAttribute(400) { Description = expectedBadRequestDescription },
206+
new ProducesResponseTypeAttribute(404) { Description = expectedNotFoundDescription },
207+
});
208+
209+
var provider = GetProvider();
210+
211+
// Act
212+
var result = provider.GetApiResponseTypes(actionDescriptor);
213+
214+
// Assert
215+
Assert.Collection(
216+
result.OrderBy(r => r.StatusCode),
217+
responseType =>
218+
{
219+
Assert.Equal(200, responseType.StatusCode);
220+
Assert.Equal(typeof(BaseModel), responseType.Type);
221+
Assert.False(responseType.IsDefaultResponse);
222+
Assert.Equal(expectedOkDescription, responseType.Description);
223+
Assert.Collection(
224+
responseType.ApiResponseFormats,
225+
format =>
226+
{
227+
Assert.Equal("application/json", format.MediaType);
228+
Assert.IsType<TestOutputFormatter>(format.Formatter);
229+
});
230+
},
231+
responseType =>
232+
{
233+
Assert.Equal(400, responseType.StatusCode);
234+
Assert.Equal(typeof(void), responseType.Type);
235+
Assert.False(responseType.IsDefaultResponse);
236+
Assert.Empty(responseType.ApiResponseFormats);
237+
Assert.Equal(expectedBadRequestDescription, responseType.Description);
238+
},
239+
responseType =>
240+
{
241+
Assert.Equal(404, responseType.StatusCode);
242+
Assert.Equal(typeof(void), responseType.Type);
243+
Assert.False(responseType.IsDefaultResponse);
244+
Assert.Empty(responseType.ApiResponseFormats);
245+
Assert.Equal(expectedNotFoundDescription, responseType.Description);
246+
});
247+
}
248+
189249
[ApiConventionType(typeof(DefaultApiConventions))]
190250
public class GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController : ControllerBase
191251
{

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

+113
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,119 @@ public void AddsMultipleResponseFormatsForTypedResults()
300300
Assert.Empty(badRequestResponseType.ApiResponseFormats);
301301
}
302302

303+
[Fact]
304+
public void AddsResponseDescription()
305+
{
306+
const string expectedCreatedDescription = "A new item was created";
307+
const string expectedBadRequestDescription = "Validation failed for the request";
308+
309+
var apiDescription = GetApiDescription(
310+
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)]
311+
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
312+
() => TypedResults.Created("https://example.com", new TimeSpan()));
313+
314+
Assert.Equal(2, apiDescription.SupportedResponseTypes.Count);
315+
316+
var createdResponseType = apiDescription.SupportedResponseTypes[0];
317+
318+
Assert.Equal(201, createdResponseType.StatusCode);
319+
Assert.Equal(typeof(TimeSpan), createdResponseType.Type);
320+
Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType);
321+
Assert.Equal(expectedCreatedDescription, createdResponseType.Description);
322+
323+
var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats);
324+
Assert.Equal("application/json", createdResponseFormat.MediaType);
325+
326+
var badRequestResponseType = apiDescription.SupportedResponseTypes[1];
327+
328+
Assert.Equal(400, badRequestResponseType.StatusCode);
329+
Assert.Equal(typeof(void), badRequestResponseType.Type);
330+
Assert.Equal(typeof(void), badRequestResponseType.ModelMetadata?.ModelType);
331+
Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description);
332+
}
333+
334+
[Fact]
335+
public void WithEmptyMethodBody_AddsResponseDescription()
336+
{
337+
const string expectedCreatedDescription = "A new item was created";
338+
const string expectedBadRequestDescription = "Validation failed for the request";
339+
340+
var apiDescription = GetApiDescription(
341+
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)]
342+
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
343+
() => new InferredJsonClass());
344+
345+
Assert.Equal(3, apiDescription.SupportedResponseTypes.Count);
346+
347+
var rdfInferredResponseType = apiDescription.SupportedResponseTypes[0];
348+
349+
Assert.Equal(200, rdfInferredResponseType.StatusCode);
350+
Assert.Equal(typeof(InferredJsonClass), rdfInferredResponseType.Type);
351+
Assert.Equal(typeof(InferredJsonClass), rdfInferredResponseType.ModelMetadata?.ModelType);
352+
353+
var rdfInferredResponseFormat = Assert.Single(rdfInferredResponseType.ApiResponseFormats);
354+
Assert.Equal("application/json", rdfInferredResponseFormat.MediaType);
355+
Assert.Null(rdfInferredResponseType.Description); // There is no description set for the default "200" code, so we expect it to be null.
356+
357+
var createdResponseType = apiDescription.SupportedResponseTypes[1];
358+
359+
Assert.Equal(201, createdResponseType.StatusCode);
360+
Assert.Equal(typeof(TimeSpan), createdResponseType.Type);
361+
Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType);
362+
Assert.Equal(expectedCreatedDescription, createdResponseType.Description);
363+
364+
var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats);
365+
Assert.Equal("application/json", createdResponseFormat.MediaType);
366+
367+
var badRequestResponseType = apiDescription.SupportedResponseTypes[2];
368+
369+
Assert.Equal(400, badRequestResponseType.StatusCode);
370+
Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.Type);
371+
Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.ModelMetadata?.ModelType);
372+
Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description);
373+
374+
var badRequestResponseFormat = Assert.Single(badRequestResponseType.ApiResponseFormats);
375+
Assert.Equal("application/json", badRequestResponseFormat.MediaType);
376+
}
377+
378+
/// <summary>
379+
/// Setting the description grabs the LAST description.
380+
// To validate this, we add multiple ProducesResponseType to validate that it only grabs the LAST ONE.
381+
/// </summary>
382+
[Fact]
383+
public void AddsResponseDescription_UsesLastOne()
384+
{
385+
const string expectedCreatedDescription = "A new item was created";
386+
const string expectedBadRequestDescription = "Validation failed for the request";
387+
388+
var apiDescription = GetApiDescription(
389+
[ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "First description")] // The first item is an int, not a timespan, shouldn't match
390+
[ProducesResponseType(typeof(int), StatusCodes.Status201Created, Description = "Second description")] // Not a timespan AND not the final item, shouldn't match
391+
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] // This is the last item, which should match
392+
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = "First description")]
393+
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
394+
() => TypedResults.Created("https://example.com", new TimeSpan()));
395+
396+
Assert.Equal(2, apiDescription.SupportedResponseTypes.Count);
397+
398+
var createdResponseType = apiDescription.SupportedResponseTypes[0];
399+
400+
Assert.Equal(201, createdResponseType.StatusCode);
401+
Assert.Equal(typeof(TimeSpan), createdResponseType.Type);
402+
Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata?.ModelType);
403+
Assert.Equal(expectedCreatedDescription, createdResponseType.Description);
404+
405+
var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats);
406+
Assert.Equal("application/json", createdResponseFormat.MediaType);
407+
408+
var badRequestResponseType = apiDescription.SupportedResponseTypes[1];
409+
410+
Assert.Equal(400, badRequestResponseType.StatusCode);
411+
Assert.Equal(typeof(void), badRequestResponseType.Type);
412+
Assert.Equal(typeof(void), badRequestResponseType.ModelMetadata?.ModelType);
413+
Assert.Equal(expectedBadRequestDescription, badRequestResponseType.Description);
414+
}
415+
303416
[Fact]
304417
public void AddsResponseFormatsForTypedResultWithoutReturnType()
305418
{

‎src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs

+79-8
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,11 @@ await VerifyOpenApiDocument(builder, document =>
305305
});
306306
}
307307

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

317320
// Act
318-
builder.MapGet("/api/todos",
319-
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)]
321+
builder.MapPost("/api/todos",
322+
[ProducesResponseType<Todo>(StatusCodes.Status200OK, Description = expectedCreatedDescription)]
320323
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
321324
() =>
322325
{ });
@@ -328,7 +331,41 @@ await VerifyOpenApiDocument(builder, document =>
328331
Assert.Collection(operation.Responses.OrderBy(r => r.Key),
329332
response =>
330333
{
331-
Assert.Equal("201", response.Key);
334+
Assert.Equal("200", response.Key);
335+
Assert.Equal(expectedCreatedDescription, response.Value.Description);
336+
},
337+
response =>
338+
{
339+
Assert.Equal("400", response.Key);
340+
Assert.Equal(expectedBadRequestDescription, response.Value.Description);
341+
});
342+
});
343+
}
344+
345+
[Fact]
346+
public async Task GetOpenApiResponse_UsesDescriptionSetByUser()
347+
{
348+
// Arrange
349+
var builder = CreateBuilder();
350+
351+
const string expectedCreatedDescription = "A new todo item was created";
352+
const string expectedBadRequestDescription = "Validation failed for the request";
353+
354+
// Act
355+
builder.MapPost("/api/todos",
356+
[ProducesResponseType<Todo>(StatusCodes.Status200OK, Description = expectedCreatedDescription)]
357+
[ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)]
358+
() =>
359+
{ 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.
360+
361+
// Assert
362+
await VerifyOpenApiDocument(builder, document =>
363+
{
364+
var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
365+
Assert.Collection(operation.Responses.OrderBy(r => r.Key),
366+
response =>
367+
{
368+
Assert.Equal("200", response.Key);
332369
Assert.Equal(expectedCreatedDescription, response.Value.Description);
333370
},
334371
response =>
@@ -346,8 +383,42 @@ public async Task GetOpenApiResponse_UsesStatusCodeReasonPhraseWhenExplicitDescr
346383
var builder = CreateBuilder();
347384

348385
// Act
349-
builder.MapGet("/api/todos",
350-
[ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = null)] // Explicitly set to NULL
386+
builder.MapPost("/api/todos",
387+
[ProducesResponseType<Todo>(StatusCodes.Status200OK, Description = null)] // Explicitly set to NULL
388+
[ProducesResponseType(StatusCodes.Status400BadRequest)] // Omitted, meaning it should be NULL
389+
() =>
390+
{ 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.
391+
392+
// Assert
393+
await VerifyOpenApiDocument(builder, document =>
394+
{
395+
var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values);
396+
Assert.Collection(operation.Responses.OrderBy(r => r.Key),
397+
response =>
398+
{
399+
Assert.Equal("200", response.Key);
400+
Assert.Equal("OK", response.Value.Description);
401+
},
402+
response =>
403+
{
404+
Assert.Equal("400", response.Key);
405+
Assert.Equal("Bad Request", response.Value.Description);
406+
});
407+
});
408+
}
409+
410+
/// <remarks>
411+
/// Regression test for https://github.com/dotnet/aspnetcore/issues/60518
412+
/// </remarks>
413+
[Fact]
414+
public async Task GetOpenApiResponse_WithEmptyMethodBody_UsesStatusCodeReasonPhraseWhenExplicitDescriptionIsMissing()
415+
{
416+
// Arrange
417+
var builder = CreateBuilder();
418+
419+
// Act
420+
builder.MapPost("/api/todos",
421+
[ProducesResponseType<Todo>(StatusCodes.Status200OK, Description = null)] // Explicitly set to NULL
351422
[ProducesResponseType(StatusCodes.Status400BadRequest)] // Omitted, meaning it should be NULL
352423
() =>
353424
{ });
@@ -359,8 +430,8 @@ await VerifyOpenApiDocument(builder, document =>
359430
Assert.Collection(operation.Responses.OrderBy(r => r.Key),
360431
response =>
361432
{
362-
Assert.Equal("201", response.Key);
363-
Assert.Equal("Created", response.Value.Description);
433+
Assert.Equal("200", response.Key);
434+
Assert.Equal("OK", response.Value.Description);
364435
},
365436
response =>
366437
{

0 commit comments

Comments
 (0)