Skip to content

OpenAPI: merge multiple body content types into one operation #61401

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion src/OpenApi/sample/Controllers/XmlController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public string Get()

/// <param name="name">The name of the person.</param>
/// <response code="200">Returns the greeting.</response>
[HttpGet]
[HttpGet("{name}")]
public string Get1(string name)
{
return $"Hello, {name}!";
Expand Down
2 changes: 2 additions & 0 deletions src/OpenApi/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ static Microsoft.AspNetCore.Builder.OpenApiEndpointConventionBuilderExtensions.A
Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.GetOrCreateSchemaAsync(System.Type! type, Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? parameterDescription = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.OpenApi.Models.OpenApiSchema!>!
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Document.get -> Microsoft.OpenApi.Models.OpenApiDocument?
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Document.init -> void
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.AllDescriptions.get -> System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription!>!
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.AllDescriptions.init -> void
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.GetOrCreateSchemaAsync(System.Type! type, Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? parameterDescription = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.OpenApi.Models.OpenApiSchema!>!
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.get -> Microsoft.OpenApi.Models.OpenApiDocument?
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.init -> void
Expand Down
47 changes: 40 additions & 7 deletions src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,23 +262,33 @@ private async Task<Dictionary<OperationType, OpenApiOperation>> GetOperationsAsy
CancellationToken cancellationToken)
{
var operations = new Dictionary<OperationType, OpenApiOperation>();
foreach (var description in descriptions)
foreach (var opTypeDescriptions in descriptions.GroupBy(d => d.GetOperationType()))
{
var operation = await GetOperationAsync(description, document, scopedServiceProvider, schemaTransformers, cancellationToken);
var operationType = opTypeDescriptions.Key;

// `description` is the first description for a given Route + HttpMethod.
// There may be additional descriptions if the endpoint has additional definitions
// with different [Consumes] definitions. We merge in the bodies of these additional endpoints,
// but currently don't merge any other parts of the definition.
IReadOnlyList<ApiDescription> allDescriptions = [.. opTypeDescriptions];
var description = allDescriptions.First();

var operation = await GetOperationAsync(allDescriptions, document, scopedServiceProvider, schemaTransformers, cancellationToken);
operation.Annotations ??= new Dictionary<string, object>();
operation.Annotations.Add(OpenApiConstants.DescriptionId, description.ActionDescriptor.Id);

var operationContext = new OpenApiOperationTransformerContext
{
DocumentName = documentName,
Description = description,
AllDescriptions = [.. allDescriptions],
ApplicationServices = scopedServiceProvider,
Document = document,
SchemaTransformers = schemaTransformers
};

_operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, operationContext);
operations[description.GetOperationType()] = operation;
operations[operationType] = operation;

// Use index-based for loop to avoid allocating an enumerator with a foreach.
for (var i = 0; i < operationTransformers.Length; i++)
Expand All @@ -289,8 +299,9 @@ private async Task<Dictionary<OperationType, OpenApiOperation>> GetOperationsAsy

// Apply any endpoint-specific operation transformers registered via
// the AddOpenApiOperationTransformer extension method.
var endpointOperationTransformers = description.ActionDescriptor.EndpointMetadata
.OfType<DelegateOpenApiOperationTransformer>();
var endpointOperationTransformers = allDescriptions
.SelectMany(d => d.ActionDescriptor.EndpointMetadata
.OfType<DelegateOpenApiOperationTransformer>());
foreach (var endpointOperationTransformer in endpointOperationTransformers)
{
await endpointOperationTransformer.TransformAsync(operation, operationContext, cancellationToken);
Expand All @@ -300,12 +311,13 @@ private async Task<Dictionary<OperationType, OpenApiOperation>> GetOperationsAsy
}

private async Task<OpenApiOperation> GetOperationAsync(
ApiDescription description,
IReadOnlyList<ApiDescription> descriptions,
OpenApiDocument document,
IServiceProvider scopedServiceProvider,
IOpenApiSchemaTransformer[] schemaTransformers,
CancellationToken cancellationToken)
{
var description = descriptions.First();
var tags = GetTags(description, document);
var operation = new OpenApiOperation
{
Expand All @@ -314,9 +326,30 @@ private async Task<OpenApiOperation> GetOperationAsync(
Description = GetDescription(description),
Responses = await GetResponsesAsync(document, description, scopedServiceProvider, schemaTransformers, cancellationToken),
Parameters = await GetParametersAsync(document, description, scopedServiceProvider, schemaTransformers, cancellationToken),
RequestBody = await GetRequestBodyAsync(document, description, scopedServiceProvider, schemaTransformers, cancellationToken),
Tags = tags,
};

foreach (var bodyDescription in descriptions)
{
var requestBody = await GetRequestBodyAsync(document, bodyDescription, scopedServiceProvider, schemaTransformers, cancellationToken);
if (operation.RequestBody is null)
{
operation.RequestBody = requestBody;
}
else if (requestBody is not null)
{
// Merge additional accepted content types that are defined by additional endpoint descriptions.
var existingContent = operation.RequestBody.Content;
foreach (var additionalContent in requestBody.Content)
{
if (!existingContent.ContainsKey(additionalContent.Key))
{
existingContent.Add(additionalContent);
}
}
}
}

return operation;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@ public sealed class OpenApiOperationTransformerContext
public required string DocumentName { get; init; }

/// <summary>
/// Gets the API description associated with target operation.
/// Gets the primary API description associated with target operation.
/// </summary>
public required ApiDescription Description { get; init; }

/// <summary>
/// Gets all API descriptions that were merged to create the target operation.
/// </summary>
public required IReadOnlyList<ApiDescription> AllDescriptions { get; init; }

/// <summary>
/// Gets the application services associated with the current document the target operation is in.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,11 @@
"tags": [
"Xml"
],
"parameters": [
{
"name": "name",
"in": "query",
"description": "The name of the person.",
"schema": {
"type": "string"
}
}
],
"summary": "A summary of the action.",
"description": "A description of the action.",
"responses": {
"200": {
"description": "Returns the greeting.",
"description": "OK",
"content": {
"text/plain": {
"schema": {
Expand Down Expand Up @@ -230,6 +222,46 @@
}
}
}
},
"/Xml/{name}": {
"get": {
"tags": [
"Xml"
],
"parameters": [
{
"name": "name",
"in": "path",
"description": "The name of the person.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Returns the greeting.",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
},
"application/json": {
"schema": {
"type": "string"
}
},
"text/json": {
"schema": {
"type": "string"
}
}
}
}
}
}
}
},
"components": {
Expand Down Expand Up @@ -394,4 +426,4 @@
"name": "Xml"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,11 @@
"tags": [
"Xml"
],
"parameters": [
{
"name": "name",
"in": "query",
"description": "The name of the person.",
"schema": {
"type": "string"
}
}
],
"summary": "A summary of the action.",
"description": "A description of the action.",
"responses": {
"200": {
"description": "Returns the greeting.",
"description": "OK",
"content": {
"text/plain": {
"schema": {
Expand Down Expand Up @@ -230,6 +222,46 @@
}
}
}
},
"/Xml/{name}": {
"get": {
"tags": [
"Xml"
],
"parameters": [
{
"name": "name",
"in": "path",
"description": "The name of the person.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Returns the greeting.",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
},
"application/json": {
"schema": {
"type": "string"
}
},
"text/json": {
"schema": {
"type": "string"
}
}
}
}
}
}
}
},
"components": {
Expand Down Expand Up @@ -394,4 +426,4 @@
"name": "Xml"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,40 @@ await VerifyOpenApiDocument(builder, document =>
});
}

/// <summary>
/// Tests documented behavior at https://learn.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-9.0#define-supported-request-content-types-with-the-consumes-attribute-1
/// </summary>
[Fact]
public async Task GetRequestBody_HandlesMultipleAcceptedContentType()
{
// Arrange
var builder = CreateBuilder();

// Act
builder.MapPost("/", [Consumes("application/json")] (TodoWithDueDate name) => { });
builder.MapPost("/", [Consumes("application/x-www-form-urlencoded")] ([FromForm] TodoWithDueDate name) => { });

// Assert
await VerifyOpenApiDocument(builder, document =>
{
var paths = Assert.Single(document.Paths.Values);
var operation = paths.Operations[OperationType.Post];
Assert.NotNull(operation.RequestBody);

Assert.Collection(operation.RequestBody.Content,
pair =>
{
Assert.Equal("application/json", pair.Key);
Assert.Equal("TodoWithDueDate", pair.Value.Schema.Annotations["x-schema-id"]);
},
pair =>
{
Assert.Equal("application/x-www-form-urlencoded", pair.Key);
Assert.Equal("TodoWithDueDate", pair.Value.Schema.Annotations["x-schema-id"]);
});
});
}

[Fact]
public async Task GetRequestBody_HandlesJsonBody()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
Expand Down Expand Up @@ -657,6 +658,55 @@ public async Task OperationTransformer_ExecutesAsynchronously()
Assert.Equal([1, 2, 3], transformerOrder);
}

