diff --git a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs index ecd59881e3f2..d83aa5028c18 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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -152,6 +153,30 @@ 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: @@ -416,6 +441,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs index 21213f5ac386..33544cd1a1af 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs @@ -20,6 +20,7 @@ public async Task SupportsXmlCommentsOnOperationsFromMinimalApis() using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(); @@ -44,6 +45,10 @@ public async Task SupportsXmlCommentsOnOperationsFromMinimalApis() app.MapGet("/15", RouteHandlerExtensionMethods.Get15); app.MapPost("/16", RouteHandlerExtensionMethods.Post16); app.MapGet("/17", RouteHandlerExtensionMethods.Get17); +app.MapPost("/18", RouteHandlerExtensionMethods.Post18); +app.MapPost("/19", RouteHandlerExtensionMethods.Post19); +app.MapGet("/20", RouteHandlerExtensionMethods.Get20); +app.MapGet("/21", RouteHandlerExtensionMethods.Get21); app.Run(); @@ -207,10 +212,81 @@ public static void Post16(Example example) public static int[][] Get17(int[] args) { return [[1, 2, 3], [4, 5, 6], [7, 8, 9], args]; + } + /// + /// A summary of Post18. + /// + public static int Post18([AsParameters] FirstParameters queryParameters, [AsParameters] SecondParameters bodyParameters) + { + return 0; + } + + /// + /// Tests mixed regular and AsParameters with examples. + /// + /// A regular parameter with documentation. + /// Mixed parameter class with various types. + public static IResult Post19(string regularParam, [AsParameters] MixedParametersClass mixedParams) + { + return TypedResults.Ok($"Regular: {regularParam}, Email: {mixedParams.Email}"); + } + + /// + /// Tests AsParameters with different binding sources. + /// + /// Parameters from different sources. + public static IResult Get20([AsParameters] BindingSourceParametersClass bindingParams) + { + return TypedResults.Ok($"Query: {bindingParams.QueryParam}, Header: {bindingParams.HeaderParam}"); + } + + /// + /// Tests XML documentation priority order (value > returns > summary). + /// + /// Parameters demonstrating XML doc priority. + public static IResult Get21([AsParameters] XmlDocPriorityParametersClass priorityParams) + { + return TypedResults.Ok($"Processed parameters"); } } +public class FirstParameters +{ + /// + /// The name of the person. + /// + public string? Name { get; set; } + /// + /// The age of the person. + /// + /// 30 + public int? Age { get; set; } + /// + /// The user information. + /// + /// + /// { + /// "username": "johndoe", + /// "email": "johndoe@example.com" + /// } + /// + public User? User { get; set; } +} + +public class SecondParameters +{ + /// + /// The description of the project. + /// + public string? Description { get; set; } + /// + /// The service used for testing. + /// + [FromServices] + public Example Service { get; set; } +} + public class User { public string Username { get; set; } = string.Empty; @@ -232,6 +308,69 @@ public Example(Func function, object? state) : base(function, stat { } } + +public class MixedParametersClass +{ + /// + /// The user's email address. + /// + /// "user@example.com" + public string? Email { get; set; } + + /// + /// The user's age in years. + /// + /// 25 + public int Age { get; set; } + + /// + /// Whether the user is active. + /// + /// true + public bool IsActive { get; set; } +} + +public class BindingSourceParametersClass +{ + /// + /// Query parameter from URL. + /// + [FromQuery] + public string? QueryParam { get; set; } + + /// + /// Header value from request. + /// + [FromHeader] + public string? HeaderParam { get; set; } +} + +public class XmlDocPriorityParametersClass +{ + /// + /// Property with only summary documentation. + /// + public string? SummaryOnlyProperty { get; set; } + + /// + /// Property with summary documentation that should be overridden. + /// + /// Returns-based description that should take precedence over summary. + public string? SummaryAndReturnsProperty { get; set; } + + /// + /// Property with all three types of documentation. + /// + /// Returns-based description that should be overridden by value. + /// Value-based description that should take highest precedence. + public string? AllThreeProperty { get; set; } + + /// Returns-only description. + public string? ReturnsOnlyProperty { get; set; } + + /// Value-only description. + public string? ValueOnlyProperty { get; set; } +} """; var generator = new XmlCommentGenerator(); await SnapshotTestHelper.Verify(source, generator, out var compilation); @@ -304,6 +443,51 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document => var path17 = document.Paths["/17"].Operations[HttpMethod.Get]; Assert.Equal("A summary of Get17.", path17.Summary); + + var path18 = document.Paths["/18"].Operations[HttpMethod.Post]; + Assert.Equal("A summary of Post18.", path18.Summary); + Assert.Equal("The name of the person.", path18.Parameters[0].Description); + Assert.Equal("The age of the person.", path18.Parameters[1].Description); + Assert.Equal(30, path18.Parameters[1].Example.GetValue()); + Assert.Equal("The description of the project.", path18.Parameters[2].Description); + Assert.Equal("The user information.", path18.RequestBody.Description); + var path18RequestBody = path18.RequestBody.Content["application/json"]; + var path18Example = Assert.IsAssignableFrom(path18RequestBody.Example); + Assert.Equal("johndoe", path18Example["username"].GetValue()); + Assert.Equal("johndoe@example.com", path18Example["email"].GetValue()); + + var path19 = document.Paths["/19"].Operations[HttpMethod.Post]; + Assert.Equal("Tests mixed regular and AsParameters with examples.", path19.Summary); + Assert.Equal("A regular parameter with documentation.", path19.Parameters[0].Description); + Assert.Equal("The user's email address.", path19.Parameters[1].Description); + Assert.Equal("user@example.com", path19.Parameters[1].Example.GetValue()); + Assert.Equal("The user's age in years.", path19.Parameters[2].Description); + Assert.Equal(25, path19.Parameters[2].Example.GetValue()); + Assert.Equal("Whether the user is active.", path19.Parameters[3].Description); + Assert.True(path19.Parameters[3].Example.GetValue()); + + var path20 = document.Paths["/20"].Operations[HttpMethod.Get]; + Assert.Equal("Tests AsParameters with different binding sources.", path20.Summary); + Assert.Equal("Query parameter from URL.", path20.Parameters[0].Description); + Assert.Equal("Header value from request.", path20.Parameters[1].Description); + + // Test XML documentation priority order: value > returns > summary + var path22 = document.Paths["/21"].Operations[HttpMethod.Get]; + // Find parameters by name for clearer assertions + var summaryOnlyParam = path22.Parameters.First(p => p.Name == "SummaryOnlyProperty"); + Assert.Equal("Property with only summary documentation.", summaryOnlyParam.Description); + + var summaryAndReturnsParam = path22.Parameters.First(p => p.Name == "SummaryAndReturnsProperty"); + Assert.Equal("Returns-based description that should take precedence over summary.", summaryAndReturnsParam.Description); + + var allThreeParam = path22.Parameters.First(p => p.Name == "AllThreeProperty"); + Assert.Equal("Value-based description that should take highest precedence.", allThreeParam.Description); + + var returnsOnlyParam = path22.Parameters.First(p => p.Name == "ReturnsOnlyProperty"); + Assert.Equal("Returns-only description.", returnsOnlyParam.Description); + + var valueOnlyParam = path22.Parameters.First(p => p.Name == "ValueOnlyProperty"); + Assert.Equal("Value-only description.", valueOnlyParam.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 4f143854b812..80dc82a41ecc 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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -134,6 +135,30 @@ 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: @@ -398,6 +423,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; } 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 7b4426da5bee..f5ced40af0d9 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 @@ -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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -163,6 +164,30 @@ 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: @@ -427,6 +452,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; } 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 979f2251418d..f135f2fc5697 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 @@ -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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -255,6 +256,30 @@ 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: @@ -519,6 +544,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; } 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 e1505e95c046..419dd3c5caff 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 @@ -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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -138,6 +139,30 @@ 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: @@ -402,6 +427,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; } 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 b0c6faedc4ee..7ddd295b8d29 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 @@ -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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -70,6 +71,24 @@ private static Dictionary GenerateCacheEntries() { var cache = new Dictionary(); + cache.Add(@"P:FirstParameters.Name", new XmlComment(@"The name of the person.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:FirstParameters.Age", new XmlComment(@"The age of the person.", null, null, null, null, false, [@"30"], null, null)); + cache.Add(@"P:FirstParameters.User", new XmlComment(@"The user information.", null, null, null, null, false, [@"{ + ""username"": ""johndoe"", + ""email"": ""johndoe@example.com"" +}"], null, null)); + cache.Add(@"P:SecondParameters.Description", new XmlComment(@"The description of the project.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:SecondParameters.Service", new XmlComment(@"The service used for testing.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:MixedParametersClass.Email", new XmlComment(@"The user's email address.", null, null, null, null, false, [@"""user@example.com"""], null, null)); + cache.Add(@"P:MixedParametersClass.Age", new XmlComment(@"The user's age in years.", null, null, null, null, false, [@"25"], null, null)); + cache.Add(@"P:MixedParametersClass.IsActive", new XmlComment(@"Whether the user is active.", null, null, null, null, false, [@"true"], null, null)); + cache.Add(@"P:BindingSourceParametersClass.QueryParam", new XmlComment(@"Query parameter from URL.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:BindingSourceParametersClass.HeaderParam", new XmlComment(@"Header value from request.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:XmlDocPriorityParametersClass.SummaryOnlyProperty", new XmlComment(@"Property with only summary documentation.", null, null, null, null, false, null, null, null)); + cache.Add(@"P:XmlDocPriorityParametersClass.SummaryAndReturnsProperty", new XmlComment(@"Property with summary documentation that should be overridden.", null, null, @"Returns-based description that should take precedence over summary.", null, false, null, null, null)); + cache.Add(@"P:XmlDocPriorityParametersClass.AllThreeProperty", new XmlComment(@"Property with all three types of documentation.", null, null, @"Returns-based description that should be overridden by value.", @"Value-based description that should take highest precedence.", false, null, null, null)); + cache.Add(@"P:XmlDocPriorityParametersClass.ReturnsOnlyProperty", new XmlComment(null, null, null, @"Returns-only description.", null, false, null, null, null)); + cache.Add(@"P:XmlDocPriorityParametersClass.ValueOnlyProperty", new XmlComment(null, null, null, null, @"Value-only description.", false, null, null, null)); cache.Add(@"M:RouteHandlerExtensionMethods.Get", new XmlComment(@"A summary of the action.", @"A description of the action.", null, @"Returns the greeting.", null, false, null, null, null)); cache.Add(@"M:RouteHandlerExtensionMethods.Get2(System.String)", new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", null, false)], [new XmlResponseComment(@"200", @"Returns the greeting.", @"")])); cache.Add(@"M:RouteHandlerExtensionMethods.Get3(System.String)", new XmlComment(null, null, null, @"Returns the greeting.", null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", @"Testy McTester", false)], null)); @@ -92,6 +111,10 @@ private static Dictionary GenerateCacheEntries() cache.Add(@"M:RouteHandlerExtensionMethods.Get15", new XmlComment(@"A summary of Get15.", null, null, null, null, false, null, null, [new XmlResponseComment(@"200", @"Returns the greeting.", @"")])); cache.Add(@"M:RouteHandlerExtensionMethods.Post16(Example)", new XmlComment(@"A summary of Post16.", null, null, null, null, false, null, null, null)); cache.Add(@"M:RouteHandlerExtensionMethods.Get17(System.Int32[])", new XmlComment(@"A summary of Get17.", null, null, null, null, false, null, null, null)); + cache.Add(@"M:RouteHandlerExtensionMethods.Post18(FirstParameters,SecondParameters)", new XmlComment(@"A summary of Post18.", null, null, null, null, false, null, null, null)); + cache.Add(@"M:RouteHandlerExtensionMethods.Post19(System.String,MixedParametersClass)", new XmlComment(@"Tests mixed regular and AsParameters with examples.", null, null, null, null, false, null, [new XmlParameterComment(@"regularParam", @"A regular parameter with documentation.", null, false), new XmlParameterComment(@"mixedParams", @"Mixed parameter class with various types.", null, false)], null)); + cache.Add(@"M:RouteHandlerExtensionMethods.Get20(BindingSourceParametersClass)", new XmlComment(@"Tests AsParameters with different binding sources.", null, null, null, null, false, null, [new XmlParameterComment(@"bindingParams", @"Parameters from different sources.", null, false)], null)); + cache.Add(@"M:RouteHandlerExtensionMethods.Get21(XmlDocPriorityParametersClass)", new XmlComment(@"Tests XML documentation priority order (value > returns > summary).", null, null, null, null, false, null, [new XmlParameterComment(@"priorityParams", @"Parameters demonstrating XML doc priority.", null, false)], null)); return cache; } @@ -156,6 +179,30 @@ 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: @@ -420,6 +467,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; } 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 bf889e0c50be..f97dae09882c 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 @@ -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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -164,6 +165,30 @@ 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: @@ -428,6 +453,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; } 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 e60c10ce68f7..c60e8c189812 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 @@ -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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -143,6 +144,30 @@ 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: @@ -407,6 +432,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; } 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 a2a2af1855b3..5e2aefb507a9 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 @@ -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.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -135,6 +136,30 @@ 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: @@ -399,6 +424,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } } } + foreach (var parameterDescription in context.Description.ParameterDescriptions) + { + var metadata = parameterDescription.ModelMetadata; + if (metadata.MetadataKind == ModelMetadataKind.Property + && 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 parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name); + if (parameter is null) + { + if (operation.RequestBody is not null) + { + operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + 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 = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + targetOperationParameter.Example = jsonString.Parse(); + } + } + } + } + } return Task.CompletedTask; }