Skip to content

Fix OpenAPI XML code gen #60977

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 2 commits into from
Mar 19, 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
22 changes: 22 additions & 0 deletions src/OpenApi/gen/Helpers/ISymbolExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,26 @@ public static IEnumerable<INamedTypeSymbol> GetBaseTypes(this ITypeSymbol? type)
current = current.BaseType;
}
}

public static bool IsAccessibleType(this ISymbol symbol)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helps us avoid generating XML comments for non-public types and internal properties in non-public types.

Note: this does mean that DTOs have to be defined as public types if they are defined in the same assembly as the API (see where this is called in the AssemnlyTypeSymbolVisitor above). We can consider changing this behavior to allow XML comments on internal types in the application assembly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems a bit overaggressive. I'm guessing we'll change this behavior a bit in the future.

Any reason to disallow the type entirely just because it has internal types?

Copy link
Member

@martincostello martincostello Mar 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just hit this error in preview 2:

C:\Coding\martincostello\alexa-london-travel-site\artifacts\obj\LondonTravel.Site\debug\Microsoft.AspNetCore.OpenApi.SourceGenerators\Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator\OpenApiXmlCommentSupport.generated.cs(207,125): error CS0122: 'CustomHttpHeadersMiddleware.Csp' is inaccessible due to its protection level

This relates to a private nested class in public class that has some /// comments on the internal const string members in it.

Would this change fix that scenario too?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more scenario for you - seems like it's trying to document things to do with the Regex source generator (example):

C:\Coding\martincostello\adventofcode\artifacts\obj\AdventOfCode.Site\debug\Microsoft.AspNetCore.OpenApi.SourceGenerators\Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator\OpenApiXmlCommentSupport.generated.cs(1589,94): error CS0234: The type or namespace name 'HexColor_1' does not exist in the namespace 'System.Text.RegularExpressions.Generated' (are you missing an assembly reference?)

If not catered for by this change, I can open a new issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends on whether the type in question is defined in or outside the assembly.

I believe these bits have landed in the nightly preview3 SDK if you want to take another bump to try it out.

Alternatively, I'd recommend filing an issue so we can at least capture this scenario in a test case even if it is already fixed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try out a nightly tomorrow and see if you think it should be fixed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed these two are catered for as of 10.0.100-preview.3.25169.19, but confirmed #61019 is still an issue and found two new ones 😅. Issues shortly...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think they're probably the same one issue: #61035

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe these bits have landed in the nightly preview3 SDK if you want to take another bump to try it out.

Alternatively, I'd recommend filing an issue so we can at least capture this scenario in a test case even if it is already fixed.

Found a different case where a private type causes a compiler error.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
// Check if the symbol itself is public
if (symbol.DeclaredAccessibility != Accessibility.Public)
{
return false;
}

// Check if all containing types are public as well
var containingType = symbol.ContainingType;
while (containingType != null)
{
if (containingType.DeclaredAccessibility != Accessibility.Public)
{
return false;
}
containingType = containingType.ContainingType;
}

