diff --git a/src/OpenApi/perf/GenerationBenchmarks.cs b/src/OpenApi/perf/GenerationBenchmarks.cs new file mode 100644 index 000000000000..1c676b4545f8 --- /dev/null +++ b/src/OpenApi/perf/GenerationBenchmarks.cs @@ -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; + +/// +/// 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. +/// +[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(); + } +} diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index 8176afc6fccb..90e039c29f86 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -20,6 +20,7 @@ return Task.CompletedTask; }); }); +builder.Services.AddOpenApi("responses"); var app = builder.Build(); @@ -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."); @@ -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(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(contentType: "text/xml"); + +app.Run(); diff --git a/src/OpenApi/sample/Sample.csproj b/src/OpenApi/sample/Sample.csproj index f05bd6e2d45c..882dbbed211d 100644 --- a/src/OpenApi/sample/Sample.csproj +++ b/src/OpenApi/sample/Sample.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index f12acc4737a6..9ab82ba85470 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -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"; } diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index f624db3539f5..faca9d9b759d 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -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; @@ -32,6 +36,7 @@ internal sealed class OpenApiDocumentService( /// operations, API descriptions, and their respective transformer contexts. /// private readonly ConcurrentDictionary _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); @@ -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, }; @@ -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() + }; + + // 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 + // looks for when generating ApiResponseFormats above so we need to pull the content + // types defined there separately. + var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata + .OfType() + .SelectMany(attr => attr.ContentTypes); + foreach (var contentType in explicitContentTypes) + { + response.Content[contentType] = new OpenApiMediaType(); + } + + return response; + } + private static List? GetParameters(ApiDescription description) { List? parameters = null; diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs new file mode 100644 index 000000000000..640073eeebc9 --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs @@ -0,0 +1,257 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; + +public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GetOpenApiResponse_SupportsMultipleResponseViaAttributes() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + () => { }); + + // 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("201", response.Key); + Assert.Equal("Created", response.Value.Description); + }, + response => + { + Assert.Equal("400", response.Key); + Assert.Equal("Bad Request", response.Value.Description); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsProblemDetailsResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status400BadRequest, typeof(ProblemDetails), ["application/json+problem"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("400", response.Key); + Assert.Equal("Bad Request", response.Value.Description); + Assert.Equal("application/json+problem", response.Value.Content.Keys.Single()); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsMultipleResponsesForStatusCode() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + // Simulates metadata provided by IEndpointMetadataProvider + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK)) + // Simulates metadata added via `Produces` call + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(string), ["text/plain"])); + + // Assert + await VerifyOpenApiDocument(builder, document => { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + var content = Assert.Single(response.Value.Content); + Assert.Equal("text/plain", content.Key); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsMultipleResponseTypesWithTypeForStatusCode() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + // Simulates metadata provided by IEndpointMetadataProvider + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"])) + // Simulates metadata added via `Produces` call + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TodoWithDueDate), ["application/json"])); + + // Assert + await VerifyOpenApiDocument(builder, document => { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + var content = Assert.Single(response.Value.Content); + Assert.Equal("application/json", content.Key); + // Todo: Check that this generates a schema using `oneOf`. + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsMultipleResponseTypesWitDifferentContentTypes() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json", "application/xml"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + Assert.Collection(response.Value.Content.OrderBy(c => c.Key), + content => + { + Assert.Equal("application/json", content.Key); + }, + content => + { + Assert.Equal("application/xml", content.Key); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsDifferentResponseTypesWitDifferentContentTypes() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TodoWithDueDate), ["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/xml"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + Assert.Collection(response.Value.Content.OrderBy(c => c.Key), + content => + { + Assert.Equal("application/xml", content.Key); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_ProducesDefaultResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsMvcProducesAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", [Produces("application/json", "application/xml")] () => new Todo(1, "Test todo", false, DateTime.Now)); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + Assert.Collection(response.Value.Content.OrderBy(c => c.Key), + content => + { + Assert.Equal("application/json", content.Key); + }, + content => + { + Assert.Equal("application/xml", content.Key); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsGeneratingDefaultResponseField() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", [ProducesDefaultResponseType(typeof(Error))] () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal(Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultOpenApiResponseKey, response.Key); + Assert.Empty(response.Value.Description); + // Todo: Validate generated schema. + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsGeneratingDefaultResponseWithSuccessResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", [ProducesDefaultResponseType(typeof(Error))] () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var defaultResponse = operation.Responses[Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultOpenApiResponseKey]; + Assert.NotNull(defaultResponse); + Assert.Empty(defaultResponse.Description); + var okResponse = operation.Responses["200"]; + Assert.NotNull(okResponse); + Assert.Equal("OK", okResponse.Description); + Assert.Equal("application/json", Assert.Single(okResponse.Content).Key); + // Todo: Validate generated schema. + }); + } +}