Skip to content

Add support for generating OpenAPI responses #55020

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
merged 4 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/OpenApi/perf/GenerationBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using BenchmarkDotNet.Attributes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks;

/// <summary>
/// The following benchmarks are used to assess the performance of the
/// core OpenAPI document generation logic. The parameter under test here
/// is the number of endpoints/operations that are defined in the application.
/// </summary>
[MemoryDiagnoser]
public class GenerationBenchmarks : OpenApiDocumentServiceTestBase
{
[Params(10, 100, 1000)]
public int EndpointCount { get; set; }

private readonly IEndpointRouteBuilder _builder = CreateBuilder();
private readonly OpenApiOptions _options = new OpenApiOptions();
private OpenApiDocumentService _documentService;

[GlobalSetup(Target = nameof(GenerateDocument))]
public void OperationTransformerAsDelegate_Setup()
{
_builder.MapGet("/", () => { });
for (var i = 0; i <= EndpointCount; i++)
{
_builder.MapGet($"/{i}", (int i) => new Todo(1, "Write benchmarks", false, DateTime.Now));
_builder.MapPost($"/{i}", (Todo todo) => Results.Ok());
_builder.MapDelete($"/{i}", (string id) => Results.NoContent());
}
_documentService = CreateDocumentService(_builder, _options);
}

[Benchmark]
public async Task GenerateDocument()
{
await _documentService.GetOpenApiDocumentAsync();
}
}
13 changes: 9 additions & 4 deletions src/OpenApi/sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
return Task.CompletedTask;
});
});
builder.Services.AddOpenApi("responses");

var app = builder.Build();

Expand All @@ -31,9 +32,10 @@

var v1 = app.MapGroup("v1")
.WithGroupName("v1");

var v2 = app.MapGroup("v2")
.WithGroupName("v2");
var responses = app.MapGroup("responses")
.WithGroupName("responses");

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

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

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

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

app.Run();
4 changes: 4 additions & 0 deletions src/OpenApi/sample/Sample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@
<Reference Include="Microsoft.AspNetCore.Mvc" />
</ItemGroup>

<ItemGroup>
<Compile Include="../test/SharedTypes.cs" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions src/OpenApi/src/Services/OpenApiConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ internal static class OpenApiConstants
internal const string DefaultOpenApiVersion = "1.0.0";
internal const string DefaultOpenApiRoute = "/openapi/{documentName}.json";
internal const string DescriptionId = "x-aspnetcore-id";
internal const string DefaultOpenApiResponseKey = "default";
}
68 changes: 68 additions & 0 deletions src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
Expand All @@ -32,6 +36,7 @@ internal sealed class OpenApiDocumentService(
/// operations, API descriptions, and their respective transformer contexts.
/// </summary>
private readonly ConcurrentDictionary<string, OpenApiOperationTransformerContext> _operationTransformerContextCache = new();
private static readonly ApiResponseType _defaultApiResponseType = new ApiResponseType { StatusCode = StatusCodes.Status200OK };

internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context)
=> _operationTransformerContextCache.TryGetValue(descriptionId, out context);
Expand Down Expand Up @@ -133,6 +138,7 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet
{
Summary = GetSummary(description),
Description = GetDescription(description),
Responses = GetResponses(description),
Parameters = GetParameters(description),
Tags = tags,
};
Expand All @@ -157,6 +163,68 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet
return [new OpenApiTag { Name = description.ActionDescriptor.RouteValues["controller"] }];
}

private static OpenApiResponses GetResponses(ApiDescription description)
{
// OpenAPI requires that each operation have a response, usually a successful one.
// if there are no response types defined, we assume a successful 200 OK response
// with no content by default.
if (description.SupportedResponseTypes.Count == 0)
{
return new OpenApiResponses
{
["200"] = GetResponse(description, StatusCodes.Status200OK, _defaultApiResponseType)
};
}

var responses = new OpenApiResponses();
foreach (var responseType in description.SupportedResponseTypes)
{
// The "default" response type is a special case in OpenAPI used to describe
// the response for all HTTP status codes that are not explicitly defined
// for a given operation. This is typically used to describe catch-all scenarios
// like error responses.
var responseKey = responseType.IsDefaultResponse
? OpenApiConstants.DefaultOpenApiResponseKey
: responseType.StatusCode.ToString(CultureInfo.InvariantCulture);
responses.Add(responseKey, GetResponse(description, responseType.StatusCode, responseType));
}
return responses;
}

private static OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType)
{
var description = ReasonPhrases.GetReasonPhrase(statusCode);
var response = new OpenApiResponse
{
Description = description,
Content = new Dictionary<string, OpenApiMediaType>()
};

// ApiResponseFormats aggregates information about the supported response content types
// from different types of Produces metadata. This is handled by ApiExplorer so looking
// up values in ApiResponseFormats should provide us a complete set of the information
// encoded in Produces metadata added via attributes or extension methods.
var apiResponseFormatContentTypes = apiResponseType.ApiResponseFormats
.Select(responseFormat => responseFormat.MediaType);
foreach (var contentType in apiResponseFormatContentTypes)
{
response.Content[contentType] = new OpenApiMediaType();
}

// MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we change that? The less MVC we reference, the better.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we discussed this in the past when we originally introduced the ProducesResponseTypeMetadata type. I think the conclusion at the time was to create new metadata/attributes for these annotations that was shared between MVC/minimal instead of recycling the existing ones.

Because this implementation needs to work for both controller-based and minimal APIs, we're not going to be able to get away with having less MVC.

// looks for when generating ApiResponseFormats above so we need to pull the content
// types defined there separately.
var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata
.OfType<ProducesAttribute>()
.SelectMany(attr => attr.ContentTypes);
foreach (var contentType in explicitContentTypes)
{
response.Content[contentType] = new OpenApiMediaType();
}

return response;
}

private static List<OpenApiParameter>? GetParameters(ApiDescription description)
{
List<OpenApiParameter>? parameters = null;
Expand Down
Loading
Loading