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 b94c71e90cb6..65b1d8cdad7a 100644
--- a/src/OpenApi/src/PublicAPI.Unshipped.txt
+++ b/src/OpenApi/src/PublicAPI.Unshipped.txt
@@ -19,6 +19,8 @@ Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.GetOrCreateSchema
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Document.get -> Microsoft.OpenApi.OpenApiDocument?
Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Document.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.OpenApiOperationTransformerContext.AllDescriptions.get -> System.Collections.Generic.IReadOnlyList!
+Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.AllDescriptions.init -> void
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.get -> Microsoft.OpenApi.OpenApiDocument?
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.Document.init -> void
Microsoft.AspNetCore.OpenApi.OpenApiSchemaTransformerContext.GetOrCreateSchemaAsync(System.Type! type, Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription? parameterDescription = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs
index 5308dc3792a1..9668e243502e 100644
--- a/src/OpenApi/src/Services/OpenApiDocumentService.cs
+++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs
@@ -269,9 +269,16 @@ private async Task> GetOperationsAsync(
CancellationToken cancellationToken)
{
var operations = new Dictionary();
- foreach (var description in descriptions)
+ foreach (var httpMethodDescriptions in descriptions.GroupBy(d => d.GetHttpMethod()))
{
- var operation = await GetOperationAsync(description, document, scopedServiceProvider, schemaTransformers, cancellationToken);
+ // `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 = [.. httpMethodDescriptions];
+ var description = allDescriptions.First();
+
+ var operation = await GetOperationAsync(allDescriptions, document, scopedServiceProvider, schemaTransformers, cancellationToken);
operation.Metadata ??= new Dictionary();
operation.Metadata.Add(OpenApiConstants.DescriptionId, description.ActionDescriptor.Id);
@@ -279,6 +286,7 @@ private async Task> GetOperationsAsync(
{
DocumentName = documentName,
Description = description,
+ AllDescriptions = allDescriptions,
ApplicationServices = scopedServiceProvider,
Document = document,
SchemaTransformers = schemaTransformers
@@ -286,7 +294,7 @@ private async Task> GetOperationsAsync(
_operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, operationContext);
- if (description.GetHttpMethod() is not { } method)
+ if (httpMethodDescriptions.Key is not { } method)
{
// Skip unsupported HTTP methods
continue;
@@ -303,8 +311,9 @@ private async Task> GetOperationsAsync(
// 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);
@@ -314,12 +323,13 @@ private async Task> GetOperationsAsync(
}
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
{
@@ -328,9 +338,31 @@ 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.
+ // `RequestBody.Content` produced by `GetRequestBodyAsync` is never null.
+ 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 82a5be551a5d..5bbbd9ce503e 100644
--- a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs
+++ b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs
@@ -17,10 +17,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/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt
index 4094879f54a8..3b716170efb3 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt
@@ -448,19 +448,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": {
@@ -530,6 +522,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"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/schemas-by-ref/typed-results": {
"get": {
"tags": [
@@ -2037,4 +2069,4 @@
"name": "Test"
}
]
-}
\ 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 8a38a89952f4..11b9568b191b 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
@@ -261,6 +261,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[HttpMethod.Post];
+ Assert.NotNull(operation.RequestBody);
+
+ Assert.Collection(operation.RequestBody.Content,
+ pair =>
+ {
+ Assert.Equal("application/json", pair.Key);
+ Assert.Equal(5, pair.Value.Schema.Properties.Count);
+ },
+ pair =>
+ {
+ Assert.Equal("application/x-www-form-urlencoded", pair.Key);
+ Assert.Equal(5, pair.Value.Schema.Properties.Count);
+ });
+ });
+ }
+
[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 a67d4882dffc..bcfb6c1cbd9f 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;
@@ -656,6 +657,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 path = Assert.Single(document.Paths.Values);
+ var operation = Assert.Single(path.Operations.Values);
+ 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)