diff --git a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
index 82864654e936..5538f0163d2d 100644
--- a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
+++ b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
@@ -57,6 +57,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -153,30 +154,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -389,7 +366,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -400,7 +377,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -449,10 +429,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -499,6 +483,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs
index ef26c9590ec3..72610d6beb79 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs
@@ -141,4 +141,173 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
Assert.Equal("The id of the user.", path.Parameters[0].Description);
});
}
+
+ [Fact]
+ public async Task SupportsParametersWithCustomNamesFromControllers()
+ {
+ var source =
+"""
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services
+ .AddControllers()
+ .AddApplicationPart(typeof(TestController).Assembly);
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+app.MapControllers();
+
+app.Run();
+
+[ApiController]
+[Route("[controller]")]
+public class TestController : ControllerBase
+{
+ /// The id of the user.
+ [HttpGet("{user_id}")]
+ public string Get([FromRoute(Name = "user_id")] int userId)
+ {
+ return "Hello, World!";
+ }
+
+ [HttpGet]
+ public IEnumerable Search(Query query)
+ {
+ return [];
+ }
+}
+
+public partial class Program {}
+
+public record Person(int Id, string Name);
+
+public class Query
+{
+ ///
+ /// The full name of the person.
+ ///
+ [FromQuery(Name = "full_name")]
+ public string? Name { get; init; }
+}
+""";
+ var generator = new XmlCommentGenerator();
+ await SnapshotTestHelper.Verify(source, generator, out var compilation);
+ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
+ {
+ var getOperation = document.Paths["/Test/{user_id}"].Operations[HttpMethod.Get];
+ Assert.Equal("user_id", getOperation.Parameters[0].Name);
+ Assert.Equal("The id of the user.", getOperation.Parameters[0].Description);
+
+ var searchOperation = document.Paths["/Test"].Operations[HttpMethod.Get];
+ Assert.Equal("full_name", searchOperation.Parameters[0].Name);
+ Assert.Equal("The full name of the person.", searchOperation.Parameters[0].Description);
+ });
+ }
+
+ [Fact]
+ public async Task ShouldNotApplyCancellationTokenDocumentationToRequestBody()
+ {
+ var source =
+"""
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services
+ .AddControllers()
+ .AddApplicationPart(typeof(TestController).Assembly);
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+app.MapControllers();
+
+app.Run();
+
+[ApiController]
+[Route("[controller]")]
+public class TestController : ControllerBase
+{
+ /// The cancellation token.
+ [HttpGet]
+ public ActionResult Create(Person person, CancellationToken cancellationToken)
+ {
+ return Created();
+ }
+}
+
+public partial class Program {}
+
+public record Person(int Id, string Name);
+""";
+ var generator = new XmlCommentGenerator();
+ await SnapshotTestHelper.Verify(source, generator, out var compilation);
+ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
+ {
+ var getOperation = document.Paths["/Test"].Operations[HttpMethod.Get];
+ Assert.Null(getOperation.RequestBody.Description);
+ });
+ }
+
+ [Fact]
+ public async Task ShouldNotApplyFromServicesDocumentationToRequestBody()
+ {
+ var source =
+"""
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services
+ .AddControllers()
+ .AddApplicationPart(typeof(TestController).Assembly);
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+app.MapControllers();
+
+app.Run();
+
+[ApiController]
+[Route("[controller]")]
+public class TestController : ControllerBase
+{
+ /// The service used to create the resource.
+ [HttpGet]
+ public ActionResult Create(Person person, [FromServices] ITestService service)
+ {
+ return Created();
+ }
+}
+
+public interface ITestService {}
+
+public partial class Program {}
+
+public record Person(int Id, string Name);
+""";
+ var generator = new XmlCommentGenerator();
+ await SnapshotTestHelper.Verify(source, generator, out var compilation);
+ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
+ {
+ var getOperation = document.Paths["/Test"].Operations[HttpMethod.Get];
+ // The `service` parameter is bound from DI, not the request body, so its
+ // documentation must not be applied to the request body description.
+ Assert.Null(getOperation.RequestBody.Description);
+ });
+ }
}
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..c3cb8044c397 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
@@ -239,7 +239,7 @@ public static IResult Post19(string regularParam, [AsParameters] MixedParameters
/// Parameters from different sources.
public static IResult Get20([AsParameters] BindingSourceParametersClass bindingParams)
{
- return TypedResults.Ok($"Query: {bindingParams.QueryParam}, Header: {bindingParams.HeaderParam}");
+ return TypedResults.Ok($"Query: {bindingParams.QueryParam}, Query with custom name: {bindingParams.QueryParamWithCustomName}, Header: {bindingParams.HeaderParam}");
}
///
@@ -348,6 +348,12 @@ public class BindingSourceParametersClass
[FromQuery]
public string? QueryParam { get; set; }
+ ///
+ /// Query parameter from URL with custom name.
+ ///
+ [FromQuery(Name = "custom_name")]
+ public string? QueryParamWithCustomName { get; set; }
+
///
/// Header value from request.
///
@@ -496,7 +502,10 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
var path20 = document.Paths["/20"].Operations[HttpMethod.Get];
Assert.Equal("Tests AsParameters with different binding sources.", path20.Summary);
Assert.Equal("Query parameter from URL.", path20.Parameters[0].Description);
- Assert.Equal("Header value from request.", path20.Parameters[1].Description);
+ var customNameParam = path20.Parameters[1];
+ Assert.Equal("custom_name", customNameParam.Name);
+ Assert.Equal("Query parameter from URL with custom name.", customNameParam.Description);
+ Assert.Equal("Header value from request.", path20.Parameters[2].Description);
// Test XML documentation priority order: value > returns > summary
var path22 = document.Paths["/21"].Operations[HttpMethod.Get];
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs
index 6c44a0094278..92baee2f72b2 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -135,30 +136,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -371,7 +348,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -382,7 +359,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -431,10 +411,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -481,6 +465,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs
index 0e41890976c0..321a982b076b 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -164,30 +165,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -400,7 +377,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -411,7 +388,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -460,10 +440,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -510,6 +494,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs
index 4a0aaf7895bf..96e654bce6b0 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -262,30 +263,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -498,7 +475,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -509,7 +486,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -558,10 +538,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -608,6 +592,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyCancellationTokenDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyCancellationTokenDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs
new file mode 100644
index 000000000000..1759f8915bc6
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyCancellationTokenDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs
@@ -0,0 +1,674 @@
+//HintName: OpenApiXmlCommentSupport.generated.cs
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+#nullable enable
+// Suppress warnings about obsolete types and members
+// in generated code
+#pragma warning disable CS0612, CS0618
+
+namespace System.Runtime.CompilerServices
+{
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ file sealed class InterceptsLocationAttribute : System.Attribute
+ {
+ public InterceptsLocationAttribute(int version, string data)
+ {
+ }
+ }
+}
+
+namespace Microsoft.AspNetCore.OpenApi.Generated
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Diagnostics.CodeAnalysis;
+ using System.Globalization;
+ using System.Linq;
+ using System.Reflection;
+ using System.Text;
+ using System.Text.Json;
+ using System.Text.Json.Nodes;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.OpenApi;
+ using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
+ using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.OpenApi;
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlComment(
+ string? Summary,
+ string? Description,
+ string? Remarks,
+ string? Returns,
+ string? Value,
+ bool Deprecated,
+ List? Examples,
+ List? Parameters,
+ List? Responses);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlResponseComment(string Code, string? Description, string? Example);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class XmlCommentCache
+ {
+ private static Dictionary? _cache;
+ public static Dictionary Cache => _cache ??= GenerateCacheEntries();
+
+ private static Dictionary GenerateCacheEntries()
+ {
+ var cache = new Dictionary();
+
+ cache.Add(@"M:TestController.Create(Person,System.Threading.CancellationToken)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"cancellationToken", @"The cancellation token.", null, false)], null));
+
+ return cache;
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class DocumentationCommentIdHelper
+ {
+ ///
+ /// Generates a documentation comment ID for a type.
+ /// Example: T:Namespace.Outer+Inner`1 becomes T:Namespace.Outer.Inner`1
+ ///
+ public static string CreateDocumentationId(this Type type)
+ {
+ if (type == null)
+ {
+ throw new ArgumentNullException(nameof(type));
+ }
+
+ return "T:" + GetTypeDocId(type, includeGenericArguments: false, omitGenericArity: false);
+ }
+
+ ///
+ /// Generates a documentation comment ID for a property.
+ /// Example: P:Namespace.ContainingType.PropertyName or for an indexer P:Namespace.ContainingType.Item(System.Int32)
+ ///
+ public static string CreateDocumentationId(this PropertyInfo property)
+ {
+ if (property == null)
+ {
+ throw new ArgumentNullException(nameof(property));
+ }
+
+ var sb = new StringBuilder();
+ sb.Append("P:");
+
+ if (property.DeclaringType != null)
+ {
+ sb.Append(GetTypeDocId(property.DeclaringType, includeGenericArguments: false, omitGenericArity: false));
+ }
+
+ sb.Append('.');
+ sb.Append(property.Name);
+
+ // For indexers, include the parameter list.
+ var indexParams = property.GetIndexParameters();
+ if (indexParams.Length > 0)
+ {
+ sb.Append('(');
+ for (int i = 0; i < indexParams.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ sb.Append(GetTypeDocId(indexParams[i].ParameterType, includeGenericArguments: true, omitGenericArity: false));
+ }
+ sb.Append(')');
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Generates a documentation comment ID for a method (or constructor).
+ /// For example:
+ /// M:Namespace.ContainingType.MethodName(ParamType1,ParamType2)~ReturnType
+ /// M:Namespace.ContainingType.#ctor(ParamType)
+ ///
+ public static string CreateDocumentationId(this MethodInfo method)
+ {
+ if (method == null)
+ {
+ throw new ArgumentNullException(nameof(method));
+ }
+
+ var sb = new StringBuilder();
+ sb.Append("M:");
+
+ // Append the fully qualified name of the declaring type.
+ if (method.DeclaringType != null)
+ {
+ sb.Append(GetTypeDocId(method.DeclaringType, includeGenericArguments: false, omitGenericArity: false));
+ }
+
+ sb.Append('.');
+
+ // Append the method name, handling constructors specially.
+ if (method.IsConstructor)
+ {
+ sb.Append(method.IsStatic ? "#cctor" : "#ctor");
+ }
+ else
+ {
+ sb.Append(method.Name);
+ if (method.IsGenericMethod)
+ {
+ sb.Append("``");
+ sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", method.GetGenericArguments().Length);
+ }
+ }
+
+ // Append the parameter list, if any.
+ var parameters = method.GetParameters();
+ if (parameters.Length > 0)
+ {
+ sb.Append('(');
+ for (int i = 0; i < parameters.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ // Omit the generic arity for the parameter type.
+ sb.Append(GetTypeDocId(parameters[i].ParameterType, includeGenericArguments: true, omitGenericArity: true));
+ }
+ sb.Append(')');
+ }
+
+ // Append the return type after a '~' (if the method returns a value).
+ if (method.ReturnType != typeof(void))
+ {
+ sb.Append('~');
+ // Omit the generic arity for the return type.
+ sb.Append(GetTypeDocId(method.ReturnType, includeGenericArguments: true, omitGenericArity: true));
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Generates a documentation ID string for a type.
+ /// This method handles nested types (replacing '+' with '.'),
+ /// generic types, arrays, pointers, by-ref types, and generic parameters.
+ /// The flag controls whether
+ /// constructed generic type arguments are emitted, while
+ /// controls whether the generic arity marker (e.g. "`1") is appended.
+ ///
+ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool omitGenericArity)
+ {
+ if (type.IsGenericParameter)
+ {
+ // Use `` for method-level generic parameters and ` for type-level.
+ if (type.DeclaringMethod != null)
+ {
+ return "``" + type.GenericParameterPosition;
+ }
+ else if (type.DeclaringType != null)
+ {
+ return "`" + type.GenericParameterPosition;
+ }
+ else
+ {
+ return type.Name;
+ }
+ }
+
+ if (type.IsGenericType)
+ {
+ Type genericDef = type.GetGenericTypeDefinition();
+ string fullName = genericDef.FullName ?? genericDef.Name;
+
+ var sb = new StringBuilder(fullName.Length);
+
+ // Replace '+' with '.' for nested types
+ for (var i = 0; i < fullName.Length; i++)
+ {
+ char c = fullName[i];
+ if (c == '+')
+ {
+ sb.Append('.');
+ }
+ else if (c == '`')
+ {
+ break;
+ }
+ else
+ {
+ sb.Append(c);
+ }
+ }
+
+ if (!omitGenericArity)
+ {
+ int arity = genericDef.GetGenericArguments().Length;
+ sb.Append('`');
+ sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", arity);
+ }
+
+ if (includeGenericArguments && !type.IsGenericTypeDefinition)
+ {
+ var typeArgs = type.GetGenericArguments();
+ sb.Append('{');
+
+ for (int i = 0; i < typeArgs.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ sb.Append(GetTypeDocId(typeArgs[i], includeGenericArguments, omitGenericArity));
+ }
+
+ sb.Append('}');
+ }
+
+ return sb.ToString();
+ }
+
+ // For non-generic types, use FullName (if available) and replace nested type separators.
+ return (type.FullName ?? type.Name).Replace('+', '.');
+ }
+
+ ///
+ /// Normalizes a documentation comment ID to match the compiler-style format.
+ /// Strips the return type suffix for ordinary methods but retains it for conversion operators.
+ ///
+ /// The documentation comment ID to normalize.
+ /// The normalized documentation comment ID.
+ public static string NormalizeDocId(string docId)
+ {
+ // Find the tilde character that indicates the return type suffix
+ var tildeIndex = docId.IndexOf('~');
+ if (tildeIndex == -1)
+ {
+ // No return type suffix, return as-is
+ return docId;
+ }
+
+ // Check if this is a conversion operator (op_Implicit or op_Explicit)
+ // For these operators, we need to keep the return type suffix
+ if (docId.Contains("op_Implicit") || docId.Contains("op_Explicit"))
+ {
+ return docId;
+ }
+
+ // For ordinary methods, strip the return type suffix
+ return docId.Substring(0, tildeIndex);
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file class XmlCommentOperationTransformer : IOpenApiOperationTransformer
+ {
+ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
+ ? controllerActionDescriptor.MethodInfo
+ : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault();
+
+ if (methodInfo is null)
+ {
+ return Task.CompletedTask;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
+ {
+ if (methodComment.Summary is { } summary)
+ {
+ operation.Summary = summary;
+ }
+ if (methodComment.Description is { } description)
+ {
+ operation.Description = description;
+ }
+ if (methodComment.Remarks is { } remarks)
+ {
+ operation.Description = remarks;
+ }
+ if (methodComment.Parameters is { Count: > 0})
+ {
+ foreach (var parameterComment in methodComment.Parameters)
+ {
+ var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
+ if (operationParameter is not null)
+ {
+ var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
+ targetOperationParameter.Description = parameterComment.Description;
+ if (parameterComment.Example is { } jsonString)
+ {
+ targetOperationParameter.Example = jsonString.Parse();
+ }
+ targetOperationParameter.Deprecated = parameterComment.Deprecated;
+ }
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
+ {
+ var requestBody = operation.RequestBody;
+ if (requestBody is not null)
+ {
+ 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())
+ {
+ mediaType.Example = jsonString.Parse();
+ }
+ }
+ }
+ }
+ }
+ }
+ // Applies `` on XML comments for operation with single response value.
+ if (methodComment.Returns is { } returns && operation.Responses is { Count: 1 })
+ {
+ var response = operation.Responses.First();
+ response.Value.Description = returns;
+ }
+ // Applies `` on XML comments for operation with multiple response values.
+ if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 })
+ {
+ foreach (var response in operation.Responses)
+ {
+ var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key);
+ if (responseComment is not null)
+ {
+ response.Value.Description = responseComment.Description;
+ }
+ }
+ }
+ }
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ var metadata = parameterDescription.ModelMetadata;
+ if (metadata is not null
+ && metadata.MetadataKind == ModelMetadataKind.Property
+ && metadata.ContainerType is { } containerType
+ && metadata.PropertyName is { } propertyName)
+ {
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
+ {
+ var parameter = GetOperationParameter(operation, propertyInfo);
+ var description = propertyComment.Summary;
+ if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
+ {
+ description = $"{description}\n{propertyComment.Value}";
+ }
+ else if (string.IsNullOrEmpty(description))
+ {
+ description = propertyComment.Value;
+ }
+ if (parameter is null)
+ {
+ if (operation.RequestBody is not null)
+ {
+ operation.RequestBody.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ var content = operation.RequestBody.Content?.Values;
+ if (content is null)
+ {
+ continue;
+ }
+ var parsedExample = jsonString.Parse();
+ foreach (var mediaType in content.OfType())
+ {
+ mediaType.Example = parsedExample;
+ }
+ }
+ }
+ continue;
+ }
+ var targetOperationParameter = UnwrapOpenApiParameter(parameter);
+ if (targetOperationParameter is not null)
+ {
+ targetOperationParameter.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ targetOperationParameter.Example = jsonString.Parse();
+ }
+ }
+ }
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
+ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
+ {
+ if (sourceParameter is OpenApiParameterReference parameterReference)
+ {
+ if (parameterReference.Target is OpenApiParameter target)
+ {
+ return target;
+ }
+ else
+ {
+ throw new InvalidOperationException($"The input schema must be an {nameof(OpenApiParameter)} or {nameof(OpenApiParameterReference)}.");
+ }
+ }
+ else if (sourceParameter is OpenApiParameter directParameter)
+ {
+ return directParameter;
+ }
+ else
+ {
+ throw new InvalidOperationException($"The input schema must be an {nameof(OpenApiParameter)} or {nameof(OpenApiParameterReference)}.");
+ }
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer
+ {
+ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
+ {
+ // Apply comments from the type
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
+ {
+ schema.Description = typeComment.Summary;
+ if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Example = jsonString.Parse();
+ }
+ }
+
+ if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
+ {
+ // Apply comments from the property
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
+ {
+ var description = propertyComment.Summary;
+ if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
+ {
+ description = $"{description}\n{propertyComment.Value}";
+ }
+ else if (string.IsNullOrEmpty(description))
+ {
+ description = propertyComment.Value;
+ }
+ if (schema.Metadata is null
+ || !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
+ || string.IsNullOrEmpty(schemaId as string))
+ {
+ // Inlined schema
+ schema.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Example = jsonString.Parse();
+ }
+ }
+ else
+ {
+ // Schema Reference
+ if (!string.IsNullOrEmpty(description))
+ {
+ schema.Metadata["x-ref-description"] = description;
+ }
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Metadata["x-ref-example"] = jsonString.Parse()!;
+ }
+ }
+ }
+ }
+ return Task.CompletedTask;
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class JsonNodeExtensions
+ {
+ public static JsonNode? Parse(this string? json)
+ {
+ if (json is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonNode.Parse(json);
+ }
+ catch (JsonException)
+ {
+ try
+ {
+ // If parsing fails, try wrapping in quotes to make it a valid JSON string
+ return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\"");
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class GeneratedServiceCollectionExtensions
+ {
+ [InterceptsLocation]
+ public static IServiceCollection AddOpenApi(this IServiceCollection services)
+ {
+ return services.AddOpenApi("v1", options =>
+ {
+ options.AddSchemaTransformer(new XmlCommentSchemaTransformer());
+ options.AddOperationTransformer(new XmlCommentOperationTransformer());
+ });
+ }
+
+ }
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyFromServicesDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyFromServicesDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs
new file mode 100644
index 000000000000..298b174fa328
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyFromServicesDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs
@@ -0,0 +1,674 @@
+//HintName: OpenApiXmlCommentSupport.generated.cs
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+#nullable enable
+// Suppress warnings about obsolete types and members
+// in generated code
+#pragma warning disable CS0612, CS0618
+
+namespace System.Runtime.CompilerServices
+{
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ file sealed class InterceptsLocationAttribute : System.Attribute
+ {
+ public InterceptsLocationAttribute(int version, string data)
+ {
+ }
+ }
+}
+
+namespace Microsoft.AspNetCore.OpenApi.Generated
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Diagnostics.CodeAnalysis;
+ using System.Globalization;
+ using System.Linq;
+ using System.Reflection;
+ using System.Text;
+ using System.Text.Json;
+ using System.Text.Json.Nodes;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.OpenApi;
+ using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
+ using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.OpenApi;
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlComment(
+ string? Summary,
+ string? Description,
+ string? Remarks,
+ string? Returns,
+ string? Value,
+ bool Deprecated,
+ List? Examples,
+ List? Parameters,
+ List? Responses);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlResponseComment(string Code, string? Description, string? Example);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class XmlCommentCache
+ {
+ private static Dictionary? _cache;
+ public static Dictionary Cache => _cache ??= GenerateCacheEntries();
+
+ private static Dictionary GenerateCacheEntries()
+ {
+ var cache = new Dictionary();
+
+ cache.Add(@"M:TestController.Create(Person,ITestService)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"service", @"The service used to create the resource.", null, false)], null));
+
+ return cache;
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class DocumentationCommentIdHelper
+ {
+ ///
+ /// Generates a documentation comment ID for a type.
+ /// Example: T:Namespace.Outer+Inner`1 becomes T:Namespace.Outer.Inner`1
+ ///
+ public static string CreateDocumentationId(this Type type)
+ {
+ if (type == null)
+ {
+ throw new ArgumentNullException(nameof(type));
+ }
+
+ return "T:" + GetTypeDocId(type, includeGenericArguments: false, omitGenericArity: false);
+ }
+
+ ///
+ /// Generates a documentation comment ID for a property.
+ /// Example: P:Namespace.ContainingType.PropertyName or for an indexer P:Namespace.ContainingType.Item(System.Int32)
+ ///
+ public static string CreateDocumentationId(this PropertyInfo property)
+ {
+ if (property == null)
+ {
+ throw new ArgumentNullException(nameof(property));
+ }
+
+ var sb = new StringBuilder();
+ sb.Append("P:");
+
+ if (property.DeclaringType != null)
+ {
+ sb.Append(GetTypeDocId(property.DeclaringType, includeGenericArguments: false, omitGenericArity: false));
+ }
+
+ sb.Append('.');
+ sb.Append(property.Name);
+
+ // For indexers, include the parameter list.
+ var indexParams = property.GetIndexParameters();
+ if (indexParams.Length > 0)
+ {
+ sb.Append('(');
+ for (int i = 0; i < indexParams.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ sb.Append(GetTypeDocId(indexParams[i].ParameterType, includeGenericArguments: true, omitGenericArity: false));
+ }
+ sb.Append(')');
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Generates a documentation comment ID for a method (or constructor).
+ /// For example:
+ /// M:Namespace.ContainingType.MethodName(ParamType1,ParamType2)~ReturnType
+ /// M:Namespace.ContainingType.#ctor(ParamType)
+ ///
+ public static string CreateDocumentationId(this MethodInfo method)
+ {
+ if (method == null)
+ {
+ throw new ArgumentNullException(nameof(method));
+ }
+
+ var sb = new StringBuilder();
+ sb.Append("M:");
+
+ // Append the fully qualified name of the declaring type.
+ if (method.DeclaringType != null)
+ {
+ sb.Append(GetTypeDocId(method.DeclaringType, includeGenericArguments: false, omitGenericArity: false));
+ }
+
+ sb.Append('.');
+
+ // Append the method name, handling constructors specially.
+ if (method.IsConstructor)
+ {
+ sb.Append(method.IsStatic ? "#cctor" : "#ctor");
+ }
+ else
+ {
+ sb.Append(method.Name);
+ if (method.IsGenericMethod)
+ {
+ sb.Append("``");
+ sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", method.GetGenericArguments().Length);
+ }
+ }
+
+ // Append the parameter list, if any.
+ var parameters = method.GetParameters();
+ if (parameters.Length > 0)
+ {
+ sb.Append('(');
+ for (int i = 0; i < parameters.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ // Omit the generic arity for the parameter type.
+ sb.Append(GetTypeDocId(parameters[i].ParameterType, includeGenericArguments: true, omitGenericArity: true));
+ }
+ sb.Append(')');
+ }
+
+ // Append the return type after a '~' (if the method returns a value).
+ if (method.ReturnType != typeof(void))
+ {
+ sb.Append('~');
+ // Omit the generic arity for the return type.
+ sb.Append(GetTypeDocId(method.ReturnType, includeGenericArguments: true, omitGenericArity: true));
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Generates a documentation ID string for a type.
+ /// This method handles nested types (replacing '+' with '.'),
+ /// generic types, arrays, pointers, by-ref types, and generic parameters.
+ /// The flag controls whether
+ /// constructed generic type arguments are emitted, while
+ /// controls whether the generic arity marker (e.g. "`1") is appended.
+ ///
+ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool omitGenericArity)
+ {
+ if (type.IsGenericParameter)
+ {
+ // Use `` for method-level generic parameters and ` for type-level.
+ if (type.DeclaringMethod != null)
+ {
+ return "``" + type.GenericParameterPosition;
+ }
+ else if (type.DeclaringType != null)
+ {
+ return "`" + type.GenericParameterPosition;
+ }
+ else
+ {
+ return type.Name;
+ }
+ }
+
+ if (type.IsGenericType)
+ {
+ Type genericDef = type.GetGenericTypeDefinition();
+ string fullName = genericDef.FullName ?? genericDef.Name;
+
+ var sb = new StringBuilder(fullName.Length);
+
+ // Replace '+' with '.' for nested types
+ for (var i = 0; i < fullName.Length; i++)
+ {
+ char c = fullName[i];
+ if (c == '+')
+ {
+ sb.Append('.');
+ }
+ else if (c == '`')
+ {
+ break;
+ }
+ else
+ {
+ sb.Append(c);
+ }
+ }
+
+ if (!omitGenericArity)
+ {
+ int arity = genericDef.GetGenericArguments().Length;
+ sb.Append('`');
+ sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", arity);
+ }
+
+ if (includeGenericArguments && !type.IsGenericTypeDefinition)
+ {
+ var typeArgs = type.GetGenericArguments();
+ sb.Append('{');
+
+ for (int i = 0; i < typeArgs.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ sb.Append(GetTypeDocId(typeArgs[i], includeGenericArguments, omitGenericArity));
+ }
+
+ sb.Append('}');
+ }
+
+ return sb.ToString();
+ }
+
+ // For non-generic types, use FullName (if available) and replace nested type separators.
+ return (type.FullName ?? type.Name).Replace('+', '.');
+ }
+
+ ///
+ /// Normalizes a documentation comment ID to match the compiler-style format.
+ /// Strips the return type suffix for ordinary methods but retains it for conversion operators.
+ ///
+ /// The documentation comment ID to normalize.
+ /// The normalized documentation comment ID.
+ public static string NormalizeDocId(string docId)
+ {
+ // Find the tilde character that indicates the return type suffix
+ var tildeIndex = docId.IndexOf('~');
+ if (tildeIndex == -1)
+ {
+ // No return type suffix, return as-is
+ return docId;
+ }
+
+ // Check if this is a conversion operator (op_Implicit or op_Explicit)
+ // For these operators, we need to keep the return type suffix
+ if (docId.Contains("op_Implicit") || docId.Contains("op_Explicit"))
+ {
+ return docId;
+ }
+
+ // For ordinary methods, strip the return type suffix
+ return docId.Substring(0, tildeIndex);
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file class XmlCommentOperationTransformer : IOpenApiOperationTransformer
+ {
+ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
+ ? controllerActionDescriptor.MethodInfo
+ : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault();
+
+ if (methodInfo is null)
+ {
+ return Task.CompletedTask;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
+ {
+ if (methodComment.Summary is { } summary)
+ {
+ operation.Summary = summary;
+ }
+ if (methodComment.Description is { } description)
+ {
+ operation.Description = description;
+ }
+ if (methodComment.Remarks is { } remarks)
+ {
+ operation.Description = remarks;
+ }
+ if (methodComment.Parameters is { Count: > 0})
+ {
+ foreach (var parameterComment in methodComment.Parameters)
+ {
+ var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
+ if (operationParameter is not null)
+ {
+ var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
+ targetOperationParameter.Description = parameterComment.Description;
+ if (parameterComment.Example is { } jsonString)
+ {
+ targetOperationParameter.Example = jsonString.Parse();
+ }
+ targetOperationParameter.Deprecated = parameterComment.Deprecated;
+ }
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
+ {
+ var requestBody = operation.RequestBody;
+ if (requestBody is not null)
+ {
+ 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())
+ {
+ mediaType.Example = jsonString.Parse();
+ }
+ }
+ }
+ }
+ }
+ }
+ // Applies `` on XML comments for operation with single response value.
+ if (methodComment.Returns is { } returns && operation.Responses is { Count: 1 })
+ {
+ var response = operation.Responses.First();
+ response.Value.Description = returns;
+ }
+ // Applies `` on XML comments for operation with multiple response values.
+ if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 })
+ {
+ foreach (var response in operation.Responses)
+ {
+ var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key);
+ if (responseComment is not null)
+ {
+ response.Value.Description = responseComment.Description;
+ }
+ }
+ }
+ }
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ var metadata = parameterDescription.ModelMetadata;
+ if (metadata is not null
+ && metadata.MetadataKind == ModelMetadataKind.Property
+ && metadata.ContainerType is { } containerType
+ && metadata.PropertyName is { } propertyName)
+ {
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
+ {
+ var parameter = GetOperationParameter(operation, propertyInfo);
+ var description = propertyComment.Summary;
+ if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
+ {
+ description = $"{description}\n{propertyComment.Value}";
+ }
+ else if (string.IsNullOrEmpty(description))
+ {
+ description = propertyComment.Value;
+ }
+ if (parameter is null)
+ {
+ if (operation.RequestBody is not null)
+ {
+ operation.RequestBody.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ var content = operation.RequestBody.Content?.Values;
+ if (content is null)
+ {
+ continue;
+ }
+ var parsedExample = jsonString.Parse();
+ foreach (var mediaType in content.OfType())
+ {
+ mediaType.Example = parsedExample;
+ }
+ }
+ }
+ continue;
+ }
+ var targetOperationParameter = UnwrapOpenApiParameter(parameter);
+ if (targetOperationParameter is not null)
+ {
+ targetOperationParameter.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ targetOperationParameter.Example = jsonString.Parse();
+ }
+ }
+ }
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
+ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
+ {
+ if (sourceParameter is OpenApiParameterReference parameterReference)
+ {
+ if (parameterReference.Target is OpenApiParameter target)
+ {
+ return target;
+ }
+ else
+ {
+ throw new InvalidOperationException($"The input schema must be an {nameof(OpenApiParameter)} or {nameof(OpenApiParameterReference)}.");
+ }
+ }
+ else if (sourceParameter is OpenApiParameter directParameter)
+ {
+ return directParameter;
+ }
+ else
+ {
+ throw new InvalidOperationException($"The input schema must be an {nameof(OpenApiParameter)} or {nameof(OpenApiParameterReference)}.");
+ }
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer
+ {
+ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
+ {
+ // Apply comments from the type
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
+ {
+ schema.Description = typeComment.Summary;
+ if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Example = jsonString.Parse();
+ }
+ }
+
+ if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
+ {
+ // Apply comments from the property
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
+ {
+ var description = propertyComment.Summary;
+ if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
+ {
+ description = $"{description}\n{propertyComment.Value}";
+ }
+ else if (string.IsNullOrEmpty(description))
+ {
+ description = propertyComment.Value;
+ }
+ if (schema.Metadata is null
+ || !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
+ || string.IsNullOrEmpty(schemaId as string))
+ {
+ // Inlined schema
+ schema.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Example = jsonString.Parse();
+ }
+ }
+ else
+ {
+ // Schema Reference
+ if (!string.IsNullOrEmpty(description))
+ {
+ schema.Metadata["x-ref-description"] = description;
+ }
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Metadata["x-ref-example"] = jsonString.Parse()!;
+ }
+ }
+ }
+ }
+ return Task.CompletedTask;
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class JsonNodeExtensions
+ {
+ public static JsonNode? Parse(this string? json)
+ {
+ if (json is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonNode.Parse(json);
+ }
+ catch (JsonException)
+ {
+ try
+ {
+ // If parsing fails, try wrapping in quotes to make it a valid JSON string
+ return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\"");
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class GeneratedServiceCollectionExtensions
+ {
+ [InterceptsLocation]
+ public static IServiceCollection AddOpenApi(this IServiceCollection services)
+ {
+ return services.AddOpenApi("v1", options =>
+ {
+ options.AddSchemaTransformer(new XmlCommentSchemaTransformer());
+ options.AddOperationTransformer(new XmlCommentOperationTransformer());
+ });
+ }
+
+ }
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsParametersWithCustomNamesFromControllers#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsParametersWithCustomNamesFromControllers#OpenApiXmlCommentSupport.generated.verified.cs
new file mode 100644
index 000000000000..9b36137469fe
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsParametersWithCustomNamesFromControllers#OpenApiXmlCommentSupport.generated.verified.cs
@@ -0,0 +1,675 @@
+//HintName: OpenApiXmlCommentSupport.generated.cs
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+#nullable enable
+// Suppress warnings about obsolete types and members
+// in generated code
+#pragma warning disable CS0612, CS0618
+
+namespace System.Runtime.CompilerServices
+{
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ file sealed class InterceptsLocationAttribute : System.Attribute
+ {
+ public InterceptsLocationAttribute(int version, string data)
+ {
+ }
+ }
+}
+
+namespace Microsoft.AspNetCore.OpenApi.Generated
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Diagnostics.CodeAnalysis;
+ using System.Globalization;
+ using System.Linq;
+ using System.Reflection;
+ using System.Text;
+ using System.Text.Json;
+ using System.Text.Json.Nodes;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.OpenApi;
+ using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
+ using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.OpenApi;
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlComment(
+ string? Summary,
+ string? Description,
+ string? Remarks,
+ string? Returns,
+ string? Value,
+ bool Deprecated,
+ List? Examples,
+ List? Parameters,
+ List? Responses);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file record XmlResponseComment(string Code, string? Description, string? Example);
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class XmlCommentCache
+ {
+ private static Dictionary? _cache;
+ public static Dictionary Cache => _cache ??= GenerateCacheEntries();
+
+ private static Dictionary GenerateCacheEntries()
+ {
+ var cache = new Dictionary();
+
+ cache.Add(@"P:Query.Name", new XmlComment(@"The full name of the person.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:TestController.Get(System.Int32)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"userId", @"The id of the user.", null, false)], null));
+
+ return cache;
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class DocumentationCommentIdHelper
+ {
+ ///
+ /// Generates a documentation comment ID for a type.
+ /// Example: T:Namespace.Outer+Inner`1 becomes T:Namespace.Outer.Inner`1
+ ///
+ public static string CreateDocumentationId(this Type type)
+ {
+ if (type == null)
+ {
+ throw new ArgumentNullException(nameof(type));
+ }
+
+ return "T:" + GetTypeDocId(type, includeGenericArguments: false, omitGenericArity: false);
+ }
+
+ ///
+ /// Generates a documentation comment ID for a property.
+ /// Example: P:Namespace.ContainingType.PropertyName or for an indexer P:Namespace.ContainingType.Item(System.Int32)
+ ///
+ public static string CreateDocumentationId(this PropertyInfo property)
+ {
+ if (property == null)
+ {
+ throw new ArgumentNullException(nameof(property));
+ }
+
+ var sb = new StringBuilder();
+ sb.Append("P:");
+
+ if (property.DeclaringType != null)
+ {
+ sb.Append(GetTypeDocId(property.DeclaringType, includeGenericArguments: false, omitGenericArity: false));
+ }
+
+ sb.Append('.');
+ sb.Append(property.Name);
+
+ // For indexers, include the parameter list.
+ var indexParams = property.GetIndexParameters();
+ if (indexParams.Length > 0)
+ {
+ sb.Append('(');
+ for (int i = 0; i < indexParams.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ sb.Append(GetTypeDocId(indexParams[i].ParameterType, includeGenericArguments: true, omitGenericArity: false));
+ }
+ sb.Append(')');
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Generates a documentation comment ID for a method (or constructor).
+ /// For example:
+ /// M:Namespace.ContainingType.MethodName(ParamType1,ParamType2)~ReturnType
+ /// M:Namespace.ContainingType.#ctor(ParamType)
+ ///
+ public static string CreateDocumentationId(this MethodInfo method)
+ {
+ if (method == null)
+ {
+ throw new ArgumentNullException(nameof(method));
+ }
+
+ var sb = new StringBuilder();
+ sb.Append("M:");
+
+ // Append the fully qualified name of the declaring type.
+ if (method.DeclaringType != null)
+ {
+ sb.Append(GetTypeDocId(method.DeclaringType, includeGenericArguments: false, omitGenericArity: false));
+ }
+
+ sb.Append('.');
+
+ // Append the method name, handling constructors specially.
+ if (method.IsConstructor)
+ {
+ sb.Append(method.IsStatic ? "#cctor" : "#ctor");
+ }
+ else
+ {
+ sb.Append(method.Name);
+ if (method.IsGenericMethod)
+ {
+ sb.Append("``");
+ sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", method.GetGenericArguments().Length);
+ }
+ }
+
+ // Append the parameter list, if any.
+ var parameters = method.GetParameters();
+ if (parameters.Length > 0)
+ {
+ sb.Append('(');
+ for (int i = 0; i < parameters.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ // Omit the generic arity for the parameter type.
+ sb.Append(GetTypeDocId(parameters[i].ParameterType, includeGenericArguments: true, omitGenericArity: true));
+ }
+ sb.Append(')');
+ }
+
+ // Append the return type after a '~' (if the method returns a value).
+ if (method.ReturnType != typeof(void))
+ {
+ sb.Append('~');
+ // Omit the generic arity for the return type.
+ sb.Append(GetTypeDocId(method.ReturnType, includeGenericArguments: true, omitGenericArity: true));
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Generates a documentation ID string for a type.
+ /// This method handles nested types (replacing '+' with '.'),
+ /// generic types, arrays, pointers, by-ref types, and generic parameters.
+ /// The flag controls whether
+ /// constructed generic type arguments are emitted, while
+ /// controls whether the generic arity marker (e.g. "`1") is appended.
+ ///
+ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool omitGenericArity)
+ {
+ if (type.IsGenericParameter)
+ {
+ // Use `` for method-level generic parameters and ` for type-level.
+ if (type.DeclaringMethod != null)
+ {
+ return "``" + type.GenericParameterPosition;
+ }
+ else if (type.DeclaringType != null)
+ {
+ return "`" + type.GenericParameterPosition;
+ }
+ else
+ {
+ return type.Name;
+ }
+ }
+
+ if (type.IsGenericType)
+ {
+ Type genericDef = type.GetGenericTypeDefinition();
+ string fullName = genericDef.FullName ?? genericDef.Name;
+
+ var sb = new StringBuilder(fullName.Length);
+
+ // Replace '+' with '.' for nested types
+ for (var i = 0; i < fullName.Length; i++)
+ {
+ char c = fullName[i];
+ if (c == '+')
+ {
+ sb.Append('.');
+ }
+ else if (c == '`')
+ {
+ break;
+ }
+ else
+ {
+ sb.Append(c);
+ }
+ }
+
+ if (!omitGenericArity)
+ {
+ int arity = genericDef.GetGenericArguments().Length;
+ sb.Append('`');
+ sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", arity);
+ }
+
+ if (includeGenericArguments && !type.IsGenericTypeDefinition)
+ {
+ var typeArgs = type.GetGenericArguments();
+ sb.Append('{');
+
+ for (int i = 0; i < typeArgs.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(',');
+ }
+
+ sb.Append(GetTypeDocId(typeArgs[i], includeGenericArguments, omitGenericArity));
+ }
+
+ sb.Append('}');
+ }
+
+ return sb.ToString();
+ }
+
+ // For non-generic types, use FullName (if available) and replace nested type separators.
+ return (type.FullName ?? type.Name).Replace('+', '.');
+ }
+
+ ///
+ /// Normalizes a documentation comment ID to match the compiler-style format.
+ /// Strips the return type suffix for ordinary methods but retains it for conversion operators.
+ ///
+ /// The documentation comment ID to normalize.
+ /// The normalized documentation comment ID.
+ public static string NormalizeDocId(string docId)
+ {
+ // Find the tilde character that indicates the return type suffix
+ var tildeIndex = docId.IndexOf('~');
+ if (tildeIndex == -1)
+ {
+ // No return type suffix, return as-is
+ return docId;
+ }
+
+ // Check if this is a conversion operator (op_Implicit or op_Explicit)
+ // For these operators, we need to keep the return type suffix
+ if (docId.Contains("op_Implicit") || docId.Contains("op_Explicit"))
+ {
+ return docId;
+ }
+
+ // For ordinary methods, strip the return type suffix
+ return docId.Substring(0, tildeIndex);
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file class XmlCommentOperationTransformer : IOpenApiOperationTransformer
+ {
+ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
+ ? controllerActionDescriptor.MethodInfo
+ : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault();
+
+ if (methodInfo is null)
+ {
+ return Task.CompletedTask;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
+ {
+ if (methodComment.Summary is { } summary)
+ {
+ operation.Summary = summary;
+ }
+ if (methodComment.Description is { } description)
+ {
+ operation.Description = description;
+ }
+ if (methodComment.Remarks is { } remarks)
+ {
+ operation.Description = remarks;
+ }
+ if (methodComment.Parameters is { Count: > 0})
+ {
+ foreach (var parameterComment in methodComment.Parameters)
+ {
+ var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
+ if (operationParameter is not null)
+ {
+ var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
+ targetOperationParameter.Description = parameterComment.Description;
+ if (parameterComment.Example is { } jsonString)
+ {
+ targetOperationParameter.Example = jsonString.Parse();
+ }
+ targetOperationParameter.Deprecated = parameterComment.Deprecated;
+ }
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
+ {
+ var requestBody = operation.RequestBody;
+ if (requestBody is not null)
+ {
+ 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())
+ {
+ mediaType.Example = jsonString.Parse();
+ }
+ }
+ }
+ }
+ }
+ }
+ // Applies `` on XML comments for operation with single response value.
+ if (methodComment.Returns is { } returns && operation.Responses is { Count: 1 })
+ {
+ var response = operation.Responses.First();
+ response.Value.Description = returns;
+ }
+ // Applies `` on XML comments for operation with multiple response values.
+ if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 })
+ {
+ foreach (var response in operation.Responses)
+ {
+ var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key);
+ if (responseComment is not null)
+ {
+ response.Value.Description = responseComment.Description;
+ }
+ }
+ }
+ }
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ var metadata = parameterDescription.ModelMetadata;
+ if (metadata is not null
+ && metadata.MetadataKind == ModelMetadataKind.Property
+ && metadata.ContainerType is { } containerType
+ && metadata.PropertyName is { } propertyName)
+ {
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
+ {
+ var parameter = GetOperationParameter(operation, propertyInfo);
+ var description = propertyComment.Summary;
+ if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
+ {
+ description = $"{description}\n{propertyComment.Value}";
+ }
+ else if (string.IsNullOrEmpty(description))
+ {
+ description = propertyComment.Value;
+ }
+ if (parameter is null)
+ {
+ if (operation.RequestBody is not null)
+ {
+ operation.RequestBody.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ var content = operation.RequestBody.Content?.Values;
+ if (content is null)
+ {
+ continue;
+ }
+ var parsedExample = jsonString.Parse();
+ foreach (var mediaType in content.OfType())
+ {
+ mediaType.Example = parsedExample;
+ }
+ }
+ }
+ continue;
+ }
+ var targetOperationParameter = UnwrapOpenApiParameter(parameter);
+ if (targetOperationParameter is not null)
+ {
+ targetOperationParameter.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ targetOperationParameter.Example = jsonString.Parse();
+ }
+ }
+ }
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
+ private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
+ {
+ if (sourceParameter is OpenApiParameterReference parameterReference)
+ {
+ if (parameterReference.Target is OpenApiParameter target)
+ {
+ return target;
+ }
+ else
+ {
+ throw new InvalidOperationException($"The input schema must be an {nameof(OpenApiParameter)} or {nameof(OpenApiParameterReference)}.");
+ }
+ }
+ else if (sourceParameter is OpenApiParameter directParameter)
+ {
+ return directParameter;
+ }
+ else
+ {
+ throw new InvalidOperationException($"The input schema must be an {nameof(OpenApiParameter)} or {nameof(OpenApiParameterReference)}.");
+ }
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer
+ {
+ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
+ {
+ // Apply comments from the type
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
+ {
+ schema.Description = typeComment.Summary;
+ if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Example = jsonString.Parse();
+ }
+ }
+
+ if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
+ {
+ // Apply comments from the property
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
+ {
+ var description = propertyComment.Summary;
+ if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
+ {
+ description = $"{description}\n{propertyComment.Value}";
+ }
+ else if (string.IsNullOrEmpty(description))
+ {
+ description = propertyComment.Value;
+ }
+ if (schema.Metadata is null
+ || !schema.Metadata.TryGetValue("x-schema-id", out var schemaId)
+ || string.IsNullOrEmpty(schemaId as string))
+ {
+ // Inlined schema
+ schema.Description = description;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Example = jsonString.Parse();
+ }
+ }
+ else
+ {
+ // Schema Reference
+ if (!string.IsNullOrEmpty(description))
+ {
+ schema.Metadata["x-ref-description"] = description;
+ }
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Metadata["x-ref-example"] = jsonString.Parse()!;
+ }
+ }
+ }
+ }
+ return Task.CompletedTask;
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class JsonNodeExtensions
+ {
+ public static JsonNode? Parse(this string? json)
+ {
+ if (json is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonNode.Parse(json);
+ }
+ catch (JsonException)
+ {
+ try
+ {
+ // If parsing fails, try wrapping in quotes to make it a valid JSON string
+ return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\"");
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
+ file static class GeneratedServiceCollectionExtensions
+ {
+ [InterceptsLocation]
+ public static IServiceCollection AddOpenApi(this IServiceCollection services)
+ {
+ return services.AddOpenApi("v1", options =>
+ {
+ options.AddSchemaTransformer(new XmlCommentSchemaTransformer());
+ options.AddOperationTransformer(new XmlCommentOperationTransformer());
+ });
+ }
+
+ }
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromControllers#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromControllers#OpenApiXmlCommentSupport.generated.verified.cs
index e569ddc2f4a8..4d598388067d 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromControllers#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromControllers#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -136,30 +137,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -372,7 +349,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -383,7 +360,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -432,10 +412,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -482,6 +466,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs
index 6ac0fcb221a9..5d7d27ce3af8 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -139,30 +140,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -375,7 +352,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -386,7 +363,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -435,10 +415,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -485,6 +469,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs
index 85d5e91dd378..791bbee9c2aa 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -83,6 +84,7 @@ private static Dictionary GenerateCacheEntries()
cache.Add(@"P:MixedParametersClass.Age", new XmlComment(@"The user's age in years.", null, null, null, null, false, [@"25"], null, null));
cache.Add(@"P:MixedParametersClass.IsActive", new XmlComment(@"Whether the user is active.", null, null, null, null, false, [@"true"], null, null));
cache.Add(@"P:BindingSourceParametersClass.QueryParam", new XmlComment(@"Query parameter from URL.", null, null, null, null, false, null, null, null));
+ cache.Add(@"P:BindingSourceParametersClass.QueryParamWithCustomName", new XmlComment(@"Query parameter from URL with custom name.", null, null, null, null, false, null, null, null));
cache.Add(@"P:BindingSourceParametersClass.HeaderParam", new XmlComment(@"Header value from request.", null, null, null, null, false, null, null, null));
cache.Add(@"P:XmlDocPriorityParametersClass.SummaryOnlyProperty", new XmlComment(@"Property with only summary documentation.", null, null, null, null, false, null, null, null));
cache.Add(@"P:XmlDocPriorityParametersClass.SummaryAndReturnsProperty", new XmlComment(@"Property with summary documentation that should be overridden.", null, null, @"Returns-based description that should take precedence over summary.", null, false, null, null, null));
@@ -183,30 +185,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -419,7 +397,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -430,7 +408,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -479,10 +460,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -529,6 +514,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs
index 9f6a292f13bb..8fe948f0e190 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -165,30 +166,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -401,7 +378,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -412,7 +389,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -461,10 +441,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -511,6 +495,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.XmlCommentsOnPropertiesShouldApplyToSchemaReferences#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.XmlCommentsOnPropertiesShouldApplyToSchemaReferences#OpenApiXmlCommentSupport.generated.verified.cs
index be4687040128..530890b9ccf5 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.XmlCommentsOnPropertiesShouldApplyToSchemaReferences#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.XmlCommentsOnPropertiesShouldApplyToSchemaReferences#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -144,30 +145,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -380,7 +357,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -391,7 +368,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -440,10 +420,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -490,6 +474,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs
index 14e6fc1de407..5d806270a95f 100644
--- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs
@@ -1,4 +1,4 @@
-//HintName: OpenApiXmlCommentSupport.generated.cs
+//HintName: OpenApiXmlCommentSupport.generated.cs
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
using System.Threading.Tasks;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Mvc.Controllers;
+ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
@@ -136,30 +137,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}
- ///
- /// Generates a documentation comment ID for a property given its container type and property name.
- /// Example: P:Namespace.ContainingType.PropertyName
- ///
- public static string CreateDocumentationId(Type containerType, string propertyName)
- {
- if (containerType == null)
- {
- throw new ArgumentNullException(nameof(containerType));
- }
- if (string.IsNullOrEmpty(propertyName))
- {
- throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
- }
-
- var sb = new StringBuilder();
- sb.Append("P:");
- sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
- sb.Append('.');
- sb.Append(propertyName);
-
- return sb.ToString();
- }
-
///
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
@@ -372,7 +349,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
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);
+ var operationParameter = GetOperationParameter(operation, parameterInfo, parameterComment);
if (operationParameter is not null)
{
var targetOperationParameter = UnwrapOpenApiParameter(operationParameter);
@@ -383,7 +360,10 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
}
targetOperationParameter.Deprecated = parameterComment.Deprecated;
}
- else
+ // Only fall back to the request body when the parameter is actually bound to it.
+ // This avoids applying documentation for parameters that aren't part of the
+ // OpenAPI surface (e.g. a `CancellationToken`) to the request body.
+ else if (IsRequestBodyParameter(context, parameterInfo, parameterComment))
{
var requestBody = operation.RequestBody;
if (requestBody is not null)
@@ -432,10 +412,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
&& metadata.ContainerType is { } containerType
&& metadata.PropertyName is { } propertyName)
{
- var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
- if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
+ var propertyInfo = containerType.GetProperty(propertyName);
+ if (propertyInfo is null)
+ {
+ continue;
+ }
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
- var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
+ var parameter = GetOperationParameter(operation, propertyInfo);
var description = propertyComment.Summary;
if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value))
{
@@ -482,6 +466,86 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
return Task.CompletedTask;
}
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo)
+ {
+ return GetOperationParameter(operation, propertyInfo, propertyInfo?.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ return GetOperationParameter(operation, parameterInfo, parameterInfo?.Name ?? comment.Name);
+ }
+
+ private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var parameters = operation.Parameters;
+ if (parameters is null || parameters.Count == 0)
+ {
+ return null;
+ }
+
+ var modelNames = GetModelNames(attributeProvider, name);
+
+ foreach (var parameter in parameters)
+ {
+ var parameterName = parameter.Name;
+
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ continue;
+ }
+
+ if (modelNames.Contains(parameterName))
+ {
+ return parameter;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRequestBodyParameter(OpenApiOperationTransformerContext context, ParameterInfo? parameterInfo, XmlParameterComment comment)
+ {
+ var modelNames = GetModelNames(parameterInfo, parameterInfo?.Name ?? comment.Name);
+
+ foreach (var parameterDescription in context.Description.ParameterDescriptions)
+ {
+ if (parameterDescription.Source == BindingSource.Body
+ && parameterDescription.Name is { } parameterName
+ && modelNames.Contains(parameterName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static IReadOnlySet GetModelNames(ICustomAttributeProvider? attributeProvider, string? name)
+ {
+ var modelNames = new HashSet();
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ modelNames.Add(name);
+ }
+
+ if (attributeProvider is null)
+ {
+ return modelNames;
+ }
+
+ foreach (var attribute in attributeProvider.GetCustomAttributes(inherit: false))
+ {
+ if (attribute is IModelNameProvider modelNameProvider && !string.IsNullOrEmpty(modelNameProvider.Name))
+ {
+ modelNames.Add(modelNameProvider.Name);
+ }
+ }
+
+ return modelNames;
+ }
+
private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter)
{
if (sourceParameter is OpenApiParameterReference parameterReference)