Skip to content

Commit 4fe1a1f

Browse files
authored
Add support for generating OpenAPI responses (#55020)
* Add support for generating OpenAPI responses * Address feedback * Address more feedback
1 parent e0c652d commit 4fe1a1f

File tree

6 files changed

+383
-4
lines changed

6 files changed

+383
-4
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using BenchmarkDotNet.Attributes;
5+
using Microsoft.AspNetCore.Builder;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Routing;
8+
9+
namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks;
10+
11+
/// <summary>
12+
/// The following benchmarks are used to assess the performance of the
13+
/// core OpenAPI document generation logic. The parameter under test here
14+
/// is the number of endpoints/operations that are defined in the application.
15+
/// </summary>
16+
[MemoryDiagnoser]
17+
public class GenerationBenchmarks : OpenApiDocumentServiceTestBase
18+
{
19+
[Params(10, 100, 1000)]
20+
public int EndpointCount { get; set; }
21+
22+
private readonly IEndpointRouteBuilder _builder = CreateBuilder();
23+
private readonly OpenApiOptions _options = new OpenApiOptions();
24+
private OpenApiDocumentService _documentService;
25+
26+
[GlobalSetup(Target = nameof(GenerateDocument))]
27+
public void OperationTransformerAsDelegate_Setup()
28+
{
29+
_builder.MapGet("/", () => { });
30+
for (var i = 0; i <= EndpointCount; i++)
31+
{
32+
_builder.MapGet($"/{i}", (int i) => new Todo(1, "Write benchmarks", false, DateTime.Now));
33+
_builder.MapPost($"/{i}", (Todo todo) => Results.Ok());
34+
_builder.MapDelete($"/{i}", (string id) => Results.NoContent());
35+
}
36+
_documentService = CreateDocumentService(_builder, _options);
37+
}
38+
39+
[Benchmark]
40+
public async Task GenerateDocument()
41+
{
42+
await _documentService.GetOpenApiDocumentAsync();
43+
}
44+
}

src/OpenApi/sample/Program.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
return Task.CompletedTask;
2121
});
2222
});
23+
builder.Services.AddOpenApi("responses");
2324

2425
var app = builder.Build();
2526

@@ -31,9 +32,10 @@
3132

3233
var v1 = app.MapGroup("v1")
3334
.WithGroupName("v1");
34-
3535
var v2 = app.MapGroup("v2")
3636
.WithGroupName("v2");
37+
var responses = app.MapGroup("responses")
38+
.WithGroupName("responses");
3739

3840
v1.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo))
3941
.WithSummary("Creates a new todo item.");
@@ -45,7 +47,10 @@
4547

4648
v2.MapPost("/users", () => Results.Created("/users/1", new { Id = 1, Name = "Test user" }));
4749

