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.
+ });
+ }
+}