From 802cf219ef4cf2fdf6926c2a65c70f32249a1aec Mon Sep 17 00:00:00 2001 From: BloodShop Date: Wed, 18 Mar 2026 13:03:17 +0200 Subject: [PATCH 1/3] fix: OpenAPI request body description uses correct parameter comment (issue #65805) When an endpoint has multiple parameters, the request body description should use the [FromBody] parameter's XML comment, not the last unmatched parameter's comment. The bug was in the loop that assigns parameter comments. It would iterate through all parameters and assign any unmatched parameter's comment to the request body, causing the last iteration (usually an injected dependency like CancellationToken) to overwrite the correct [FromBody] parameter's comment. Fix: Only use a parameter's comment for the request body if it has a [FromBody] attribute or is a complex type (indicating it's meant for request body binding). --- .../gen/XmlCommentGenerator.Emitter.cs | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs index 82864654e936..6c78d8d6645a 100644 --- a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs +++ b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs @@ -400,22 +400,33 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } targetOperationParameter.Deprecated = parameterComment.Deprecated; } - else + else if (parameterInfo is not null) { - var requestBody = operation.RequestBody; - if (requestBody is not null) + // Only use this parameter's comment for the request body if it's actually a [FromBody] parameter. + // Check for [FromBody] attribute or if it's a complex type without any binding attribute. + var hasFromBodyAttribute = parameterInfo.GetCustomAttributes() + .Any(attr => attr.GetType().Name == "FromBodyAttribute"); + var isComplexType = !parameterInfo.ParameterType.IsValueType && + parameterInfo.ParameterType != typeof(string) && + parameterInfo.ParameterType.Namespace != "System"; + + if (hasFromBodyAttribute || (isComplexType && operation.RequestBody is not null)) { - requestBody.Description = parameterComment.Description; - if (parameterComment.Example is { } jsonString) + var requestBody = operation.RequestBody; + if (requestBody is not null) { - var content = requestBody?.Content?.Values; - if (content is null) - { - continue; - } - foreach (var mediaType in content.OfType()) + requestBody.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) { - mediaType.Example = jsonString.Parse(); + var content = requestBody?.Content?.Values; + if (content is null) + { + continue; + } + foreach (var mediaType in content.OfType()) + { + mediaType.Example = jsonString.Parse(); + } } } } From dc78b19cb53ed14115b56ba3f823ea28820d83f3 Mon Sep 17 00:00:00 2001 From: BloodShop Date: Wed, 18 Mar 2026 13:16:53 +0200 Subject: [PATCH 2/3] test: add regression test for OpenAPI request body comment bug Verifies that when an endpoint has [FromBody] followed by other parameters ([FromServices], CancellationToken), the request body description uses the [FromBody] parameter's comment, not the last parameter's comment. --- .../OperationTests.MinimalApis.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs index dad4380e2f9a..6724535ddc7c 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs @@ -530,4 +530,62 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document => Assert.Equal("Property with only value documentation.", valueOnlyParam2.Description); }); } + + [Fact] + public async Task RequestBodyDescriptionUsesFromBodyParameterCommentNotLastParameter() + { + // Regression test for issue #65805 + // When an endpoint has [FromBody] parameter followed by other parameters like [FromServices] or CancellationToken, + // the request body description should use the [FromBody] parameter's XML comment, not the last parameter's. + var source = """ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Http.HttpResults; + +var builder = WebApplication.CreateBuilder(); +builder.Services.AddOpenApi(); +var app = builder.Build(); + +app.MapPost("/test", TestEndpoint.PostData); +app.Run(); + +public static class TestEndpoint +{ + /// + /// Process some sample input. + /// + /// Sample data provided by the user. + /// Logger for diagnostics and tracing. + /// Injected cancellation token. + /// The number the user supplied. + public static async Task> PostData( + [FromBody] SampleData data, + [FromServices] ILogger logger, + CancellationToken cancellation) + { + ArgumentNullException.ThrowIfNull(data); + logger.LogInformation("User supplied {Number} and {Text}", data.Number, data.Text); + await Task.Delay(1000, cancellation).ConfigureAwait(false); + return TypedResults.Ok(data.Number); + } +} + +public record SampleData(int Number, string Text); +"""; + await SnapshotTestHelper.VerifyOpenApiDocument(source, document => + { + var postOperation = document.Paths["/test"].Operations[HttpMethod.Post]; + var requestBody = postOperation.RequestBody; + + // The request body description should come from the [FromBody] SampleData parameter + Assert.NotNull(requestBody); + Assert.Equal("Sample data provided by the user.", requestBody.Description); + // Ensure it's NOT using the last parameter's comment + Assert.NotEqual("Injected cancellation token.", requestBody.Description); + }); + } } From cbe1634f92dc79a35307d5a85a4f2b2e5ceee33b Mon Sep 17 00:00:00 2001 From: BloodShop Date: Wed, 18 Mar 2026 15:31:04 +0200 Subject: [PATCH 3/3] fix: use ApiExplorer binding source to identify request body parameter Switched from attribute-based heuristics to using the actual ParameterDescription binding source to identify which parameter comment belongs to the request body. Also prevent overwriting requestBody.Description if already set, ensuring the correct parameter's comment is preserved. --- .../gen/XmlCommentGenerator.Emitter.cs | 43 ++++++++----------- .../OperationTests.MinimalApis.cs | 10 +++-- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs index 6c78d8d6645a..fe0a8b0188b1 100644 --- a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs +++ b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs @@ -386,9 +386,15 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } if (methodComment.Parameters is { Count: > 0}) { + var requestBodyParameterName = context.Description.ParameterDescriptions + .FirstOrDefault(parameterDescription => + parameterDescription.Source == Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource.Body || + parameterDescription.Source == Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource.FormFile || + parameterDescription.Source == Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource.Form) + ?.ParameterDescriptor?.Name; + foreach (var parameterComment in methodComment.Parameters) { - var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name); var operationParameter = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name); if (operationParameter is not null) { @@ -400,33 +406,22 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } targetOperationParameter.Deprecated = parameterComment.Deprecated; } - else if (parameterInfo is not null) + else if (requestBodyParameterName is not null && string.Equals(parameterComment.Name, requestBodyParameterName, StringComparison.Ordinal)) { - // Only use this parameter's comment for the request body if it's actually a [FromBody] parameter. - // Check for [FromBody] attribute or if it's a complex type without any binding attribute. - var hasFromBodyAttribute = parameterInfo.GetCustomAttributes() - .Any(attr => attr.GetType().Name == "FromBodyAttribute"); - var isComplexType = !parameterInfo.ParameterType.IsValueType && - parameterInfo.ParameterType != typeof(string) && - parameterInfo.ParameterType.Namespace != "System"; - - if (hasFromBodyAttribute || (isComplexType && operation.RequestBody is not null)) + var requestBody = operation.RequestBody; + if (requestBody is not null) { - var requestBody = operation.RequestBody; - if (requestBody is not null) + requestBody.Description ??= parameterComment.Description; + if (parameterComment.Example is { } jsonString) { - requestBody.Description = parameterComment.Description; - if (parameterComment.Example is { } jsonString) + var content = requestBody?.Content?.Values; + if (content is null) + { + continue; + } + foreach (var mediaType in content.OfType()) { - var content = requestBody?.Content?.Values; - if (content is null) - { - continue; - } - foreach (var mediaType in content.OfType()) - { - mediaType.Example = jsonString.Parse(); - } + mediaType.Example = jsonString.Parse(); } } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs index 6724535ddc7c..13d3d4f5ea9a 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs @@ -544,6 +544,8 @@ public async Task RequestBodyDescriptionUsesFromBodyParameterCommentNotLastParam using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; var builder = WebApplication.CreateBuilder(); @@ -569,22 +571,22 @@ public static async Task> PostData( { ArgumentNullException.ThrowIfNull(data); logger.LogInformation("User supplied {Number} and {Text}", data.Number, data.Text); - await Task.Delay(1000, cancellation).ConfigureAwait(false); + await Task.Delay(1, cancellation).ConfigureAwait(false); return TypedResults.Ok(data.Number); } } public record SampleData(int Number, string Text); """; - await SnapshotTestHelper.VerifyOpenApiDocument(source, document => + var generator = new XmlCommentGenerator(); + await SnapshotTestHelper.Verify(source, generator, out var compilation); + await SnapshotTestHelper.VerifyOpenApi(compilation, document => { var postOperation = document.Paths["/test"].Operations[HttpMethod.Post]; var requestBody = postOperation.RequestBody; - // The request body description should come from the [FromBody] SampleData parameter Assert.NotNull(requestBody); Assert.Equal("Sample data provided by the user.", requestBody.Description); - // Ensure it's NOT using the last parameter's comment Assert.NotEqual("Injected cancellation token.", requestBody.Description); }); }