[Fact]
public async Task OperationTransformer_ExecutesOncePerSetOfMergedEndpoints()
{
// Arrange
var builder = CreateBuilder();

// Act
builder.MapPost("/overload", [Consumes("application/json")] (TodoWithDueDate name) => { });
builder.MapPost("/overload", [Consumes("application/x-www-form-urlencoded")] ([FromForm] TodoWithDueDate name) => { });

var options = new OpenApiOptions();
int executionCount = 0;
options.AddOperationTransformer((operation, context, cancellationToken) =>
{
executionCount++;

Assert.Collection(context.AllDescriptions,
description =>
{
Assert.Equal("application/json", description.SupportedRequestFormats.Single().MediaType);
// The primary description (the first declared endpoint) should be first in AllDescriptions.
Assert.Equal(context.Description, description);
},
description =>
{
Assert.Equal("application/x-www-form-urlencoded", description.SupportedRequestFormats.Single().MediaType);
});

operation.Description = "overloaded x" + context.AllDescriptions.Count;
return Task.CompletedTask;
});

// Assert
await VerifyOpenApiDocument(builder, options, document =>
{
Assert.Equal(1, executionCount);

var paths = Assert.Single(document.Paths.Values);
var operation = paths.Operations[OperationType.Post];
Assert.NotNull(operation.RequestBody);

Assert.Equal("overloaded x2", operation.Description);
Assert.Collection(operation.RequestBody.Content,
pair => Assert.Equal("application/json", pair.Key),
pair => Assert.Equal("application/x-www-form-urlencoded", pair.Key)
);
});
}

private class ActivatedTransformer : IOpenApiOperationTransformer
{
public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
Expand Down
Loading