return true;
}
}
21 changes: 16 additions & 5 deletions src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ internal static string GenerateXmlCommentSupportSource(string commentsFromXmlFil
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
// Suppress warnings about obsolete types and members
// in generated code
#pragma warning disable CS0612, CS0618
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppress obsoletion warnings in generated code. If someone happens to be using an obsolete type in their API, we don't want to resurface this warning in generated code where we call typeof(ObsolteType).


namespace System.Runtime.CompilerServices
{
Expand Down Expand Up @@ -487,7 +490,7 @@ internal static string EmitSourceGeneratedXmlComment(XmlComment comment)
else
{
codeWriter.Write("[");
for (int i = 0; i < comment.Examples.Count; i++)
for (var i = 0; i < comment.Examples.Count; i++)
{
var example = comment.Examples[i];
codeWriter.Write(FormatStringForCode(example));
Expand All @@ -506,13 +509,18 @@ internal static string EmitSourceGeneratedXmlComment(XmlComment comment)
else
{
codeWriter.Write("[");
for (int i = 0; i < comment.Parameters.Count; i++)
for (var i = 0; i < comment.Parameters.Count; i++)
{
var parameter = comment.Parameters[i];
var exampleLiteral = string.IsNullOrEmpty(parameter.Example)
? "null"
: FormatStringForCode(parameter.Example!);
codeWriter.Write($"new XmlParameterComment(@\"{parameter.Name}\", @\"{parameter.Description}\", {exampleLiteral}, {(parameter.Deprecated == true ? "true" : "false")})");
codeWriter.Write("new XmlParameterComment(");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and below we format strings in the emitted code so that we can properly escape quoted strings inside XML descriptions/summaries/etc.

codeWriter.Write(FormatStringForCode(parameter.Name) + ", ");
codeWriter.Write(FormatStringForCode(parameter.Description) + ", ");
codeWriter.Write(exampleLiteral + ", ");
codeWriter.Write(parameter.Deprecated == true ? "true" : "false");
codeWriter.Write(")");
if (i < comment.Parameters.Count - 1)
{
codeWriter.Write(", ");
Expand All @@ -528,10 +536,13 @@ internal static string EmitSourceGeneratedXmlComment(XmlComment comment)
else
{
codeWriter.Write("[");
for (int i = 0; i < comment.Responses.Count; i++)
for (var i = 0; i < comment.Responses.Count; i++)
{
var response = comment.Responses[i];
codeWriter.Write($"new XmlResponseComment(@\"{response.Code}\", @\"{response.Description}\", {(response.Example is null ? "null" : FormatStringForCode(response.Example))})");
codeWriter.Write("new XmlResponseComment(");
codeWriter.Write(FormatStringForCode(response.Code) + ", ");
codeWriter.Write(FormatStringForCode(response.Description) + ", ");
codeWriter.Write(response.Example is null ? "null)" : FormatStringForCode(response.Example) + ")");
if (i < comment.Responses.Count - 1)
{
codeWriter.Write(", ");
Expand Down
8 changes: 7 additions & 1 deletion src/OpenApi/gen/XmlCommentGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ public sealed partial class XmlCommentGenerator
var comments = new List<(MemberKey, XmlComment?)>();
foreach (var (name, value) in input.RawComments)
{
if (DocumentationCommentId.GetFirstSymbolForDeclarationId(name, compilation) is ISymbol symbol)
if (DocumentationCommentId.GetFirstSymbolForDeclarationId(name, compilation) is ISymbol symbol &&
// Only include symbols that are declared in the application assembly or are
// accessible from the application assembly.
(SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, input.Compilation.Assembly) || symbol.IsAccessibleType()) &&
// Skip static classes that are just containers for members with annotations
// since they cannot be instantiated.
symbol is not INamedTypeSymbol { TypeKind: TypeKind.Class, IsStatic: true })
{
var parsedComment = XmlComment.Parse(symbol, compilation, value, cancellationToken);
if (parsedComment is not null)
Expand Down
6 changes: 3 additions & 3 deletions src/OpenApi/gen/XmlComments/MemberKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public static MemberKey FromMethodSymbol(IMethodSymbol method, Compilation compi

returnType = actualReturnType.TypeKind == TypeKind.TypeParameter
? "typeof(object)"
: $"typeof({actualReturnType.ToDisplayString(_typeKeyFormat)})";
: $"typeof({ReplaceGenericArguments(actualReturnType.ToDisplayString(_typeKeyFormat))})";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure that all generics are emitted as open generics for method declarations. We already do this elsewhere in the MemberKey for the declaring type and the property type but not for methods.

}

// Handle extension methods by skipping the 'this' parameter
Expand All @@ -62,10 +62,10 @@ public static MemberKey FromMethodSymbol(IMethodSymbol method, Compilation compi
// For params arrays, use the array type
if (p.IsParams && p.Type is IArrayTypeSymbol arrayType)
{
return $"typeof({arrayType.ToDisplayString(_typeKeyFormat)})";
return $"typeof({ReplaceGenericArguments(arrayType.ToDisplayString(_typeKeyFormat))})";
}

return $"typeof({p.Type.ToDisplayString(_typeKeyFormat)})";
return $"typeof({ReplaceGenericArguments(p.Type.ToDisplayString(_typeKeyFormat))})";
})
.ToArray();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public async Task CanHandleXmlForSchemasInAdditionalTexts()
app.MapPost("/project-record", (ProjectRecord project) => { });
app.MapPost("/todo-with-description", (TodoWithDescription todo) => { });
app.MapPost("/type-with-examples", (TypeWithExamples typeWithExamples) => { });
app.MapPost("/external-method", ClassLibrary.Endpoints.ExternalMethod);

app.Run();
""";
Expand Down Expand Up @@ -61,6 +62,9 @@ public class ProjectBoard
/// </summary>
public class BoardItem
{
/// <summary>
/// The identifier of the board item. Defaults to "name".
/// </summary>
public string Name { get; set; }
}
}
Expand Down Expand Up @@ -120,6 +124,37 @@ public class TypeWithExamples
/// <example>https://example.com</example>
public Uri UriType { get; set; }
}

public class Holder<T>
{
/// <summary>
/// The value to hold.
/// </summary>
public T Value { get; set; }

public Holder(T value)
{
Value = value;
}
}

public static class Endpoints
{
/// <summary>
/// An external method.
/// </summary>
/// <param name="name">The name of the tester. Defaults to "Tester".</param>
public static void ExternalMethod(string name = "Tester") { }

/// <summary>
/// Creates a holder for the specified value.
/// </summary>
/// <typeparam name="T">The type of the value.</typeparam>
/// <param name="value">The value to hold.</param>
/// <returns>A holder for the specified value.</returns>
/// <example>{ value: 42 }</example>
public static Holder<T> CreateHolder<T>(T value) => new(value);
}
""";
var references = new Dictionary<string, List<string>>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ public class InheritAllButRemarks
/// In generic classes and methods, you'll often want to reference the
/// generic type, or the type parameter.
/// </remarks>
class GenericClass<T>
public class GenericClass<T>
{
// Fields and members.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
// Suppress warnings about obsolete types and members
// in generated code
#pragma warning disable CS0612, CS0618

namespace System.Runtime.CompilerServices
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
// Suppress warnings about obsolete types and members
// in generated code
#pragma warning disable CS0612, CS0618

namespace System.Runtime.CompilerServices
{
Expand Down Expand Up @@ -186,6 +189,7 @@ private static Dictionary<MemberKey, XmlComment> GenerateCacheEntries()
_cache.Add(new MemberKey(typeof(global::ClassLibrary.Project), MemberType.Type, null, null, []), new XmlComment(@"The project that contains Todo items.", null, null, null, null, false, null, null, null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.Project), MemberType.Method, ".ctor", typeof(void), [typeof(global::System.String), typeof(global::System.String)]), new XmlComment(@"The project that contains Todo items.", null, null, null, null, false, null, null, null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.ProjectBoard.BoardItem), MemberType.Type, null, null, []), new XmlComment(@"An item on the board.", null, null, null, null, false, null, null, null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.ProjectBoard.BoardItem), MemberType.Property, "Name", null, []), new XmlComment(@"The identifier of the board item. Defaults to ""name"".", null, null, null, null, false, null, null, null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.ProjectRecord), MemberType.Type, null, null, []), new XmlComment(@"The project that contains Todo items.", null, null, null, null, false, null, [new XmlParameterComment(@"Name", @"The name of the project.", null, false), new XmlParameterComment(@"Description", @"The description of the project.", null, false)], null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.ProjectRecord), MemberType.Method, ".ctor", typeof(void), [typeof(global::System.String), typeof(global::System.String)]), new XmlComment(@"The project that contains Todo items.", null, null, null, null, false, null, [new XmlParameterComment(@"Name", @"The name of the project.", null, false), new XmlParameterComment(@"Description", @"The description of the project.", null, false)], null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.ProjectRecord), MemberType.Property, "Name", null, []), new XmlComment(@"The name of the project.", null, null, null, null, false, null, null, null));
Expand All @@ -207,6 +211,9 @@ private static Dictionary<MemberKey, XmlComment> GenerateCacheEntries()
_cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "ByteType", null, []), new XmlComment(null, null, null, null, null, false, [@"255"], null, null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "DecimalType", null, []), new XmlComment(null, null, null, null, null, false, [@"3.14159265359"], null, null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "UriType", null, []), new XmlComment(null, null, null, null, null, false, [@"https://example.com"], null, null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.Holder<>), MemberType.Property, "Value", null, []), new XmlComment(@"The value to hold.", null, null, null, null, false, null, null, null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.Endpoints), MemberType.Method, "ExternalMethod", typeof(void), [typeof(global::System.String)]), new XmlComment(@"An external method.", null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the tester. Defaults to ""Tester"".", null, false)], null));
_cache.Add(new MemberKey(typeof(global::ClassLibrary.Endpoints), MemberType.Method, "CreateHolder", typeof(global::ClassLibrary.Holder<>), [typeof(object)]), new XmlComment(@"Creates a holder for the specified value.", null, null, @"A holder for the specified value.", null, false, [@"{ value: 42 }"], [new XmlParameterComment(@"value", @"The value to hold.", null, false)], null));


return _cache;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
// Suppress warnings about obsolete types and members
// in generated code
#pragma warning disable CS0612, CS0618

namespace System.Runtime.CompilerServices
{
Expand Down Expand Up @@ -268,7 +271,7 @@ Note that there isn't a way to provide a ""cref"" to
{
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(new MemberKey(typeof(global::ExampleClass), MemberType.Method, "AddAsync", typeof(global::System.Threading.Tasks.Task<global::System.Int32>), [typeof(global::System.Int32), typeof(global::System.Int32)]), new XmlComment(@"This method is an example of a method that
_cache.Add(new MemberKey(typeof(global::ExampleClass), MemberType.Method, "AddAsync", typeof(global::System.Threading.Tasks.Task<>), [typeof(global::System.Int32), typeof(global::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(new MemberKey(typeof(global::ExampleClass), MemberType.Method, "DoNothingAsync", typeof(global::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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
// Suppress warnings about obsolete types and members
// in generated code
#pragma warning disable CS0612, CS0618

namespace System.Runtime.CompilerServices
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
// Suppress warnings about obsolete types and members
// in generated code
#pragma warning disable CS0612, CS0618

namespace System.Runtime.CompilerServices
{
Expand Down Expand Up @@ -186,8 +189,8 @@ private static Dictionary<MemberKey, XmlComment> GenerateCacheEntries()
_cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get", typeof(global::System.String), []), new XmlComment(@"A summary of the action.", @"A description of the action.", null, null, null, false, null, null, null));
_cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get2", typeof(global::System.String), [typeof(global::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(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get3", typeof(global::System.String), [typeof(global::System.String)]), new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", @"Testy McTester", false)], null));
_cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get4", typeof(global::Microsoft.AspNetCore.Http.HttpResults.NotFound<global::System.String>), []), new XmlComment(null, null, null, null, null, false, null, null, [new XmlResponseComment(@"404", @"Indicates that the value was not found.", @"")]));
_cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get5", typeof(global::Microsoft.AspNetCore.Http.HttpResults.Results<global::Microsoft.AspNetCore.Http.HttpResults.NotFound<global::System.String>, global::Microsoft.AspNetCore.Http.HttpResults.Ok<global::System.String>, global::Microsoft.AspNetCore.Http.HttpResults.Created>), []), new XmlComment(null, null, null, null, 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(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get4", typeof(global::Microsoft.AspNetCore.Http.HttpResults.NotFound<>), []), new XmlComment(null, null, null, null, null, false, null, null, [new XmlResponseComment(@"404", @"Indicates that the value was not found.", @"")]));
_cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get5", typeof(global::Microsoft.AspNetCore.Http.HttpResults.Results<,,>), []), new XmlComment(null, null, null, null, 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(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Post6", typeof(global::Microsoft.AspNetCore.Http.IResult), [typeof(global::User)]), new XmlComment(@"Creates a new user.", null, @"Sample request:
POST /6
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
// Suppress warnings about obsolete types and members
// in generated code
#pragma warning disable CS0612, CS0618

namespace System.Runtime.CompilerServices
{
Expand Down
Loading