From 4d97c9b4caf77be209266c5ae710472ba9085caa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 00:40:48 +0000 Subject: [PATCH 1/6] Initial plan From 7215cca5d7da8663c05edd3cb40fadea17ec6b82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 00:52:25 +0000 Subject: [PATCH 2/6] Implement NormalizeDocId fix for XML comment generator Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- src/OpenApi/gen/XmlCommentGenerator.Parser.cs | 34 +- .../XmlCommentDocumentationIdTests.cs | 90 +++ ...ApiXmlCommentSupport.generated.received.cs | 592 ++++++++++++++++++ ...ApiXmlCommentSupport.generated.received.cs | 475 ++++++++++++++ ...ApiXmlCommentSupport.generated.received.cs | 493 +++++++++++++++ ...ApiXmlCommentSupport.generated.received.cs | 472 ++++++++++++++ 6 files changed, 2152 insertions(+), 4 deletions(-) create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/XmlCommentDocumentationIdTests.cs create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.received.cs diff --git a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs index 0463486167df..f86c9407733b 100644 --- a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs +++ b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs @@ -14,6 +14,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") || docId.Contains("op_Explicit")) + { + 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 +63,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 +80,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 +89,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 +103,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/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs new file mode 100644 index 000000000000..cfd4045037dd --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs @@ -0,0 +1,592 @@ +//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(@"T:ExampleClass", new XmlComment(@"Every class and member should have a one sentence +summary describing its purpose.", null, @" You can expand on that one sentence summary to + provide more information for readers. In this case, + the `ExampleClass` provides different C# + elements to show how you would add documentation + comments for most elements in a typical class. + The remarks can add multiple paragraphs, so you can +write detailed information for developers that use +your work. You should add everything needed for +readers to be successful. This class contains +examples for the following: + * Summary + +This should provide a one sentence summary of the class or member. +* Remarks + +This is typically a more detailed description of the class or member +* para + +The para tag separates a section into multiple paragraphs +* list + +Provides a list of terms or elements +* returns, param + +Used to describe parameters and return values +* value +Used to describe properties +* exception + +Used to describe exceptions that may be thrown +* c, cref, see, seealso + +These provide code style and links to other +documentation elements +* example, code + +These are used for code examples + The list above uses the ""table"" style. You could +also use the ""bullet"" or ""number"" style. Neither +would typically use the ""term"" element. + +Note: paragraphs are double spaced. Use the *br* +tag for single spaced lines.", null, null, false, null, null, null)); + cache.Add(@"T:Person", new XmlComment(@"This is an example of a positional record.", null, @"There isn't a way to add XML comments for properties +created for positional records, yet. The language +design team is still considering what tags should +be supported, and where. Currently, you can use +the ""param"" tag to describe the parameters to the +primary constructor.", null, null, false, null, [new XmlParameterComment(@"FirstName", @"This tag will apply to the primary constructor parameter.", null, false), new XmlParameterComment(@"LastName", @"This tag will apply to the primary constructor parameter.", null, false)], null)); + cache.Add(@"T:MainClass", new XmlComment(@"A summary about this class.", null, @"These remarks would explain more about this class. +In this example, these comments also explain the +general information about the derived class.", null, null, false, null, null, null)); + cache.Add(@"T:DerivedClass", new XmlComment(@"A summary about this class.", null, @"These remarks would explain more about this class. +In this example, these comments also explain the +general information about the derived class.", null, null, false, null, null, null)); + cache.Add(@"T:ITestInterface", new XmlComment(@"This interface would describe all the methods in +its contract.", null, @"While elided for brevity, each method or property +in this interface would contain docs that you want +to duplicate in each implementing class.", null, null, false, null, null, null)); + cache.Add(@"T:ImplementingClass", new XmlComment(@"This interface would describe all the methods in +its contract.", null, @"While elided for brevity, each method or property +in this interface would contain docs that you want +to duplicate in each implementing class.", null, null, false, null, null, null)); + cache.Add(@"T:InheritOnlyReturns", new XmlComment(@"This class shows hows you can ""inherit"" the doc +comments from one method in another method.", null, @"You can inherit all comments, or only a specific tag, +represented by an xpath expression.", null, null, false, null, null, null)); + cache.Add(@"T:InheritAllButRemarks", new XmlComment(@"This class shows an example of sharing comments across methods.", null, null, null, null, false, null, null, null)); + cache.Add(@"T:GenericClass`1", new XmlComment(@"This is a generic class.", null, @"This example shows how to specify the GenericClass<T> +type as a cref attribute. +In generic classes and methods, you'll often want to reference the +generic type, or the type parameter.", null, null, false, null, null, null)); + cache.Add(@"T:GenericParent", new XmlComment(@"This class validates the behavior for mapping +generic types to open generics for use in +typeof expressions.", null, null, null, null, false, null, null, null)); + cache.Add(@"T:ParamsAndParamRefs", new XmlComment(@"This shows examples of typeparamref and typeparam tags", null, null, null, null, false, null, null, null)); + cache.Add(@"T:DisposableType", new XmlComment(@"A class that implements the IDisposable interface.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:ExampleClass.Label", new XmlComment(null, null, @" The string? ExampleClass.Label is a `string` + that you use for a label. + Note that there isn't a way to provide a ""cref"" to +each accessor, only to the property itself.", null, @"The `Label` property represents a label +for this instance.", false, null, null, null)); + cache.Add(@"P:Person.FirstName", new XmlComment(@"This tag will apply to the primary constructor parameter.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:Person.LastName", new XmlComment(@"This tag will apply to the primary constructor parameter.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:GenericParent.Id", new XmlComment(@"This property is a nullable value type.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:GenericParent.Name", new XmlComment(@"This property is a nullable reference type.", null, null, null, null, false, null, null, null)); + 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)", 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)", 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", 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[])", 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)", 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)", 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", 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", 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", 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)); + cache.Add(@"M:DisposableType.Dispose", new XmlComment(null, null, null, null, null, false, null, null, 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('+', '.'); + } + } + + [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(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(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(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 diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs new file mode 100644 index 000000000000..448667404712 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs @@ -0,0 +1,475 @@ +//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: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; + } + } + + 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('+', '.'); + } + } + + [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(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(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(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 diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs new file mode 100644 index 000000000000..c5c44ed109bc --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs @@ -0,0 +1,493 @@ +//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: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)", 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[])", new XmlComment(@"A summary of Get17.", null, null, null, null, false, null, null, 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('+', '.'); + } + } + + [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(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(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(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 diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.received.cs new file mode 100644 index 000000000000..f4843a66a73f --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.received.cs @@ -0,0 +1,472 @@ +//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('+', '.'); + } + } + + [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(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(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(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 From 2d427913d804a249a2bd5380c0fc18751a505caa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 01:01:07 +0000 Subject: [PATCH 3/6] Fix runtime lookup to use normalized documentation IDs Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../gen/XmlCommentGenerator.Emitter.cs | 33 +- ...piXmlCommentSupport.generated.received.cs} | 33 +- ...piXmlCommentSupport.generated.received.cs} | 33 +- ...ApiXmlCommentSupport.generated.received.cs | 33 +- ...ApiXmlCommentSupport.generated.verified.cs | 592 ------------------ ...ApiXmlCommentSupport.generated.received.cs | 33 +- ...ApiXmlCommentSupport.generated.verified.cs | 475 -------------- ...ApiXmlCommentSupport.generated.received.cs | 33 +- ...ApiXmlCommentSupport.generated.verified.cs | 493 --------------- ...piXmlCommentSupport.generated.received.cs} | 33 +- ...piXmlCommentSupport.generated.verified.cs} | 33 +- 11 files changed, 240 insertions(+), 1584 deletions(-) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs => AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.received.cs} (92%) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs => AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.received.cs} (93%) delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs => SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs} (93%) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.received.cs => XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs} (92%) 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/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.received.cs similarity index 92% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.received.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.received.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.received.cs similarity index 93% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.received.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.received.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.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs index cfd4045037dd..a70de5ef5d3f 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs @@ -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/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs deleted file mode 100644 index 4e6a566bb894..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs +++ /dev/null @@ -1,592 +0,0 @@ -//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(@"T:ExampleClass", new XmlComment(@"Every class and member should have a one sentence -summary describing its purpose.", null, @" You can expand on that one sentence summary to - provide more information for readers. In this case, - the `ExampleClass` provides different C# - elements to show how you would add documentation - comments for most elements in a typical class. - The remarks can add multiple paragraphs, so you can -write detailed information for developers that use -your work. You should add everything needed for -readers to be successful. This class contains -examples for the following: - * Summary - -This should provide a one sentence summary of the class or member. -* Remarks - -This is typically a more detailed description of the class or member -* para - -The para tag separates a section into multiple paragraphs -* list - -Provides a list of terms or elements -* returns, param - -Used to describe parameters and return values -* value -Used to describe properties -* exception - -Used to describe exceptions that may be thrown -* c, cref, see, seealso - -These provide code style and links to other -documentation elements -* example, code - -These are used for code examples - The list above uses the ""table"" style. You could -also use the ""bullet"" or ""number"" style. Neither -would typically use the ""term"" element. - -Note: paragraphs are double spaced. Use the *br* -tag for single spaced lines.", null, null, false, null, null, null)); - cache.Add(@"T:Person", new XmlComment(@"This is an example of a positional record.", null, @"There isn't a way to add XML comments for properties -created for positional records, yet. The language -design team is still considering what tags should -be supported, and where. Currently, you can use -the ""param"" tag to describe the parameters to the -primary constructor.", null, null, false, null, [new XmlParameterComment(@"FirstName", @"This tag will apply to the primary constructor parameter.", null, false), new XmlParameterComment(@"LastName", @"This tag will apply to the primary constructor parameter.", null, false)], null)); - cache.Add(@"T:MainClass", new XmlComment(@"A summary about this class.", null, @"These remarks would explain more about this class. -In this example, these comments also explain the -general information about the derived class.", null, null, false, null, null, null)); - cache.Add(@"T:DerivedClass", new XmlComment(@"A summary about this class.", null, @"These remarks would explain more about this class. -In this example, these comments also explain the -general information about the derived class.", null, null, false, null, null, null)); - cache.Add(@"T:ITestInterface", new XmlComment(@"This interface would describe all the methods in -its contract.", null, @"While elided for brevity, each method or property -in this interface would contain docs that you want -to duplicate in each implementing class.", null, null, false, null, null, null)); - cache.Add(@"T:ImplementingClass", new XmlComment(@"This interface would describe all the methods in -its contract.", null, @"While elided for brevity, each method or property -in this interface would contain docs that you want -to duplicate in each implementing class.", null, null, false, null, null, null)); - cache.Add(@"T:InheritOnlyReturns", new XmlComment(@"This class shows hows you can ""inherit"" the doc -comments from one method in another method.", null, @"You can inherit all comments, or only a specific tag, -represented by an xpath expression.", null, null, false, null, null, null)); - cache.Add(@"T:InheritAllButRemarks", new XmlComment(@"This class shows an example of sharing comments across methods.", null, null, null, null, false, null, null, null)); - cache.Add(@"T:GenericClass`1", new XmlComment(@"This is a generic class.", null, @"This example shows how to specify the GenericClass<T> -type as a cref attribute. -In generic classes and methods, you'll often want to reference the -generic type, or the type parameter.", null, null, false, null, null, null)); - cache.Add(@"T:GenericParent", new XmlComment(@"This class validates the behavior for mapping -generic types to open generics for use in -typeof expressions.", null, null, null, null, false, null, null, null)); - cache.Add(@"T:ParamsAndParamRefs", new XmlComment(@"This shows examples of typeparamref and typeparam tags", null, null, null, null, false, null, null, null)); - cache.Add(@"T:DisposableType", new XmlComment(@"A class that implements the IDisposable interface.", null, null, null, null, false, null, null, null)); - cache.Add(@"P:ExampleClass.Label", new XmlComment(null, null, @" The string? ExampleClass.Label is a `string` - that you use for a label. - Note that there isn't a way to provide a ""cref"" to -each accessor, only to the property itself.", null, @"The `Label` property represents a label -for this instance.", false, null, null, null)); - cache.Add(@"P:Person.FirstName", new XmlComment(@"This tag will apply to the primary constructor parameter.", null, null, null, null, false, null, null, null)); - cache.Add(@"P:Person.LastName", new XmlComment(@"This tag will apply to the primary constructor parameter.", null, null, null, null, false, null, null, null)); - cache.Add(@"P:GenericParent.Id", new XmlComment(@"This property is a nullable value type.", null, null, null, null, false, null, null, null)); - cache.Add(@"P:GenericParent.Name", new XmlComment(@"This property is a nullable reference type.", null, null, null, null, false, null, null, null)); - 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); -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 -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 -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 -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 -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 -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 -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 -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) -method as a cref attribute. -The parameter and return value are both of an arbitrary type, -T", null, null, false, null, null, null)); - cache.Add(@"M:DisposableType.Dispose", new XmlComment(null, null, null, null, null, false, null, null, 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('+', '.'); - } - } - - [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(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(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(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 diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs index 448667404712..f4da876e2123 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs @@ -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.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs deleted file mode 100644 index 76e202554e42..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs +++ /dev/null @@ -1,475 +0,0 @@ -//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: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)); - - 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('+', '.'); - } - } - - [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(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(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(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 diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs index c5c44ed109bc..4a2aa376b299 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs @@ -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/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs deleted file mode 100644 index dbce7f0223bf..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs +++ /dev/null @@ -1,493 +0,0 @@ -//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: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: - 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.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)); - - 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('+', '.'); - } - } - - [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(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(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(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 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.received.cs similarity index 93% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.received.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.received.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.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs similarity index 92% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.received.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs index f4843a66a73f..5c3dcf382281 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.received.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs @@ -285,6 +285,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")] @@ -300,7 +327,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) { @@ -406,7 +433,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) @@ -415,7 +442,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) From a6c924790acfc023e8238e52253df460e03726da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 01:02:12 +0000 Subject: [PATCH 4/6] Update snapshots to reflect normalized documentation IDs Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- ...ceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs} | 0 ...dditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs} | 0 ...lTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs} | 0 ...romControllers#OpenApiXmlCommentSupport.generated.verified.cs} | 0 ...romMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs} | 0 ...mentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.received.cs => AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs} (100%) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.received.cs => AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs} (100%) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs => CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs} (100%) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs => OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs} (100%) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs => OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs} (100%) rename src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/{SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs => SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs} (100%) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs similarity index 100% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.received.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs similarity index 100% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.received.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs similarity index 100% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs similarity index 100% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.received.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs similarity index 100% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.received.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs similarity index 100% rename from src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.received.cs rename to src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs From 22760db281b232eb541096da22b173acb3b4dae1 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 14 Jul 2025 10:50:35 -0700 Subject: [PATCH 5/6] Update src/OpenApi/gen/XmlCommentGenerator.Parser.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/OpenApi/gen/XmlCommentGenerator.Parser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs index f86c9407733b..7cfd00ad7ae7 100644 --- a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs +++ b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs @@ -32,7 +32,7 @@ internal static string NormalizeDocId(string 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")) + if (docId.Contains("op_Implicit", StringComparison.Ordinal) || docId.Contains("op_Explicit", StringComparison.Ordinal)) { return docId; } From 01943b50b39ec6aaead04e3a9e9cdffaa750000d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:19:34 +0000 Subject: [PATCH 6/6] Fix missing using System directive for StringComparison Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- src/OpenApi/gen/XmlCommentGenerator.Parser.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs index 7cfd00ad7ae7..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; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy