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