diff --git a/src/OpenApi/sample/Controllers/XmlController.cs b/src/OpenApi/sample/Controllers/XmlController.cs
index d2849c6b304c..0181638195db 100644
--- a/src/OpenApi/sample/Controllers/XmlController.cs
+++ b/src/OpenApi/sample/Controllers/XmlController.cs
@@ -22,7 +22,7 @@ public string Get()
/// The name of the person.
/// Returns the greeting.
- [HttpGet]
+ [HttpGet("{name}")]
public string Get1(string name)
{
return $"Hello, {name}!";
diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt
index 629fbbb86f29..fb732aff0b2e 100644
--- a/src/OpenApi/src/PublicAPI.Unshipped.txt
+++ b/src/OpenApi/src/PublicAPI.Unshipped.txt
@@ -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.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.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.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.get -> Microsoft.OpenApi.Models.OpenApiDocument?
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.init -> void
diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs
index a358f56d08a9..f63448e1fb86 100644
--- a/src/OpenApi/src/Services/OpenApiDocumentService.cs
+++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs
@@ -262,9 +262,18 @@ private async Task> GetOperationsAsy
CancellationToken cancellationToken)
{
var operations = new Dictionary();
- 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 allDescriptions = [.. opTypeDescriptions];
+ var description = allDescriptions.First();
+
+ var operation = await GetOperationAsync(allDescriptions, document, scopedServiceProvider, schemaTransformers, cancellationToken);
operation.Annotations ??= new Dictionary();
operation.Annotations.Add(OpenApiConstants.DescriptionId, description.ActionDescriptor.Id);
@@ -272,13 +281,14 @@ private async Task> GetOperationsAsy
{
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++)
@@ -289,8 +299,9 @@ private async Task> GetOperationsAsy
// Apply any endpoint-specific operation transformers registered via
// the AddOpenApiOperationTransformer extension method.
- var endpointOperationTransformers = description.ActionDescriptor.EndpointMetadata
- .OfType();
+ var endpointOperationTransformers = allDescriptions
+ .SelectMany(d => d.ActionDescriptor.EndpointMetadata
+ .OfType());
foreach (var endpointOperationTransformer in endpointOperationTransformers)
{
await endpointOperationTransformer.TransformAsync(operation, operationContext, cancellationToken);
@@ -300,12 +311,13 @@ private async Task> GetOperationsAsy
}
private async Task GetOperationAsync(
- ApiDescription description,
+ IReadOnlyList descriptions,
OpenApiDocument document,
IServiceProvider scopedServiceProvider,
IOpenApiSchemaTransformer[] schemaTransformers,
CancellationToken cancellationToken)
{
+ var description = descriptions.First();
var tags = GetTags(description, document);
var operation = new OpenApiOperation
{
@@ -314,9 +326,30 @@ private async Task 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;
}
diff --git a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs
index 8e070cc2c92f..71742c05424d 100644
--- a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs
+++ b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs
@@ -18,10 +18,15 @@ public sealed class OpenApiOperationTransformerContext
public required string DocumentName { get; init; }
///
- /// Gets the API description associated with target operation.
+ /// Gets the primary API description associated with target operation.
///
public required ApiDescription Description { get; init; }
+ ///
+ /// Gets all API descriptions that were merged to create the target operation.
+ ///
+ public required IReadOnlyList AllDescriptions { get; init; }
+
///
/// Gets the application services associated with the current document the target operation is in.
///
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt
index 9eed2206b116..39cf0179861b 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt
@@ -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": {
@@ -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": {
@@ -394,4 +426,4 @@
"name": "Xml"
}
]
-}
\ No newline at end of file
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt
index 4a8829575928..de0b6ecf9f51 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt
@@ -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": {
@@ -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": {
@@ -394,4 +426,4 @@
"name": "Xml"
}
]
-}
\ No newline at end of file
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs
index 032720da5cc0..5066a207cbbf 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs
@@ -259,6 +259,40 @@ await VerifyOpenApiDocument(builder, document =>
});
}
+ ///
+ /// 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
+ ///
+ [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()
{
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs
index f172ac364f55..ce093173e67d 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/OperationTransformerTests.cs
@@ -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;
@@ -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)