diff --git a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs index 2dd60e993cc9..400f3b768f8e 100644 --- a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs +++ b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs @@ -57,6 +57,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -153,30 +154,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -389,7 +366,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -449,10 +430,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -499,6 +484,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs index ef26c9590ec3..19fa164e4230 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs @@ -124,7 +124,7 @@ public class TestController : ControllerBase { /// The id of the user. [HttpGet("{userId}")] - public string Get() + public string Get(int userId) { return "Hello, World!"; } @@ -141,4 +141,121 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document => Assert.Equal("The id of the user.", path.Parameters[0].Description); }); } + + [Fact] + public async Task SupportsParametersWithCustomNamesFromControllers() + { + var source = +""" +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(); + +builder.Services + .AddControllers() + .AddApplicationPart(typeof(TestController).Assembly); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); + +[ApiController] +[Route("[controller]")] +public class TestController : ControllerBase +{ + /// The id of the user. + [HttpGet("{user_id}")] + public string Get([FromRoute(Name = "user_id")] int userId) + { + return "Hello, World!"; + } + + [HttpGet] + public IEnumerable Search(Query query) + { + return []; + } +} + +public partial class Program {} + +public record Person(int Id, string Name); + +public class Query +{ + /// + /// The full name of the person. + /// + [FromQuery(Name = "full_name")] + public string? Name { get; init; } +} +"""; + var generator = new XmlCommentGenerator(); + await SnapshotTestHelper.Verify(source, generator, out var compilation); + await SnapshotTestHelper.VerifyOpenApi(compilation, document => + { + var getOperation = document.Paths["/Test/{user_id}"].Operations[HttpMethod.Get]; + Assert.Equal("user_id", getOperation.Parameters[0].Name); + Assert.Equal("The id of the user.", getOperation.Parameters[0].Description); + + var searchOperation = document.Paths["/Test"].Operations[HttpMethod.Get]; + Assert.Equal("full_name", searchOperation.Parameters[0].Name); + Assert.Equal("The full name of the person.", searchOperation.Parameters[0].Description); + }); + } + + [Fact] + public async Task ShouldNotApplyCancellationTokenDocumentationToRequestBody() + { + var source = +""" +using System.Collections.Generic; +using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(); + +builder.Services + .AddControllers() + .AddApplicationPart(typeof(TestController).Assembly); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); + +[ApiController] +[Route("[controller]")] +public class TestController : ControllerBase +{ + /// The cancellation token. + [HttpGet] + public ActionResult Create(Person person, CancellationToken cancellationToken) + { + return Created(); + } +} + +public partial class Program {} + +public record Person(int Id, string Name); +"""; + var generator = new XmlCommentGenerator(); + await SnapshotTestHelper.Verify(source, generator, out var compilation); + await SnapshotTestHelper.VerifyOpenApi(compilation, document => + { + var getOperation = document.Paths["/Test"].Operations[HttpMethod.Get]; + Assert.Null(getOperation.RequestBody.Description); + }); + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs index c235ae4c3688..c79c73b66514 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -135,30 +136,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -371,7 +348,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -431,10 +412,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -481,6 +466,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs index 3affbf940068..b8a1172e40b9 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AdditionalTextsTests.CanHandleXmlForSchemasInAdditionalTexts#OpenApiXmlCommentSupport.generated.verified.cs @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -164,30 +165,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -400,7 +377,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -460,10 +441,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -510,6 +495,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) 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 7bb176e85405..5319cc43fa94 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 @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -262,30 +263,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -498,7 +475,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -558,10 +539,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -608,6 +593,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyCancellationTokenDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyCancellationTokenDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..1fa849e91a7c --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.ShouldNotApplyCancellationTokenDocumentationToRequestBody#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,650 @@ +//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.AspNetCore.Mvc.ModelBinding; + using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + 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.Create(Person,System.Threading.CancellationToken)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"cancellationToken", @"The cancellation token.", null, false)], null)); + + return cache; + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class DocumentationCommentIdHelper + { + /// + /// Generates a documentation comment ID for a type. + /// Example: T:Namespace.Outer+Inner`1 becomes T:Namespace.Outer.Inner`1 + /// + public static string CreateDocumentationId(this Type type) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + return "T:" + GetTypeDocId(type, includeGenericArguments: false, omitGenericArity: false); + } + + /// + /// Generates a documentation comment ID for a property. + /// Example: P:Namespace.ContainingType.PropertyName or for an indexer P:Namespace.ContainingType.Item(System.Int32) + /// + public static string CreateDocumentationId(this PropertyInfo property) + { + if (property == null) + { + throw new ArgumentNullException(nameof(property)); + } + + var sb = new StringBuilder(); + sb.Append("P:"); + + if (property.DeclaringType != null) + { + sb.Append(GetTypeDocId(property.DeclaringType, includeGenericArguments: false, omitGenericArity: false)); + } + + sb.Append('.'); + sb.Append(property.Name); + + // For indexers, include the parameter list. + var indexParams = property.GetIndexParameters(); + if (indexParams.Length > 0) + { + sb.Append('('); + for (int i = 0; i < indexParams.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + + sb.Append(GetTypeDocId(indexParams[i].ParameterType, includeGenericArguments: true, omitGenericArity: false)); + } + sb.Append(')'); + } + + return sb.ToString(); + } + + /// + /// Generates a documentation comment ID for a method (or constructor). + /// For example: + /// M:Namespace.ContainingType.MethodName(ParamType1,ParamType2)~ReturnType + /// M:Namespace.ContainingType.#ctor(ParamType) + /// + public static string CreateDocumentationId(this MethodInfo method) + { + if (method == null) + { + throw new ArgumentNullException(nameof(method)); + } + + var sb = new StringBuilder(); + sb.Append("M:"); + + // Append the fully qualified name of the declaring type. + if (method.DeclaringType != null) + { + sb.Append(GetTypeDocId(method.DeclaringType, includeGenericArguments: false, omitGenericArity: false)); + } + + sb.Append('.'); + + // Append the method name, handling constructors specially. + if (method.IsConstructor) + { + sb.Append(method.IsStatic ? "#cctor" : "#ctor"); + } + else + { + sb.Append(method.Name); + if (method.IsGenericMethod) + { + sb.Append("``"); + sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", method.GetGenericArguments().Length); + } + } + + // Append the parameter list, if any. + var parameters = method.GetParameters(); + if (parameters.Length > 0) + { + sb.Append('('); + for (int i = 0; i < parameters.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + + // Omit the generic arity for the parameter type. + sb.Append(GetTypeDocId(parameters[i].ParameterType, includeGenericArguments: true, omitGenericArity: true)); + } + sb.Append(')'); + } + + // Append the return type after a '~' (if the method returns a value). + if (method.ReturnType != typeof(void)) + { + sb.Append('~'); + // Omit the generic arity for the return type. + sb.Append(GetTypeDocId(method.ReturnType, includeGenericArguments: true, omitGenericArity: true)); + } + + return sb.ToString(); + } + + /// + /// Generates a documentation ID string for a type. + /// This method handles nested types (replacing '+' with '.'), + /// generic types, arrays, pointers, by-ref types, and generic parameters. + /// The flag controls whether + /// constructed generic type arguments are emitted, while + /// controls whether the generic arity marker (e.g. "`1") is appended. + /// + private static string GetTypeDocId(Type type, bool includeGenericArguments, bool omitGenericArity) + { + if (type.IsGenericParameter) + { + // Use `` for method-level generic parameters and ` for type-level. + if (type.DeclaringMethod != null) + { + return "``" + type.GenericParameterPosition; + } + else if (type.DeclaringType != null) + { + return "`" + type.GenericParameterPosition; + } + else + { + return type.Name; + } + } + + if (type.IsGenericType) + { + Type genericDef = type.GetGenericTypeDefinition(); + string fullName = genericDef.FullName ?? genericDef.Name; + + var sb = new StringBuilder(fullName.Length); + + // Replace '+' with '.' for nested types + for (var i = 0; i < fullName.Length; i++) + { + char c = fullName[i]; + if (c == '+') + { + sb.Append('.'); + } + else if (c == '`') + { + break; + } + else + { + sb.Append(c); + } + } + + if (!omitGenericArity) + { + int arity = genericDef.GetGenericArguments().Length; + sb.Append('`'); + sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", arity); + } + + if (includeGenericArguments && !type.IsGenericTypeDefinition) + { + var typeArgs = type.GetGenericArguments(); + sb.Append('{'); + + for (int i = 0; i < typeArgs.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + + sb.Append(GetTypeDocId(typeArgs[i], includeGenericArguments, omitGenericArity)); + } + + sb.Append('}'); + } + + return sb.ToString(); + } + + // For non-generic types, use FullName (if available) and replace nested type separators. + return (type.FullName ?? type.Name).Replace('+', '.'); + } + + /// + /// Normalizes a documentation comment ID to match the compiler-style format. + /// Strips the return type suffix for ordinary methods but retains it for conversion operators. + /// + /// The documentation comment ID to normalize. + /// The normalized documentation comment ID. + public static string NormalizeDocId(string docId) + { + // Find the tilde character that indicates the return type suffix + var tildeIndex = docId.IndexOf('~'); + if (tildeIndex == -1) + { + // No return type suffix, return as-is + return docId; + } + + // Check if this is a conversion operator (op_Implicit or op_Explicit) + // For these operators, we need to keep the return type suffix + if (docId.Contains("op_Implicit") || docId.Contains("op_Explicit")) + { + return docId; + } + + // For ordinary methods, strip the return type suffix + return docId.Substring(0, tildeIndex); + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor + ? controllerActionDescriptor.MethodInfo + : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + + if (methodInfo is null) + { + return Task.CompletedTask; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment)) + { + if (methodComment.Summary is { } summary) + { + operation.Summary = summary; + } + if (methodComment.Description is { } description) + { + operation.Description = description; + } + if (methodComment.Remarks is { } remarks) + { + operation.Description = remarks; + } + if (methodComment.Parameters is { Count: > 0}) + { + foreach (var parameterComment in methodComment.Parameters) + { + var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name); + if (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); + 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; + } + } + } + } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata is not null + && metadata.MetadataKind == ModelMetadataKind.Property + && metadata.ContainerType is { } containerType + && metadata.PropertyName is { } propertyName) + { + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) + { + var parameter = GetOperationParameter(operation, propertyInfo); + var description = propertyComment.Summary; + if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) + { + description = $"{description}\n{propertyComment.Value}"; + } + else if (string.IsNullOrEmpty(description)) + { + description = propertyComment.Value; + } + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = description; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + var content = operation.RequestBody.Content?.Values; + if (content is null) + { + continue; + } + var parsedExample = jsonString.Parse(); + foreach (var mediaType in content) + { + mediaType.Example = parsedExample; + } + } + } + continue; + } + var targetOperationParameter = UnwrapOpenApiParameter(parameter); + if (targetOperationParameter is not null) + { + targetOperationParameter.Description = description; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } + + return Task.CompletedTask; + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + + 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) + { + // Apply comments from the type + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment)) + { + schema.Description = typeComment.Summary; + if (typeComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + + if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo }) + { + // Apply comments from the property + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) + { + var description = propertyComment.Summary; + if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) + { + description = $"{description}\n{propertyComment.Value}"; + } + else if (string.IsNullOrEmpty(description)) + { + description = propertyComment.Value; + } + if (schema.Metadata is null + || !schema.Metadata.TryGetValue("x-schema-id", out var schemaId) + || string.IsNullOrEmpty(schemaId as string)) + { + // Inlined schema + schema.Description = description; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + else + { + // Schema Reference + if (!string.IsNullOrEmpty(description)) + { + schema.Metadata["x-ref-description"] = description; + } + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Metadata["x-ref-example"] = jsonString.Parse()!; + } + } + } + } + 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 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()); + }); + } + + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsParametersWithCustomNamesFromControllers#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsParametersWithCustomNamesFromControllers#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..764f8dce398b --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsParametersWithCustomNamesFromControllers#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,651 @@ +//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.AspNetCore.Mvc.ModelBinding; + using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + 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(@"P:Query.Name", new XmlComment(@"The full name of the person.", null, null, null, null, false, null, null, null)); + cache.Add(@"M:TestController.Get(System.Int32)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"userId", @"The id of the user.", null, false)], null)); + + return cache; + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class DocumentationCommentIdHelper + { + /// + /// Generates a documentation comment ID for a type. + /// Example: T:Namespace.Outer+Inner`1 becomes T:Namespace.Outer.Inner`1 + /// + public static string CreateDocumentationId(this Type type) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + return "T:" + GetTypeDocId(type, includeGenericArguments: false, omitGenericArity: false); + } + + /// + /// Generates a documentation comment ID for a property. + /// Example: P:Namespace.ContainingType.PropertyName or for an indexer P:Namespace.ContainingType.Item(System.Int32) + /// + public static string CreateDocumentationId(this PropertyInfo property) + { + if (property == null) + { + throw new ArgumentNullException(nameof(property)); + } + + var sb = new StringBuilder(); + sb.Append("P:"); + + if (property.DeclaringType != null) + { + sb.Append(GetTypeDocId(property.DeclaringType, includeGenericArguments: false, omitGenericArity: false)); + } + + sb.Append('.'); + sb.Append(property.Name); + + // For indexers, include the parameter list. + var indexParams = property.GetIndexParameters(); + if (indexParams.Length > 0) + { + sb.Append('('); + for (int i = 0; i < indexParams.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + + sb.Append(GetTypeDocId(indexParams[i].ParameterType, includeGenericArguments: true, omitGenericArity: false)); + } + sb.Append(')'); + } + + return sb.ToString(); + } + + /// + /// Generates a documentation comment ID for a method (or constructor). + /// For example: + /// M:Namespace.ContainingType.MethodName(ParamType1,ParamType2)~ReturnType + /// M:Namespace.ContainingType.#ctor(ParamType) + /// + public static string CreateDocumentationId(this MethodInfo method) + { + if (method == null) + { + throw new ArgumentNullException(nameof(method)); + } + + var sb = new StringBuilder(); + sb.Append("M:"); + + // Append the fully qualified name of the declaring type. + if (method.DeclaringType != null) + { + sb.Append(GetTypeDocId(method.DeclaringType, includeGenericArguments: false, omitGenericArity: false)); + } + + sb.Append('.'); + + // Append the method name, handling constructors specially. + if (method.IsConstructor) + { + sb.Append(method.IsStatic ? "#cctor" : "#ctor"); + } + else + { + sb.Append(method.Name); + if (method.IsGenericMethod) + { + sb.Append("``"); + sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", method.GetGenericArguments().Length); + } + } + + // Append the parameter list, if any. + var parameters = method.GetParameters(); + if (parameters.Length > 0) + { + sb.Append('('); + for (int i = 0; i < parameters.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + + // Omit the generic arity for the parameter type. + sb.Append(GetTypeDocId(parameters[i].ParameterType, includeGenericArguments: true, omitGenericArity: true)); + } + sb.Append(')'); + } + + // Append the return type after a '~' (if the method returns a value). + if (method.ReturnType != typeof(void)) + { + sb.Append('~'); + // Omit the generic arity for the return type. + sb.Append(GetTypeDocId(method.ReturnType, includeGenericArguments: true, omitGenericArity: true)); + } + + return sb.ToString(); + } + + /// + /// Generates a documentation ID string for a type. + /// This method handles nested types (replacing '+' with '.'), + /// generic types, arrays, pointers, by-ref types, and generic parameters. + /// The flag controls whether + /// constructed generic type arguments are emitted, while + /// controls whether the generic arity marker (e.g. "`1") is appended. + /// + private static string GetTypeDocId(Type type, bool includeGenericArguments, bool omitGenericArity) + { + if (type.IsGenericParameter) + { + // Use `` for method-level generic parameters and ` for type-level. + if (type.DeclaringMethod != null) + { + return "``" + type.GenericParameterPosition; + } + else if (type.DeclaringType != null) + { + return "`" + type.GenericParameterPosition; + } + else + { + return type.Name; + } + } + + if (type.IsGenericType) + { + Type genericDef = type.GetGenericTypeDefinition(); + string fullName = genericDef.FullName ?? genericDef.Name; + + var sb = new StringBuilder(fullName.Length); + + // Replace '+' with '.' for nested types + for (var i = 0; i < fullName.Length; i++) + { + char c = fullName[i]; + if (c == '+') + { + sb.Append('.'); + } + else if (c == '`') + { + break; + } + else + { + sb.Append(c); + } + } + + if (!omitGenericArity) + { + int arity = genericDef.GetGenericArguments().Length; + sb.Append('`'); + sb.AppendFormat(CultureInfo.InvariantCulture, "{0}", arity); + } + + if (includeGenericArguments && !type.IsGenericTypeDefinition) + { + var typeArgs = type.GetGenericArguments(); + sb.Append('{'); + + for (int i = 0; i < typeArgs.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + + sb.Append(GetTypeDocId(typeArgs[i], includeGenericArguments, omitGenericArity)); + } + + sb.Append('}'); + } + + return sb.ToString(); + } + + // For non-generic types, use FullName (if available) and replace nested type separators. + return (type.FullName ?? type.Name).Replace('+', '.'); + } + + /// + /// Normalizes a documentation comment ID to match the compiler-style format. + /// Strips the return type suffix for ordinary methods but retains it for conversion operators. + /// + /// The documentation comment ID to normalize. + /// The normalized documentation comment ID. + public static string NormalizeDocId(string docId) + { + // Find the tilde character that indicates the return type suffix + var tildeIndex = docId.IndexOf('~'); + if (tildeIndex == -1) + { + // No return type suffix, return as-is + return docId; + } + + // Check if this is a conversion operator (op_Implicit or op_Explicit) + // For these operators, we need to keep the return type suffix + if (docId.Contains("op_Implicit") || docId.Contains("op_Explicit")) + { + return docId; + } + + // For ordinary methods, strip the return type suffix + return docId.Substring(0, tildeIndex); + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor + ? controllerActionDescriptor.MethodInfo + : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + + if (methodInfo is null) + { + return Task.CompletedTask; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(methodInfo.CreateDocumentationId()), out var methodComment)) + { + if (methodComment.Summary is { } summary) + { + operation.Summary = summary; + } + if (methodComment.Description is { } description) + { + operation.Description = description; + } + if (methodComment.Remarks is { } remarks) + { + operation.Description = remarks; + } + if (methodComment.Parameters is { Count: > 0}) + { + foreach (var parameterComment in methodComment.Parameters) + { + var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name); + if (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); + 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; + } + } + } + } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata is not null + && metadata.MetadataKind == ModelMetadataKind.Property + && metadata.ContainerType is { } containerType + && metadata.PropertyName is { } propertyName) + { + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) + { + var parameter = GetOperationParameter(operation, propertyInfo); + var description = propertyComment.Summary; + if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) + { + description = $"{description}\n{propertyComment.Value}"; + } + else if (string.IsNullOrEmpty(description)) + { + description = propertyComment.Value; + } + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = description; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + var content = operation.RequestBody.Content?.Values; + if (content is null) + { + continue; + } + var parsedExample = jsonString.Parse(); + foreach (var mediaType in content) + { + mediaType.Example = parsedExample; + } + } + } + continue; + } + var targetOperationParameter = UnwrapOpenApiParameter(parameter); + if (targetOperationParameter is not null) + { + targetOperationParameter.Description = description; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } + + return Task.CompletedTask; + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + + 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) + { + // Apply comments from the type + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(context.JsonTypeInfo.Type.CreateDocumentationId()), out var typeComment)) + { + schema.Description = typeComment.Summary; + if (typeComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + + if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo }) + { + // Apply comments from the property + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) + { + var description = propertyComment.Summary; + if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) + { + description = $"{description}\n{propertyComment.Value}"; + } + else if (string.IsNullOrEmpty(description)) + { + description = propertyComment.Value; + } + if (schema.Metadata is null + || !schema.Metadata.TryGetValue("x-schema-id", out var schemaId) + || string.IsNullOrEmpty(schemaId as string)) + { + // Inlined schema + schema.Description = description; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + else + { + // Schema Reference + if (!string.IsNullOrEmpty(description)) + { + schema.Metadata["x-ref-description"] = description; + } + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Metadata["x-ref-example"] = jsonString.Parse()!; + } + } + } + } + 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 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()); + }); + } + + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromControllers#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromControllers#OpenApiXmlCommentSupport.generated.verified.cs index 9f2b1de95ca8..ba11f7a5f3b2 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromControllers#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromControllers#OpenApiXmlCommentSupport.generated.verified.cs @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -71,7 +72,7 @@ private static Dictionary GenerateCacheEntries() { var cache = new Dictionary(); - cache.Add(@"M:TestController.Get", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"userId", @"The id of the user.", null, false)], null)); + cache.Add(@"M:TestController.Get(System.Int32)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"userId", @"The id of the user.", null, false)], null)); return cache; } @@ -136,30 +137,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -372,7 +349,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -432,10 +413,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -482,6 +467,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs index 753c09661ae8..ed75560d2bd1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -139,30 +140,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -375,7 +352,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -435,10 +416,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -485,6 +470,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs index fe7be155d841..f09fee2e1143 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -183,30 +184,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -419,7 +396,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -479,10 +460,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -529,6 +514,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs index 9ae384e0e245..58e706997a2f 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -165,30 +166,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -401,7 +378,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -461,10 +442,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -511,6 +496,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.XmlCommentsOnPropertiesShouldApplyToSchemaReferences#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.XmlCommentsOnPropertiesShouldApplyToSchemaReferences#OpenApiXmlCommentSupport.generated.verified.cs index 86f754cd6cd6..a37fd2a86179 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.XmlCommentsOnPropertiesShouldApplyToSchemaReferences#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.XmlCommentsOnPropertiesShouldApplyToSchemaReferences#OpenApiXmlCommentSupport.generated.verified.cs @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -144,30 +145,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -380,7 +357,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -440,10 +421,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -490,6 +475,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs index 44baeee7116b..210b515058ff 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/XmlCommentDocumentationIdTests.CanMergeXmlCommentsWithDifferentDocumentationIdFormats#OpenApiXmlCommentSupport.generated.verified.cs @@ -1,4 +1,4 @@ -//HintName: OpenApiXmlCommentSupport.generated.cs +//HintName: OpenApiXmlCommentSupport.generated.cs //------------------------------------------------------------------------------ // // This code was generated by a tool. @@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -136,30 +137,6 @@ public static string CreateDocumentationId(this PropertyInfo property) return sb.ToString(); } - /// - /// Generates a documentation comment ID for a property given its container type and property name. - /// Example: P:Namespace.ContainingType.PropertyName - /// - public static string CreateDocumentationId(Type containerType, string propertyName) - { - if (containerType == null) - { - throw new ArgumentNullException(nameof(containerType)); - } - if (string.IsNullOrEmpty(propertyName)) - { - throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - var sb = new StringBuilder(); - sb.Append("P:"); - sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false)); - sb.Append('.'); - sb.Append(propertyName); - - return sb.ToString(); - } - /// /// Generates a documentation comment ID for a method (or constructor). /// For example: @@ -372,7 +349,11 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform 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 (parameterInfo is null || parameterInfo.ParameterType == typeof(CancellationToken)) + { + continue; + } + var operationParameter = GetOperationParameter(operation, parameterInfo); if (operationParameter is not null) { var targetOperationParameter = UnwrapOpenApiParameter(operationParameter); @@ -432,10 +413,14 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform && metadata.ContainerType is { } containerType && metadata.PropertyName is { } propertyName) { - var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName); - if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment)) + var propertyInfo = containerType.GetProperty(propertyName); + if (propertyInfo is null) + { + continue; + } + if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyInfo.CreateDocumentationId()), out var propertyComment)) { - var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + var parameter = GetOperationParameter(operation, propertyInfo); var description = propertyComment.Summary; if (!string.IsNullOrEmpty(description) && !string.IsNullOrEmpty(propertyComment.Value)) { @@ -482,6 +467,61 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, PropertyInfo propertyInfo) + { + return GetOperationParameter(operation, propertyInfo, propertyInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ParameterInfo parameterInfo) + { + return GetOperationParameter(operation, parameterInfo, parameterInfo.Name); + } + + private static IOpenApiParameter? GetOperationParameter(OpenApiOperation operation, ICustomAttributeProvider attributeProvider, string? name) + { + var parameters = operation.Parameters; + if (parameters is null) + { + return null; + } + + var modelNames = GetModelNames(attributeProvider, name); + + foreach (var parameter in parameters) + { + var parameterName = parameter.Name; + + if (string.IsNullOrEmpty(parameterName)) + { + continue; + } + + if (modelNames.Contains(parameterName)) + { + return parameter; + } + } + + return null; + } + + private static IReadOnlySet GetModelNames(ICustomAttributeProvider attributeProvider, string? name) + { + var modelNames = attributeProvider + .GetCustomAttributes(inherit: false) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Select(p => p.Name!) + .ToHashSet(); + + if (!string.IsNullOrEmpty(name)) + { + modelNames.Add(name); + } + + return modelNames; + } + private static OpenApiParameter UnwrapOpenApiParameter(IOpenApiParameter sourceParameter) { if (sourceParameter is OpenApiParameterReference parameterReference)