diff --git a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs index fad7e9318e45..6aa1e9aa35d8 100644 --- a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs +++ b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs @@ -98,7 +98,7 @@ public sealed partial class XmlCommentGenerator { var memberKey = symbol switch { - IMethodSymbol methodSymbol => MemberKey.FromMethodSymbol(methodSymbol), + IMethodSymbol methodSymbol => MemberKey.FromMethodSymbol(methodSymbol, input.Compilation), IPropertySymbol propertySymbol => MemberKey.FromPropertySymbol(propertySymbol), INamedTypeSymbol typeSymbol => MemberKey.FromTypeSymbol(typeSymbol), _ => null diff --git a/src/OpenApi/gen/XmlComments/MemberKey.cs b/src/OpenApi/gen/XmlComments/MemberKey.cs index c6cb3d0741ab..1b8c19741f8f 100644 --- a/src/OpenApi/gen/XmlComments/MemberKey.cs +++ b/src/OpenApi/gen/XmlComments/MemberKey.cs @@ -21,19 +21,70 @@ internal sealed record MemberKey( typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters); - public static MemberKey FromMethodSymbol(IMethodSymbol method) + public static MemberKey FromMethodSymbol(IMethodSymbol method, Compilation compilation) { + string returnType; + if (method.ReturnsVoid) + { + returnType = "typeof(void)"; + } + else + { + // Handle Task/ValueTask for async methods + var actualReturnType = method.ReturnType; + if (method.IsAsync && actualReturnType is INamedTypeSymbol namedType) + { + if (namedType.TypeArguments.Length > 0) + { + actualReturnType = namedType.TypeArguments[0]; + } + else + { + actualReturnType = compilation.GetSpecialType(SpecialType.System_Void); + } + } + + returnType = actualReturnType.TypeKind == TypeKind.TypeParameter + ? "typeof(object)" + : $"typeof({actualReturnType.ToDisplayString(_typeKeyFormat)})"; + } + + // Handle extension methods by skipping the 'this' parameter + var parameters = method.Parameters + .Where(p => !p.IsThis) + .Select(p => + { + if (p.Type.TypeKind == TypeKind.TypeParameter) + { + return "typeof(object)"; + } + + // For params arrays, use the array type + if (p.IsParams && p.Type is IArrayTypeSymbol arrayType) + { + return $"typeof({arrayType.ToDisplayString(_typeKeyFormat)})"; + } + + return $"typeof({p.Type.ToDisplayString(_typeKeyFormat)})"; + }) + .ToArray(); + + // For generic methods, use the containing type with generic parameters + var declaringType = method.ContainingType; + var typeDisplay = declaringType.ToDisplayString(_typeKeyFormat); + + // If the method is in a generic type, we need to handle the type parameters + if (declaringType.IsGenericType) + { + typeDisplay = ReplaceGenericArguments(typeDisplay); + } + return new MemberKey( - $"typeof({ReplaceGenericArguments(method.ContainingType.ToDisplayString(_typeKeyFormat))})", + $"typeof({typeDisplay})", MemberType.Method, - method.MetadataName, - method.ReturnType.TypeKind == TypeKind.TypeParameter - ? "typeof(object)" - : $"typeof({method.ReturnType.ToDisplayString(_typeKeyFormat)})", - [.. method.Parameters.Select(p => - p.Type.TypeKind == TypeKind.TypeParameter - ? "typeof(object)" - : $"typeof({p.Type.ToDisplayString(_typeKeyFormat)})")]); + method.MetadataName, // Use MetadataName to match runtime MethodInfo.Name + returnType, + parameters); } public static MemberKey FromPropertySymbol(IPropertySymbol property) diff --git a/src/OpenApi/gen/XmlComments/XmlComment.InheritDoc.cs b/src/OpenApi/gen/XmlComments/XmlComment.InheritDoc.cs index ee6d503449a0..12ce950f24b0 100644 --- a/src/OpenApi/gen/XmlComments/XmlComment.InheritDoc.cs +++ b/src/OpenApi/gen/XmlComments/XmlComment.InheritDoc.cs @@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml; /// -/// Source code in this class is derived from Roslyn's Xml documentation comment processing +/// Source code in this class is derived from Roslyn's XML documentation comment processing /// to support the full resolution of tags. /// For the original code, see https://github.com/dotnet/roslyn/blob/ef1d7fe925c94e96a93e4c9af50983e0f675a9fd/src/Workspaces/Core/Portable/Shared/Extensions/ISymbolExtensions.cs. /// diff --git a/src/OpenApi/gen/XmlComments/XmlComment.cs b/src/OpenApi/gen/XmlComments/XmlComment.cs index e1ece052e315..be47fa913d82 100644 --- a/src/OpenApi/gen/XmlComments/XmlComment.cs +++ b/src/OpenApi/gen/XmlComments/XmlComment.cs @@ -191,7 +191,7 @@ private static void ResolveCrefLink(Compilation compilation, XNode node, string var symbol = DocumentationCommentId.GetFirstSymbolForDeclarationId(cref, compilation); if (symbol is not null) { - var type = symbol.ToDisplayString(); + var type = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); item.ReplaceWith(new XText(type)); } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs index f23fed7018c9..9f066e5cab77 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.OpenApi.Build.Tests; public class GenerateAdditionalXmlFilesForOpenApiTests { - private static readonly TimeSpan _defaultProcessTimeout = TimeSpan.FromSeconds(45); + private static readonly TimeSpan _defaultProcessTimeout = TimeSpan.FromSeconds(120); [Fact] public void VerifiesTargetGeneratesXmlFiles() diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/AdditionalTextsTests.Schemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/AdditionalTextsTests.Schemas.cs new file mode 100644 index 000000000000..623b4a8b3694 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/AdditionalTextsTests.Schemas.cs @@ -0,0 +1,205 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests; + +[UsesVerify] +public partial class AdditionalTextsTests +{ + [Fact] + public async Task CanHandleXmlForSchemasInAdditionalTexts() + { + var source = """ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ClassLibrary; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.MapPost("/todo", (Todo todo) => { }); +app.MapPost("/project", (Project project) => { }); +app.MapPost("/board", (ProjectBoard.BoardItem boardItem) => { }); +app.MapPost("/project-record", (ProjectRecord project) => { }); +app.MapPost("/todo-with-description", (TodoWithDescription todo) => { }); +app.MapPost("/type-with-examples", (TypeWithExamples typeWithExamples) => { }); + +app.Run(); +"""; + + var librarySource = """ +using System; + +namespace ClassLibrary; + +/// +/// This is a todo item. +/// +public class Todo +{ + public int Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } +} + +/// +/// The project that contains items. +/// +public record Project(string Name, string Description); + +public class ProjectBoard +{ + /// + /// An item on the board. + /// + public class BoardItem + { + public string Name { get; set; } + } +} + +/// +/// The project that contains items. +/// +/// The name of the project. +/// The description of the project. +public record ProjectRecord(string Name, string Description); + +public class TodoWithDescription +{ + /// + /// The identifier of the todo. + /// + public int Id { get; set; } + /// + /// The name of the todo. + /// + public string Name { get; set; } + /// + /// A description of the todo. + /// + /// Another description of the todo. + public string Description { get; set; } +} + +public class TypeWithExamples +{ + /// true + public bool BooleanType { get; set; } + /// 42 + public int IntegerType { get; set; } + /// 1234567890123456789 + public long LongType { get; set; } + /// 3.14 + public double DoubleType { get; set; } + /// 3.14 + public float FloatType { get; set; } + /// 2022-01-01T00:00:00Z + public DateTime DateTimeType { get; set; } + /// 2022-01-01 + public DateOnly DateOnlyType { get; set; } + /// Hello, World! + public string StringType { get; set; } + /// 2d8f1eac-b5c6-4e29-8c62-4d9d75ef3d3d + public Guid GuidType { get; set; } + /// 12:30:45 + public TimeOnly TimeOnlyType { get; set; } + /// P3DT4H5M + public TimeSpan TimeSpanType { get; set; } + /// 255 + public byte ByteType { get; set; } + /// 3.14159265359 + public decimal DecimalType { get; set; } + /// https://example.com + public Uri UriType { get; set; } +} +"""; + var references = new Dictionary> + { + { "ClassLibrary", [librarySource] } + }; + + 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["/todo"].Operations[OperationType.Post]; + var todo = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("This is a todo item.", todo.Description); + + path = document.Paths["/project"].Operations[OperationType.Post]; + var project = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("The project that contains Todo items.", project.Description); + + path = document.Paths["/board"].Operations[OperationType.Post]; + var board = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("An item on the board.", board.Description); + + path = document.Paths["/project-record"].Operations[OperationType.Post]; + project = path.RequestBody.Content["application/json"].Schema; + + Assert.Equal("The name of the project.", project.Properties["name"].Description); + Assert.Equal("The description of the project.", project.Properties["description"].Description); + + path = document.Paths["/todo-with-description"].Operations[OperationType.Post]; + todo = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("The identifier of the todo.", todo.Properties["id"].Description); + Assert.Equal("The name of the todo.", todo.Properties["name"].Description); + Assert.Equal("Another description of the todo.", todo.Properties["description"].Description); + + path = document.Paths["/type-with-examples"].Operations[OperationType.Post]; + var typeWithExamples = path.RequestBody.Content["application/json"].Schema; + + var booleanTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["booleanType"].Example); + Assert.True(booleanTypeExample.GetValue()); + + var integerTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["integerType"].Example); + Assert.Equal(42, integerTypeExample.GetValue()); + + var longTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["longType"].Example); + Assert.Equal(1234567890123456789, longTypeExample.GetValue()); + + // Broken due to https://github.com/microsoft/OpenAPI.NET/issues/2137 + // var doubleTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["doubleType"].Example); + // Assert.Equal("3.14", doubleTypeExample.GetValue()); + + // var floatTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["floatType"].Example); + // Assert.Equal(3.14f, floatTypeExample.GetValue()); + + // var dateTimeTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["dateTimeType"].Example); + // Assert.Equal(DateTime.Parse("2022-01-01T00:00:00Z", CultureInfo.InvariantCulture), dateTimeTypeExample.GetValue()); + + // var dateOnlyTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["dateOnlyType"].Example); + // Assert.Equal(DateOnly.Parse("2022-01-01", CultureInfo.InvariantCulture), dateOnlyTypeExample.GetValue()); + + var stringTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["stringType"].Example); + Assert.Equal("Hello, World!", stringTypeExample.GetValue()); + + var guidTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["guidType"].Example); + Assert.Equal("2d8f1eac-b5c6-4e29-8c62-4d9d75ef3d3d", guidTypeExample.GetValue()); + + var byteTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["byteType"].Example); + Assert.Equal(255, byteTypeExample.GetValue()); + + // Broken due to https://github.com/microsoft/OpenAPI.NET/issues/2137 + // var timeOnlyTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["timeOnlyType"].Example); + // Assert.Equal(TimeOnly.Parse("12:30:45", CultureInfo.InvariantCulture), timeOnlyTypeExample.GetValue()); + + // var timeSpanTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["timeSpanType"].Example); + // Assert.Equal(TimeSpan.Parse("P3DT4H5M", CultureInfo.InvariantCulture), timeSpanTypeExample.GetValue()); + + // var decimalTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["decimalType"].Example); + // Assert.Equal(3.14159265359m, decimalTypeExample.GetValue()); + + var uriTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["uriType"].Example); + Assert.Equal("https://example.com", uriTypeExample.GetValue()); + }); + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/CompletenessTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/CompletenessTests.cs index b0baa35b59dd..8c81ba948ac7 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/CompletenessTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/CompletenessTests.cs @@ -17,6 +17,7 @@ public async Task SupportsAllXmlTagsOnSchemas() { var source = """ using System; +using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -180,6 +181,38 @@ public static int Add(int left, int right) return left + right; } + + /// + /// This method is an example of a method that + /// returns an awaitable item. + /// + public static Task AddAsync(int left, int right) + { + return Task.FromResult(Add(left, right)); + } + + /// + /// This method is an example of a method that + /// returns a Task which should map to a void return type. + /// + public static Task DoNothingAsync() + { + return Task.CompletedTask; + } + + /// + /// This method is an example of a method that consumes + /// an params array. + /// + public static int AddNumbers(params int[] numbers) + { + var sum = 0; + foreach (var number in numbers) + { + sum += number; + } + return sum; + } } /// diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs index c6fd1a38becc..c2d09bdb5971 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs @@ -29,6 +29,9 @@ public static partial class SnapshotTestHelper .WithFeatures([new KeyValuePair("InterceptorsNamespaces", "Microsoft.AspNetCore.OpenApi.Generated")]); public static Task Verify(string source, IIncrementalGenerator generator, out Compilation compilation) + => Verify(source, generator, [], out compilation, out _); + + public static Task Verify(string source, IIncrementalGenerator generator, Dictionary> classLibrarySources, out Compilation compilation, out List generatedAssemblies) { var references = AppDomain.CurrentDomain.GetAssemblies() .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) @@ -47,12 +50,49 @@ public static Task Verify(string source, IIncrementalGenerator generator, out Co MetadataReference.CreateFromFile(typeof(System.Text.Json.Nodes.JsonArray).Assembly.Location), MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), MetadataReference.CreateFromFile(typeof(Uri).Assembly.Location), - ]); + ]) + .ToList(); + + var additionalTexts = new List(); + generatedAssemblies = []; + + foreach (var classLibrary in classLibrarySources) + { + var classLibraryCompilation = CSharpCompilation.Create(classLibrary.Key, + classLibrary.Value.Select((source, index) => CSharpSyntaxTree.ParseText(source, options: ParseOptions, path: $"{classLibrary.Key}-{index}.cs")), + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var ms = new MemoryStream(); + using var xmlStream = new MemoryStream(); + var emitResult = classLibraryCompilation.Emit(ms, xmlDocumentationStream: xmlStream); + + if (!emitResult.Success) + { + throw new InvalidOperationException($"Failed to compile class library {classLibrary.Key}: {string.Join(Environment.NewLine, emitResult.Diagnostics)}"); + } + + ms.Seek(0, SeekOrigin.Begin); + xmlStream.Seek(0, SeekOrigin.Begin); + + var assembly = ms.ToArray(); + generatedAssemblies.Add(assembly); + references.Add(MetadataReference.CreateFromImage(assembly)); + + var xmlText = Encoding.UTF8.GetString(xmlStream.ToArray()); + additionalTexts.Add(new TestAdditionalText($"{classLibrary.Key}.xml", xmlText)); + } + var inputCompilation = CSharpCompilation.Create("OpenApiXmlCommentGeneratorSample", [CSharpSyntaxTree.ParseText(source, options: ParseOptions, path: "Program.cs")], references, new CSharpCompilationOptions(OutputKind.ConsoleApplication)); - var driver = CSharpGeneratorDriver.Create(generators: [generator.AsSourceGenerator()], parseOptions: ParseOptions); + + var driver = CSharpGeneratorDriver.Create( + generators: [generator.AsSourceGenerator()], + additionalTexts: additionalTexts, + parseOptions: ParseOptions); + return Verifier .Verify(driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out compilation, out var diagnostics)) .ScrubLinesWithReplace(line => InterceptsLocationRegex().Replace(line, "[InterceptsLocation]")) @@ -62,6 +102,9 @@ public static Task Verify(string source, IIncrementalGenerator generator, out Co } public static async Task VerifyOpenApi(Compilation compilation, Action verifyFunc) + => await VerifyOpenApi(compilation, [], verifyFunc); + + public static async Task VerifyOpenApi(Compilation compilation, List generatedAssemblies, Action verifyFunc) { var assemblyName = compilation.AssemblyName; var symbolsName = Path.ChangeExtension(assemblyName, "pdb"); @@ -100,6 +143,10 @@ public static async Task VerifyOpenApi(Compilation compilation, Action path; + + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return SourceText.From(text, Encoding.UTF8); + } + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..69425f1352d9 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,380 @@ +//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 + +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.Linq; + using System.Reflection; + 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.Models; + using Microsoft.OpenApi.Models.References; + using Microsoft.OpenApi.Any; + + [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 sealed record MemberKey( + Type? DeclaringType, + MemberType MemberKind, + string? Name, + Type? ReturnType, + Type[]? Parameters) : IEquatable + { + public bool Equals(MemberKey? other) + { + if (other is null) return false; + + // Check member kind + if (MemberKind != other.MemberKind) return false; + + // Check declaring type, handling generic types + if (!TypesEqual(DeclaringType, other.DeclaringType)) return false; + + // Check name + if (Name != other.Name) return false; + + // For methods, check return type and parameters + if (MemberKind == MemberType.Method) + { + if (!TypesEqual(ReturnType, other.ReturnType)) return false; + if (Parameters is null || other.Parameters is null) return false; + if (Parameters.Length != other.Parameters.Length) return false; + + for (int i = 0; i < Parameters.Length; i++) + { + if (!TypesEqual(Parameters[i], other.Parameters[i])) return false; + } + } + + return true; + } + + private static bool TypesEqual(Type? type1, Type? type2) + { + if (type1 == type2) return true; + if (type1 == null || type2 == null) return false; + + if (type1.IsGenericType && type2.IsGenericType) + { + return type1.GetGenericTypeDefinition() == type2.GetGenericTypeDefinition(); + } + + return type1 == type2; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(GetTypeHashCode(DeclaringType)); + hash.Add(MemberKind); + hash.Add(Name); + + if (MemberKind == MemberType.Method) + { + hash.Add(GetTypeHashCode(ReturnType)); + if (Parameters is not null) + { + foreach (var param in Parameters) + { + hash.Add(GetTypeHashCode(param)); + } + } + } + + return hash.ToHashCode(); + } + + private static int GetTypeHashCode(Type? type) + { + if (type == null) return 0; + return type.IsGenericType ? type.GetGenericTypeDefinition().GetHashCode() : type.GetHashCode(); + } + + public static MemberKey FromMethodInfo(MethodInfo method) + { + return new MemberKey( + method.DeclaringType, + MemberType.Method, + method.Name, + method.ReturnType.IsGenericParameter ? typeof(object) : method.ReturnType, + method.GetParameters().Select(p => p.ParameterType.IsGenericParameter ? typeof(object) : p.ParameterType).ToArray()); + } + + public static MemberKey FromPropertyInfo(PropertyInfo property) + { + return new MemberKey( + property.DeclaringType, + MemberType.Property, + property.Name, + null, + null); + } + + public static MemberKey FromTypeInfo(Type type) + { + return new MemberKey( + type, + MemberType.Type, + null, + null, + null); + } + } + + file enum MemberType + { + Type, + Property, + Method + } + + [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(new MemberKey(typeof(global::ClassLibrary.Todo), MemberType.Type, null, null, []), new XmlComment(@"This is a todo item.", null, null, null, null, false, null, null, null)); + _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.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)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.ProjectRecord), MemberType.Property, "Description", null, []), new XmlComment(@"The description of the project.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TodoWithDescription), MemberType.Property, "Id", null, []), new XmlComment(@"The identifier of the todo.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TodoWithDescription), MemberType.Property, "Name", null, []), new XmlComment(null, null, null, null, @"The name of the todo.", false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TodoWithDescription), MemberType.Property, "Description", null, []), new XmlComment(@"A description of the todo.", null, null, null, @"Another description of the todo.", false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "BooleanType", null, []), new XmlComment(null, null, null, null, null, false, [@"true"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "IntegerType", null, []), new XmlComment(null, null, null, null, null, false, [@"42"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "LongType", null, []), new XmlComment(null, null, null, null, null, false, [@"1234567890123456789"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "DoubleType", null, []), new XmlComment(null, null, null, null, null, false, [@"3.14"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "FloatType", null, []), new XmlComment(null, null, null, null, null, false, [@"3.14"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "DateTimeType", null, []), new XmlComment(null, null, null, null, null, false, [@"2022-01-01T00:00:00Z"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "DateOnlyType", null, []), new XmlComment(null, null, null, null, null, false, [@"2022-01-01"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "StringType", null, []), new XmlComment(null, null, null, null, null, false, [@"Hello, World!"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "GuidType", null, []), new XmlComment(null, null, null, null, null, false, [@"2d8f1eac-b5c6-4e29-8c62-4d9d75ef3d3d"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "TimeOnlyType", null, []), new XmlComment(null, null, null, null, null, false, [@"12:30:45"], null, null)); + _cache.Add(new MemberKey(typeof(global::ClassLibrary.TypeWithExamples), MemberType.Property, "TimeSpanType", null, []), new XmlComment(null, null, null, null, null, false, [@"P3DT4H5M"], null, null)); + _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)); + + + return _cache; + } + + internal static bool TryGetXmlComment(Type? type, MethodInfo? methodInfo, [NotNullWhen(true)] out XmlComment? xmlComment) + { + if (methodInfo is null) + { + return Cache.TryGetValue(new MemberKey(type, MemberType.Property, null, null, null), out xmlComment); + } + + return Cache.TryGetValue(MemberKey.FromMethodInfo(methodInfo), out xmlComment); + } + + internal static bool TryGetXmlComment(Type? type, string? memberName, [NotNullWhen(true)] out XmlComment? xmlComment) + { + return Cache.TryGetValue(new MemberKey(type, memberName is null ? MemberType.Type : MemberType.Property, memberName, null, null), out xmlComment); + } + } + + [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.TryGetXmlComment(methodInfo.DeclaringType, methodInfo, 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 = operationParameter is OpenApiParameterReference reference + ? reference.Target + : (OpenApiParameter)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) + { + foreach (var mediaType in requestBody.Content.Values) + { + mediaType.Example = jsonString.Parse(); + } + } + } + } + } + } + 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; + } + } + + [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.TryGetXmlComment(propertyInfo.DeclaringType, propertyInfo.Name, out var propertyComment)) + { + schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + } + if (XmlCommentCache.TryGetXmlComment(context.JsonTypeInfo.Type, (string?)null, 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/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs index aa9137dd68b8..e2150a6ea5be 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs @@ -256,7 +256,7 @@ 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(new MemberKey(typeof(global::ParamsAndParamRefs), MemberType.Type, null, null, []), new XmlComment(@"This shows examples of typeparamref and typeparam tags", null, null, null, null, false, null, null, null)); - _cache.Add(new MemberKey(typeof(global::ExampleClass), MemberType.Property, "Label", null, []), new XmlComment(null, null, @" The ExampleClass.Label is a + _cache.Add(new MemberKey(typeof(global::ExampleClass), MemberType.Property, "Label", null, []), new XmlComment(null, null, @" The string? ExampleClass.Label is a 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 @@ -268,6 +268,12 @@ 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), [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)); + _cache.Add(new MemberKey(typeof(global::ExampleClass), MemberType.Method, "AddNumbers", typeof(global::System.Int32), [typeof(global::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(new MemberKey(typeof(global::ITestInterface), MemberType.Method, "Method", typeof(global::System.Int32), [typeof(global::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)); @@ -276,7 +282,7 @@ that implement this interface when the _cache.Add(new MemberKey(typeof(global::InheritAllButRemarks), MemberType.Method, "MyParentMethod", typeof(global::System.Boolean), [typeof(global::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(new MemberKey(typeof(global::InheritAllButRemarks), MemberType.Method, "MyChildMethod", typeof(global::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(new MemberKey(typeof(global::ParamsAndParamRefs), MemberType.Method, "GetGenericValue", typeof(object), [typeof(object)]), new XmlComment(@"The GetGenericValue method.", null, @"This sample shows how to specify the ParamsAndParamRefs.GetGenericValue<T>(T) + _cache.Add(new MemberKey(typeof(global::ParamsAndParamRefs), MemberType.Method, "GetGenericValue", typeof(object), [typeof(object)]), 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));