Skip to content

Unify handling of documentation IDs in OpenAPI XML comment generator #62692

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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('+', '.');
}

/// <summary>
/// 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.
/// </summary>
/// <param name="docId">The documentation comment ID to normalize.</param>
/// <returns>The normalized documentation comment ID.</returns>
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}}
Expand All @@ -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)
{
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
35 changes: 31 additions & 4 deletions src/OpenApi/gen/XmlCommentGenerator.Parser.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,6 +15,32 @@ namespace Microsoft.AspNetCore.OpenApi.SourceGenerators;

public sealed partial class XmlCommentGenerator
{
/// <summary>
/// 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.
/// </summary>
/// <param name="docId">The documentation comment ID to normalize.</param>
/// <returns>The normalized documentation comment ID.</returns>
internal static string NormalizeDocId(string docId)
{
// Find the tilde character that indicates the return type suffix
var tildeIndex = docId.IndexOf('~');
if (tildeIndex == -1)
{
// No return type suffix, return as-is
return docId;
}

// Check if this is a conversion operator (op_Implicit or op_Explicit)
// For these operators, we need to keep the return type suffix
if (docId.Contains("op_Implicit", StringComparison.Ordinal) || docId.Contains("op_Explicit", StringComparison.Ordinal))
{
return docId;
}

// For ordinary methods, strip the return type suffix
return docId.Substring(0, tildeIndex);
}
internal static List<(string, string)> ParseXmlFile(AdditionalText additionalText, CancellationToken cancellationToken)
{
var text = additionalText.GetText(cancellationToken);
Expand All @@ -37,7 +64,7 @@ public sealed partial class XmlCommentGenerator
var name = member.Attribute(DocumentationCommentXmlNames.NameAttributeName)?.Value;
if (name is not null)
{
comments.Add((name, member.ToString()));
comments.Add((NormalizeDocId(name), member.ToString()));
}
}
return comments;
Expand All @@ -54,7 +81,7 @@ public sealed partial class XmlCommentGenerator
if (DocumentationCommentId.CreateDeclarationId(type) is string name &&
type.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
{
comments.Add((name, xml));
comments.Add((NormalizeDocId(name), xml));
}
}
var properties = visitor.GetPublicProperties();
Expand All @@ -63,7 +90,7 @@ public sealed partial class XmlCommentGenerator
if (DocumentationCommentId.CreateDeclarationId(property) is string name &&
property.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
{
comments.Add((name, xml));
comments.Add((NormalizeDocId(name), xml));
}
}
var methods = visitor.GetPublicMethods();
Expand All @@ -77,7 +104,7 @@ public sealed partial class XmlCommentGenerator
if (DocumentationCommentId.CreateDeclarationId(method) is string name &&
method.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
{
comments.Add((name, xml));
comments.Add((NormalizeDocId(name), xml));
}
}
return comments;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// This method should have its XML comment merged properly.
/// </summary>
/// <param name="id">The identifier for the test.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public static Task TestMethod(int id)
{
return Task.CompletedTask;
}
}
""";

var references = new Dictionary<string, List<string>>
{
{ "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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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('+', '.');
}

/// <summary>
/// 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.
/// </summary>
/// <param name="docId">The documentation comment ID to normalize.</param>
/// <returns>The normalized documentation comment ID.</returns>
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")]
Expand All @@ -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)
{
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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('+', '.');
}

/// <summary>
/// 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.
/// </summary>
/// <param name="docId">The documentation comment ID to normalize.</param>
/// <returns>The normalized documentation comment ID.</returns>
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")]
Expand All @@ -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)
{
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading