-
Notifications
You must be signed in to change notification settings - Fork 10.4k
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,9 +4,13 @@ | |
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.WebUtilities; | ||
using Microsoft.Extensions.DependencyInjection; | ||
using Microsoft.Extensions.Hosting; | ||
using Microsoft.Extensions.Options; | ||
|
@@ -132,6 +136,7 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet | |
{ | ||
Summary = GetSummary(description), | ||
Description = GetDescription(description), | ||
Responses = GetResponses(description), | ||
Tags = tags, | ||
}; | ||
return operation; | ||
|
@@ -154,4 +159,67 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet | |
// allows us to group endpoints by the "resource" concept (e.g. users, todos, etc.) | ||
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, new ApiResponseType { StatusCode = StatusCodes.Status200OK }) | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
} | ||
|
||
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 statusCode = responseType.IsDefaultResponse | ||
? OpenApiConstants.DefaultResponseStatusCode | ||
: responseType.StatusCode.ToString(CultureInfo.InvariantCulture); | ||
responses.Add(statusCode, GetResponse(description, responseType.StatusCode, responseType)); | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
return responses; | ||
} | ||
|
||
private static OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType) | ||
{ | ||
var description = ReasonPhrases.GetReasonPhrase(statusCode); | ||
HashSet<string> responseContentTypes = []; | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
var response = new OpenApiResponse | ||
{ | ||
Description = description, | ||
Content = new Dictionary<string, OpenApiMediaType>() | ||
}; | ||
|
||
// ApiResponseFormats aggregates information about the support response content types | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we change that? The less MVC we reference, the better. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
// 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"])); | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// 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_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))] () => { }); | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// 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.DefaultResponseStatusCode, response.Key); | ||
Assert.Empty(response.Value.Description); | ||
// Todo: Validate generated schema. | ||
}); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why would a sample reference the tests? We don't want customers to do that, do we?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, it's for the "shared" TODO types. I think I'd probably redeclare them to keep the sample self-contained.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just realized that I can use a
Compile
-include to do this. 🤦🏽♀️ 😅Edit: I realize this doesn't make the sample very self-contained but the samples in the repo don't lend themselves nicely to being pulled out, especially because they rely on the special reference resolution setup that we have in the repo.