48-
app.Run();
50+
responses.MapGet("/200-add-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
51+
.Produces<Todo>(additionalContentTypes: "text/xml");
4952

50-
public record Todo(int Id, string Title, bool Completed, DateTime CreatedAt);
51-
public record TodoWithDueDate(int Id, string Title, bool Completed, DateTime CreatedAt, DateTime DueDate) : Todo(Id, Title, Completed, CreatedAt);
53+
responses.MapGet("/200-only-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
54+
.Produces<Todo>(contentType: "text/xml");
55+
56+
app.Run();

src/OpenApi/sample/Sample.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,8 @@
1818
<Reference Include="Microsoft.AspNetCore.Mvc" />
1919
</ItemGroup>
2020

21+
<ItemGroup>
22+
<Compile Include="../test/SharedTypes.cs" />
23+
</ItemGroup>
24+
2125
</Project>

src/OpenApi/src/Services/OpenApiConstants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ internal static class OpenApiConstants
99
internal const string DefaultOpenApiVersion = "1.0.0";
1010
internal const string DefaultOpenApiRoute = "/openapi/{documentName}.json";
1111
internal const string DescriptionId = "x-aspnetcore-id";
12+
internal const string DefaultOpenApiResponseKey = "default";
1213
}

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
using System.Collections.Concurrent;
55
using System.Diagnostics;
66
using System.Diagnostics.CodeAnalysis;
7+
using System.Globalization;
78
using System.Linq;
9+
using Microsoft.AspNetCore.Http;
810
using Microsoft.AspNetCore.Http.Metadata;
11+
using Microsoft.AspNetCore.Mvc;
912
using Microsoft.AspNetCore.Mvc.ApiExplorer;
1013
using Microsoft.AspNetCore.Mvc.ModelBinding;
14+
using Microsoft.AspNetCore.WebUtilities;
1115
using Microsoft.Extensions.DependencyInjection;
1216
using Microsoft.Extensions.Hosting;
1317
using Microsoft.Extensions.Options;
@@ -32,6 +36,7 @@ internal sealed class OpenApiDocumentService(
3236
/// operations, API descriptions, and their respective transformer contexts.
3337
/// </summary>
3438
private readonly ConcurrentDictionary<string, OpenApiOperationTransformerContext> _operationTransformerContextCache = new();
39+
private static readonly ApiResponseType _defaultApiResponseType = new ApiResponseType { StatusCode = StatusCodes.Status200OK };
3540

3641
internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context)
3742
=> _operationTransformerContextCache.TryGetValue(descriptionId, out context);
@@ -133,6 +138,7 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet
133138
{
134139
Summary = GetSummary(description),
135140
Description = GetDescription(description),
141+
Responses = GetResponses(description),
136142
Parameters = GetParameters(description),
137143
Tags = tags,
138144
};
@@ -157,6 +163,68 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet
157163
return [new OpenApiTag { Name = description.ActionDescriptor.RouteValues["controller"] }];
158164
}
159165

166+
private static OpenApiResponses GetResponses(ApiDescription description)
167+
{
168+
// OpenAPI requires that each operation have a response, usually a successful one.
169+
// if there are no response types defined, we assume a successful 200 OK response
170+
// with no content by default.
171+
if (description.SupportedResponseTypes.Count == 0)
172+
{
173+
return new OpenApiResponses
174+
{
175+
["200"] = GetResponse(description, StatusCodes.Status200OK, _defaultApiResponseType)
176+
};
177+
}
178+
179+
var responses = new OpenApiResponses();
180+
foreach (var responseType in description.SupportedResponseTypes)
181+
{
182+
// The "default" response type is a special case in OpenAPI used to describe
183+
// the response for all HTTP status codes that are not explicitly defined
184+
// for a given operation. This is typically used to describe catch-all scenarios
185+
// like error responses.
186+
var responseKey = responseType.IsDefaultResponse
187+
? OpenApiConstants.DefaultOpenApiResponseKey
188+
: responseType.StatusCode.ToString(CultureInfo.InvariantCulture);
189+
responses.Add(responseKey, GetResponse(description, responseType.StatusCode, responseType));
190+
}
191+
return responses;
192+
}
193+
194+
private static OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType)
195+
{
196+
var description = ReasonPhrases.GetReasonPhrase(statusCode);
197+
var response = new OpenApiResponse
198+
{
199+
Description = description,
200+
Content = new Dictionary<string, OpenApiMediaType>()
201+
};
202+
203+
// ApiResponseFormats aggregates information about the supported response content types
204+
// from different types of Produces metadata. This is handled by ApiExplorer so looking
205+
// up values in ApiResponseFormats should provide us a complete set of the information
206+
// encoded in Produces metadata added via attributes or extension methods.
207+
var apiResponseFormatContentTypes = apiResponseType.ApiResponseFormats
208+
.Select(responseFormat => responseFormat.MediaType);
209+
foreach (var contentType in apiResponseFormatContentTypes)
210+
{
211+
response.Content[contentType] = new OpenApiMediaType();
212+
}
213+
214+
// MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer
215+
// looks for when generating ApiResponseFormats above so we need to pull the content
216+
// types defined there separately.
217+
var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata
218+
.OfType<ProducesAttribute>()
219+
.SelectMany(attr => attr.ContentTypes);
220+
foreach (var contentType in explicitContentTypes)
221+
{
222+
response.Content[contentType] = new OpenApiMediaType();
223+
}
224+
225+
return response;
226+
}
227+
160228
private static List<OpenApiParameter>? GetParameters(ApiDescription description)
161229
{
162230
List<OpenApiParameter>? parameters = null;

0 commit comments

Comments
 (0)