Skip to content

Commit 2dadec3

Browse files
authored
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

Lines changed: 18 additions & 0 deletions
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

Lines changed: 60 additions & 0 deletions
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

Lines changed: 113 additions & 0 deletions
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

Lines changed: 79 additions & 8 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy