diff --git a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
index 7e27ffb79b95..6f6d16d41461 100644
--- a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
+++ b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
@@ -302,6 +302,33 @@ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool
// 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);
+ }
}
{{GeneratedCodeAttribute}}
@@ -317,7 +344,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
{
return Task.CompletedTask;
}
- if (XmlCommentCache.Cache.TryGetValue(methodInfo.CreateDocumentationId(), out var methodComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
{
if (methodComment.Summary is { } summary)
{
@@ -423,7 +450,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
- if (XmlCommentCache.Cache.TryGetValue(propertyInfo.CreateDocumentationId(), out var propertyComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
@@ -432,7 +459,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
- if (XmlCommentCache.Cache.TryGetValue(context.JsonTypeInfo.Type.CreateDocumentationId(), out var typeComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
diff --git a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs
index 0463486167df..ba8248194d20 100644
--- a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs
+++ b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
@@ -14,6 +15,32 @@ namespace Microsoft.AspNetCore.OpenApi.SourceGenerators;
public sealed partial class XmlCommentGenerator
{
+ ///
+ /// 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.
+ internal 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", StringComparison.Ordinal) || docId.Contains("op_Explicit", StringComparison.Ordinal))
+ {
+ return docId;
+ }
+
+ // For ordinary methods, strip the return type suffix
+ return docId.Substring(0, tildeIndex);
+ }
internal static List<(string, string)> ParseXmlFile(AdditionalText additionalText, CancellationToken cancellationToken)
{
var text = additionalText.GetText(cancellationToken);
@@ -37,7 +64,7 @@ public sealed partial class XmlCommentGenerator
var name = member.Attribute(DocumentationCommentXmlNames.NameAttributeName)?.Value;
if (name is not null)
{
- comments.Add((name, member.ToString()));
+ comments.Add((NormalizeDocId(name), member.ToString()));
}
}
return comments;
@@ -54,7 +81,7 @@ public sealed partial class XmlCommentGenerator
if (DocumentationCommentId.CreateDeclarationId(type) is string name &&
type.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
{
- comments.Add((name, xml));
+ comments.Add((NormalizeDocId(name), xml));
}
}
var properties = visitor.GetPublicProperties();
@@ -63,7 +90,7 @@ public sealed partial class XmlCommentGenerator
if (DocumentationCommentId.CreateDeclarationId(property) is string name &&
property.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
{
- comments.Add((name, xml));
+ comments.Add((NormalizeDocId(name), xml));
}
}
var methods = visitor.GetPublicMethods();
@@ -77,7 +104,7 @@ public sealed partial class XmlCommentGenerator
if (DocumentationCommentId.CreateDeclarationId(method) is string name &&
method.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
{
- comments.Add((name, xml));
+ comments.Add((NormalizeDocId(name), xml));
}
}
return comments;
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/XmlCommentDocumentationIdTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/XmlCommentDocumentationIdTests.cs
new file mode 100644
index 000000000000..fbf42c1e5092
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/XmlCommentDocumentationIdTests.cs
@@ -0,0 +1,90 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net.Http;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests;
+
+[UsesVerify]
+public class XmlCommentDocumentationIdTests
+{
+ [Fact]
+ public async Task CanMergeXmlCommentsWithDifferentDocumentationIdFormats()
+ {
+ // This test verifies that XML comments from referenced assemblies (without return type suffix)
+ // are properly merged with in-memory symbols (with return type suffix)
+ var source = """
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using ReferencedLibrary;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+app.MapPost("/test-method", ReferencedLibrary.TestApi.TestMethod);
+
+app.Run();
+""";
+
+ var referencedLibrarySource = """
+using System;
+using System.Threading.Tasks;
+
+namespace ReferencedLibrary;
+
+public static class TestApi
+{
+ ///
+ /// This method should have its XML comment merged properly.
+ ///
+ /// The identifier for the test.
+ /// A task representing the asynchronous operation.
+ public static Task TestMethod(int id)
+ {
+ return Task.CompletedTask;
+ }
+}
+""";
+
+ var references = new Dictionary>
+ {
+ { "ReferencedLibrary", [referencedLibrarySource] }
+ };
+
+ var generator = new XmlCommentGenerator();
+ await SnapshotTestHelper.Verify(source, generator, references, out var compilation, out var additionalAssemblies);
+ await SnapshotTestHelper.VerifyOpenApi(compilation, additionalAssemblies, document =>
+ {
+ var path = document.Paths["/test-method"].Operations[HttpMethod.Post];
+
+ // Verify that the XML comment from the referenced library was properly merged
+ // This would fail before the fix because the documentation IDs didn't match
+ Assert.NotNull(path.Summary);
+ Assert.Equal("This method should have its XML comment merged properly.", path.Summary);
+
+ // Verify the parameter comment is also available
+ Assert.NotNull(path.Parameters);
+ Assert.Single(path.Parameters);
+ Assert.Equal("The identifier for the test.", path.Parameters[0].Description);
+ });
+ }
+
+ [Theory]
+ [InlineData("M:Sample.MyMethod(System.Int32)~System.Threading.Tasks.Task", "M:Sample.MyMethod(System.Int32)")]
+ [InlineData("M:Sample.MyMethod(System.Int32)", "M:Sample.MyMethod(System.Int32)")]
+ [InlineData("M:Sample.op_Implicit(System.Int32)~Sample.MyClass", "M:Sample.op_Implicit(System.Int32)~Sample.MyClass")]
+ [InlineData("M:Sample.op_Explicit(System.Int32)~Sample.MyClass", "M:Sample.op_Explicit(System.Int32)~Sample.MyClass")]
+ [InlineData("T:Sample.MyClass", "T:Sample.MyClass")]
+ [InlineData("P:Sample.MyClass.MyProperty", "P:Sample.MyClass.MyProperty")]
+ public void NormalizeDocId_ReturnsExpectedResult(string input, string expected)
+ {
+ var result = XmlCommentGenerator.NormalizeDocId(input);
+ Assert.Equal(expected, result);
+ }
+}
\ No newline at end of file
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 0f7dc96817c8..f13f5ffc8c83 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
@@ -284,6 +284,33 @@ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool
// 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")]
@@ -299,7 +326,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
{
return Task.CompletedTask;
}
- if (XmlCommentCache.Cache.TryGetValue(methodInfo.CreateDocumentationId(), out var methodComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
{
if (methodComment.Summary is { } summary)
{
@@ -405,7 +432,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
- if (XmlCommentCache.Cache.TryGetValue(propertyInfo.CreateDocumentationId(), out var propertyComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
@@ -414,7 +441,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
- if (XmlCommentCache.Cache.TryGetValue(context.JsonTypeInfo.Type.CreateDocumentationId(), out var typeComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
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 10b09abc3d01..ed5d669b673a 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
@@ -313,6 +313,33 @@ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool
// 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")]
@@ -328,7 +355,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
{
return Task.CompletedTask;
}
- if (XmlCommentCache.Cache.TryGetValue(methodInfo.CreateDocumentationId(), out var methodComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
{
if (methodComment.Summary is { } summary)
{
@@ -434,7 +461,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
- if (XmlCommentCache.Cache.TryGetValue(propertyInfo.CreateDocumentationId(), out var propertyComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
@@ -443,7 +470,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
- if (XmlCommentCache.Cache.TryGetValue(context.JsonTypeInfo.Type.CreateDocumentationId(), out var typeComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
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 4e6a566bb894..a70de5ef5d3f 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
@@ -159,34 +159,34 @@ Note that there isn't a way to provide a ""cref"" to
cache.Add(@"P:GenericParent.TaskOfTupleProp", new XmlComment(@"This property is a generic type containing a tuple.", null, null, null, null, false, null, null, null));
cache.Add(@"P:GenericParent.TupleWithGenericProp", new XmlComment(@"This property is a tuple with a generic type inside.", null, null, null, null, false, null, null, null));
cache.Add(@"P:GenericParent.TupleWithNestedGenericProp", new XmlComment(@"This property is a tuple with a nested generic type inside.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:ExampleClass.Add(System.Int32,System.Int32)~System.Int32", new XmlComment(@"Adds two integers and returns the result.", null, null, @"The sum of two integers.", null, false, [@" ```int c = Math.Add(4, 5);
+ cache.Add(@"M:ExampleClass.Add(System.Int32,System.Int32)", new XmlComment(@"Adds two integers and returns the result.", null, null, @"The sum of two integers.", null, false, [@" ```int c = Math.Add(4, 5);
if (c > 10)
{
Console.WriteLine(c);
}```"], [new XmlParameterComment(@"left", @"The left operand of the addition.", null, false), new XmlParameterComment(@"right", @"The right operand of the addition.", null, false)], null));
- cache.Add(@"M:ExampleClass.AddAsync(System.Int32,System.Int32)~System.Threading.Tasks.Task{System.Int32}", new XmlComment(@"This method is an example of a method that
+ cache.Add(@"M:ExampleClass.AddAsync(System.Int32,System.Int32)", new XmlComment(@"This method is an example of a method that
returns an awaitable item.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:ExampleClass.DoNothingAsync~System.Threading.Tasks.Task", new XmlComment(@"This method is an example of a method that
+ cache.Add(@"M:ExampleClass.DoNothingAsync", new XmlComment(@"This method is an example of a method that
returns a Task which should map to a void return type.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:ExampleClass.AddNumbers(System.Int32[])~System.Int32", new XmlComment(@"This method is an example of a method that consumes
+ cache.Add(@"M:ExampleClass.AddNumbers(System.Int32[])", new XmlComment(@"This method is an example of a method that consumes
an params array.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:ITestInterface.Method(System.Int32)~System.Int32", new XmlComment(@"This method is part of the test interface.", null, @"This content would be inherited by classes
+ cache.Add(@"M:ITestInterface.Method(System.Int32)", new XmlComment(@"This method is part of the test interface.", null, @"This content would be inherited by classes
that implement this interface when the
implementing class uses ""inheritdoc""", @"The value of arg", null, false, null, [new XmlParameterComment(@"arg", @"The argument to the method", null, false)], null));
- cache.Add(@"M:InheritOnlyReturns.MyParentMethod(System.Boolean)~System.Boolean", new XmlComment(@"In this example, this summary is only visible for this method.", null, null, @"A boolean", null, false, null, null, null));
- cache.Add(@"M:InheritOnlyReturns.MyChildMethod~System.Boolean", new XmlComment(null, null, null, @"A boolean", null, false, null, null, null));
- cache.Add(@"M:InheritAllButRemarks.MyParentMethod(System.Boolean)~System.Boolean", new XmlComment(@"In this example, this summary is visible on all the methods.", null, @"The remarks can be inherited by other methods
+ cache.Add(@"M:InheritOnlyReturns.MyParentMethod(System.Boolean)", new XmlComment(@"In this example, this summary is only visible for this method.", null, null, @"A boolean", null, false, null, null, null));
+ cache.Add(@"M:InheritOnlyReturns.MyChildMethod", new XmlComment(null, null, null, @"A boolean", null, false, null, null, null));
+ cache.Add(@"M:InheritAllButRemarks.MyParentMethod(System.Boolean)", new XmlComment(@"In this example, this summary is visible on all the methods.", null, @"The remarks can be inherited by other methods
using the xpath expression.", @"A boolean", null, false, null, null, null));
- cache.Add(@"M:InheritAllButRemarks.MyChildMethod~System.Boolean", new XmlComment(@"In this example, this summary is visible on all the methods.", null, null, @"A boolean", null, false, null, null, null));
- cache.Add(@"M:GenericParent.GetTaskOfTuple~System.Threading.Tasks.Task{System.ValueTuple{System.Int32,System.String}}", new XmlComment(@"This method returns a generic type containing a tuple.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:GenericParent.GetTupleOfTask~System.ValueTuple{System.Int32,System.Collections.Generic.Dictionary{System.Int32,System.String}}", new XmlComment(@"This method returns a tuple with a generic type inside.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:GenericParent.GetTupleOfTask1``1~System.ValueTuple{System.Int32,System.Collections.Generic.Dictionary{System.Int32,``0}}", new XmlComment(@"This method return a tuple with a generic type containing a
+ cache.Add(@"M:InheritAllButRemarks.MyChildMethod", new XmlComment(@"In this example, this summary is visible on all the methods.", null, null, @"A boolean", null, false, null, null, null));
+ cache.Add(@"M:GenericParent.GetTaskOfTuple", new XmlComment(@"This method returns a generic type containing a tuple.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:GenericParent.GetTupleOfTask", new XmlComment(@"This method returns a tuple with a generic type inside.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:GenericParent.GetTupleOfTask1``1", new XmlComment(@"This method return a tuple with a generic type containing a
type parameter inside.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:GenericParent.GetTupleOfTask2``1~System.ValueTuple{``0,System.Collections.Generic.Dictionary{System.Int32,System.String}}", new XmlComment(@"This method return a tuple with a generic type containing a
+ cache.Add(@"M:GenericParent.GetTupleOfTask2``1", new XmlComment(@"This method return a tuple with a generic type containing a
type parameter inside.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:GenericParent.GetNestedGeneric~System.Collections.Generic.Dictionary{System.Int32,System.Collections.Generic.Dictionary{System.Int32,System.String}}", new XmlComment(@"This method returns a nested generic with all types resolved.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:GenericParent.GetNestedGeneric1``1~System.Collections.Generic.Dictionary{System.Int32,System.Collections.Generic.Dictionary{System.Int32,``0}}", new XmlComment(@"This method returns a nested generic with a type parameter.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:ParamsAndParamRefs.GetGenericValue``1(``0)~``0", new XmlComment(@"The GetGenericValue method.", null, @"This sample shows how to specify the T ParamsAndParamRefs.GetGenericValue<T>(T para)
+ cache.Add(@"M:GenericParent.GetNestedGeneric", new XmlComment(@"This method returns a nested generic with all types resolved.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:GenericParent.GetNestedGeneric1``1", new XmlComment(@"This method returns a nested generic with a type parameter.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:ParamsAndParamRefs.GetGenericValue``1(``0)", new XmlComment(@"The GetGenericValue method.", null, @"This sample shows how to specify the T ParamsAndParamRefs.GetGenericValue<T>(T para)
method as a cref attribute.
The parameter and return value are both of an arbitrary type,
T", null, null, false, null, null, null));
@@ -405,6 +405,33 @@ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool
// 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")]
@@ -420,7 +447,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
{
return Task.CompletedTask;
}
- if (XmlCommentCache.Cache.TryGetValue(methodInfo.CreateDocumentationId(), out var methodComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
{
if (methodComment.Summary is { } summary)
{
@@ -526,7 +553,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
- if (XmlCommentCache.Cache.TryGetValue(propertyInfo.CreateDocumentationId(), out var propertyComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
@@ -535,7 +562,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
- if (XmlCommentCache.Cache.TryGetValue(context.JsonTypeInfo.Type.CreateDocumentationId(), out var typeComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
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 76e202554e42..f4da876e2123 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
@@ -70,10 +70,10 @@ private static Dictionary GenerateCacheEntries()
{
var cache = new Dictionary();
- cache.Add(@"M:TestController.Get~System.String", new XmlComment(@"A summary of the action.", @"A description of the action.", null, null, null, false, null, null, null));
- cache.Add(@"M:Test2Controller.Get(System.String)~System.String", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", null, false)], [new XmlResponseComment(@"200", @"Returns the greeting.", @"")]));
- cache.Add(@"M:Test2Controller.Get(System.Int32)~System.String", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"id", @"The id associated with the request.", null, false)], null));
- cache.Add(@"M:Test2Controller.Post(Todo)~System.String", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"todo", @"The todo to insert into the database.", null, false)], null));
+ cache.Add(@"M:TestController.Get", new XmlComment(@"A summary of the action.", @"A description of the action.", null, null, null, false, null, null, null));
+ cache.Add(@"M:Test2Controller.Get(System.String)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", null, false)], [new XmlResponseComment(@"200", @"Returns the greeting.", @"")]));
+ cache.Add(@"M:Test2Controller.Get(System.Int32)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"id", @"The id associated with the request.", null, false)], null));
+ cache.Add(@"M:Test2Controller.Post(Todo)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"todo", @"The todo to insert into the database.", null, false)], null));
return cache;
}
@@ -288,6 +288,33 @@ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool
// 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")]
@@ -303,7 +330,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
{
return Task.CompletedTask;
}
- if (XmlCommentCache.Cache.TryGetValue(methodInfo.CreateDocumentationId(), out var methodComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
{
if (methodComment.Summary is { } summary)
{
@@ -409,7 +436,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
- if (XmlCommentCache.Cache.TryGetValue(propertyInfo.CreateDocumentationId(), out var propertyComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
@@ -418,7 +445,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
- if (XmlCommentCache.Cache.TryGetValue(context.JsonTypeInfo.Type.CreateDocumentationId(), out var typeComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
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 dbce7f0223bf..4a2aa376b299 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
@@ -70,28 +70,28 @@ private static Dictionary GenerateCacheEntries()
{
var cache = new Dictionary();
- cache.Add(@"M:RouteHandlerExtensionMethods.Get~System.String", new XmlComment(@"A summary of the action.", @"A description of the action.", null, @"Returns the greeting.", null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get2(System.String)~System.String", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", null, false)], [new XmlResponseComment(@"200", @"Returns the greeting.", @"")]));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get3(System.String)~System.String", new XmlComment(null, null, null, @"Returns the greeting.", null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", @"Testy McTester", false)], null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get4~Microsoft.AspNetCore.Http.HttpResults.NotFound{System.String}", new XmlComment(null, null, null, @"Indicates that the value was not found.", null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get5~Microsoft.AspNetCore.Http.HttpResults.Results{Microsoft.AspNetCore.Http.HttpResults.NotFound{System.String},Microsoft.AspNetCore.Http.HttpResults.Ok{System.String},Microsoft.AspNetCore.Http.HttpResults.Created}", new XmlComment(null, null, null, @"This gets ignored.", null, false, null, null, [new XmlResponseComment(@"200", @"Indicates that the value is even.", @""), new XmlResponseComment(@"201", @"Indicates that the value is less than 50.", @""), new XmlResponseComment(@"404", @"Indicates that the value was not found.", @"")]));
- cache.Add(@"M:RouteHandlerExtensionMethods.Post6(User)~Microsoft.AspNetCore.Http.IResult", new XmlComment(@"Creates a new user.", null, @"Sample request:
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get", new XmlComment(@"A summary of the action.", @"A description of the action.", null, @"Returns the greeting.", null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get2(System.String)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", null, false)], [new XmlResponseComment(@"200", @"Returns the greeting.", @"")]));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get3(System.String)", new XmlComment(null, null, null, @"Returns the greeting.", null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", @"Testy McTester", false)], null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get4", new XmlComment(null, null, null, @"Indicates that the value was not found.", null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get5", new XmlComment(null, null, null, @"This gets ignored.", null, false, null, null, [new XmlResponseComment(@"200", @"Indicates that the value is even.", @""), new XmlResponseComment(@"201", @"Indicates that the value is less than 50.", @""), new XmlResponseComment(@"404", @"Indicates that the value was not found.", @"")]));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Post6(User)", new XmlComment(@"Creates a new user.", null, @"Sample request:
POST /6
{
""username"": ""johndoe"",
""email"": ""john@example.com""
}", null, null, false, null, [new XmlParameterComment(@"user", @"The user information.", @"{""username"": ""johndoe"", ""email"": ""john@example.com""}", false)], [new XmlResponseComment(@"201", @"Successfully created the user.", @""), new XmlResponseComment(@"400", @"If the user data is invalid.", @"")]));
- cache.Add(@"M:RouteHandlerExtensionMethods.Put7(System.Nullable{System.Int32},System.String)~Microsoft.AspNetCore.Http.IResult", new XmlComment(@"Updates an existing record.", null, null, null, null, false, null, [new XmlParameterComment(@"id", @"Legacy ID parameter - use uuid instead.", null, true), new XmlParameterComment(@"uuid", @"Unique identifier for the record.", null, false)], [new XmlResponseComment(@"204", @"Update successful.", @""), new XmlResponseComment(@"404", @"Legacy response - will be removed.", @"")]));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get8~System.Threading.Tasks.Task", new XmlComment(@"A summary of Get8.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get9~System.Threading.Tasks.ValueTask", new XmlComment(@"A summary of Get9.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get10~System.Threading.Tasks.Task", new XmlComment(@"A summary of Get10.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get11~System.Threading.Tasks.ValueTask", new XmlComment(@"A summary of Get11.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get12~System.Threading.Tasks.Task{System.String}", new XmlComment(@"A summary of Get12.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get13~System.Threading.Tasks.ValueTask{System.String}", new XmlComment(@"A summary of Get13.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get14~System.Threading.Tasks.Task{Holder{System.String}}", new XmlComment(@"A summary of Get14.", null, null, @"Returns the greeting.", null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get15~System.Threading.Tasks.Task{Holder{System.String}}", new XmlComment(@"A summary of Get15.", null, null, null, null, false, null, null, [new XmlResponseComment(@"200", @"Returns the greeting.", @"")]));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Put7(System.Nullable{System.Int32},System.String)", new XmlComment(@"Updates an existing record.", null, null, null, null, false, null, [new XmlParameterComment(@"id", @"Legacy ID parameter - use uuid instead.", null, true), new XmlParameterComment(@"uuid", @"Unique identifier for the record.", null, false)], [new XmlResponseComment(@"204", @"Update successful.", @""), new XmlResponseComment(@"404", @"Legacy response - will be removed.", @"")]));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get8", new XmlComment(@"A summary of Get8.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get9", new XmlComment(@"A summary of Get9.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get10", new XmlComment(@"A summary of Get10.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get11", new XmlComment(@"A summary of Get11.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get12", new XmlComment(@"A summary of Get12.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get13", new XmlComment(@"A summary of Get13.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get14", new XmlComment(@"A summary of Get14.", null, null, @"Returns the greeting.", null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get15", new XmlComment(@"A summary of Get15.", null, null, null, null, false, null, null, [new XmlResponseComment(@"200", @"Returns the greeting.", @"")]));
cache.Add(@"M:RouteHandlerExtensionMethods.Post16(Example)", new XmlComment(@"A summary of Post16.", null, null, null, null, false, null, null, null));
- cache.Add(@"M:RouteHandlerExtensionMethods.Get17(System.Int32[])~System.Int32[][]", new XmlComment(@"A summary of Get17.", null, null, null, null, false, null, null, null));
+ cache.Add(@"M:RouteHandlerExtensionMethods.Get17(System.Int32[])", new XmlComment(@"A summary of Get17.", null, null, null, null, false, null, null, null));
return cache;
}
@@ -306,6 +306,33 @@ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool
// 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")]
@@ -321,7 +348,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
{
return Task.CompletedTask;
}
- if (XmlCommentCache.Cache.TryGetValue(methodInfo.CreateDocumentationId(), out var methodComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
{
if (methodComment.Summary is { } summary)
{
@@ -427,7 +454,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
- if (XmlCommentCache.Cache.TryGetValue(propertyInfo.CreateDocumentationId(), out var propertyComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
@@ -436,7 +463,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
- if (XmlCommentCache.Cache.TryGetValue(context.JsonTypeInfo.Type.CreateDocumentationId(), out var typeComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
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 915b30278e70..92cc5419f37c 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
@@ -314,6 +314,33 @@ private static string GetTypeDocId(Type type, bool includeGenericArguments, bool
// 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")]
@@ -329,7 +356,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
{
return Task.CompletedTask;
}
- if (XmlCommentCache.Cache.TryGetValue(methodInfo.CreateDocumentationId(), out var methodComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment))
{
if (methodComment.Summary is { } summary)
{
@@ -435,7 +462,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
{
if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
{
- if (XmlCommentCache.Cache.TryGetValue(propertyInfo.CreateDocumentationId(), out var propertyComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
{
schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
@@ -444,7 +471,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext
}
}
}
- if (XmlCommentCache.Cache.TryGetValue(context.JsonTypeInfo.Type.CreateDocumentationId(), out var typeComment))
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment))
{
schema.Description = typeComment.Summary;
if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
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
new file mode 100644
index 000000000000..5c3dcf382281
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs
@@ -0,0 +1,499 @@
+//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.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:ReferencedLibrary.TestApi.TestMethod(System.Int32)", new XmlComment(@"This method should have its XML comment merged properly.", null, null, @"A task representing the asynchronous operation.", null, false, null, [new XmlParameterComment(@"id", @"The identifier for the test.", null, false)], null));
+
+
+ return cache;
+ }
+ }
+
+ 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 = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name);
+ 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;
+ }
+ else
+ {
+ 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)
+ {
+ 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;
+ }
+ }
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ 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)
+ {
+ if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
+ {
+ if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment))
+ {
+ schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
+ if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+ {
+ schema.Example = jsonString.Parse();
+ }
+ }
+ }
+ 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();
+ }
+ }
+ return Task.CompletedTask;
+ }
+ }
+
+ 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());
+ });
+ }
+
+ }
+}
\ No newline at end of file