Skip to content

Commit a7e8fa4

Browse files
authored
Support processing XML comments on [AsParameters] parameter (#63166)
* Support processing XML comments on [AsParameters] parameter * Feedback and more tests * Update snapshots * Feedback and snapshots update
1 parent 3c4f3ec commit a7e8fa4

10 files changed

+828
-1
lines changed

src/OpenApi/gen/XmlCommentGenerator.Emitter.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
5757
using System.Threading.Tasks;
5858
using Microsoft.AspNetCore.OpenApi;
5959
using Microsoft.AspNetCore.Mvc.Controllers;
60+
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
6061
using Microsoft.Extensions.DependencyInjection;
6162
using Microsoft.OpenApi;
6263
@@ -152,6 +153,30 @@ public static string CreateDocumentationId(this PropertyInfo property)
152153
return sb.ToString();
153154
}
154155
156+
/// <summary>
157+
/// Generates a documentation comment ID for a property given its container type and property name.
158+
/// Example: P:Namespace.ContainingType.PropertyName
159+
/// </summary>
160+
public static string CreateDocumentationId(Type containerType, string propertyName)
161+
{
162+
if (containerType == null)
163+
{
164+
throw new ArgumentNullException(nameof(containerType));
165+
}
166+
if (string.IsNullOrEmpty(propertyName))
167+
{
168+
throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
169+
}
170+
171+
var sb = new StringBuilder();
172+
sb.Append("P:");
173+
sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
174+
sb.Append('.');
175+
sb.Append(propertyName);
176+
177+
return sb.ToString();
178+
}
179+
155180
/// <summary>
156181
/// Generates a documentation comment ID for a method (or constructor).
157182
/// For example:
@@ -416,6 +441,50 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
416441
}
417442
}
418443
}
444+
foreach (var parameterDescription in context.Description.ParameterDescriptions)
445+
{
446+
var metadata = parameterDescription.ModelMetadata;
447+
if (metadata.MetadataKind == ModelMetadataKind.Property
448+
&& metadata.ContainerType is { } containerType
449+
&& metadata.PropertyName is { } propertyName)
450+
{
451+
var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName);
452+
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
453+
{
454+
var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
455+
if (parameter is null)
456+
{
457+
if (operation.RequestBody is not null)
458+
{
459+
operation.RequestBody.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
460+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
461+
{
462+
var content = operation.RequestBody.Content?.Values;
463+
if (content is null)
464+
{
465+
continue;
466+
}
467+
var parsedExample = jsonString.Parse();
468+
foreach (var mediaType in content)
469+
{
470+
mediaType.Example = parsedExample;
471+
}
472+
}
473+
}
474+
continue;
475+
}
476+
var targetOperationParameter = UnwrapOpenApiParameter(parameter);
477+
if (targetOperationParameter is not null)
478+
{
479+
targetOperationParameter.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
480+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
481+
{
482+
targetOperationParameter.Example = jsonString.Parse();
483+
}
484+
}
485+
}
486+
}
487+
}
419488
420489
return Task.CompletedTask;
421490
}

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public async Task SupportsXmlCommentsOnOperationsFromMinimalApis()
2020
using Microsoft.Extensions.DependencyInjection;
2121
using Microsoft.AspNetCore.Http.HttpResults;
2222
using Microsoft.AspNetCore.Http;
23+
using Microsoft.AspNetCore.Mvc;
2324
2425
var builder = WebApplication.CreateBuilder();
2526
@@ -44,6 +45,10 @@ public async Task SupportsXmlCommentsOnOperationsFromMinimalApis()
4445
app.MapGet("/15", RouteHandlerExtensionMethods.Get15);
4546
app.MapPost("/16", RouteHandlerExtensionMethods.Post16);
4647
app.MapGet("/17", RouteHandlerExtensionMethods.Get17);
48+
app.MapPost("/18", RouteHandlerExtensionMethods.Post18);
49+
app.MapPost("/19", RouteHandlerExtensionMethods.Post19);
50+
app.MapGet("/20", RouteHandlerExtensionMethods.Get20);
51+
app.MapGet("/21", RouteHandlerExtensionMethods.Get21);
4752
4853
app.Run();
4954
@@ -207,10 +212,81 @@ public static void Post16(Example example)
207212
public static int[][] Get17(int[] args)
208213
{
209214
return [[1, 2, 3], [4, 5, 6], [7, 8, 9], args];
215+
}
210216
217+
/// <summary>
218+
/// A summary of Post18.
219+
/// </summary>
220+
public static int Post18([AsParameters] FirstParameters queryParameters, [AsParameters] SecondParameters bodyParameters)
221+
{
222+
return 0;
223+
}
224+
225+
/// <summary>
226+
/// Tests mixed regular and AsParameters with examples.
227+
/// </summary>
228+
/// <param name="regularParam">A regular parameter with documentation.</param>
229+
/// <param name="mixedParams">Mixed parameter class with various types.</param>
230+
public static IResult Post19(string regularParam, [AsParameters] MixedParametersClass mixedParams)
231+
{
232+
return TypedResults.Ok($"Regular: {regularParam}, Email: {mixedParams.Email}");
233+
}
234+
235+
/// <summary>
236+
/// Tests AsParameters with different binding sources.
237+
/// </summary>
238+
/// <param name="bindingParams">Parameters from different sources.</param>
239+
public static IResult Get20([AsParameters] BindingSourceParametersClass bindingParams)
240+
{
241+
return TypedResults.Ok($"Query: {bindingParams.QueryParam}, Header: {bindingParams.HeaderParam}");
242+
}
243+
244+
/// <summary>
245+
/// Tests XML documentation priority order (value > returns > summary).
246+
/// </summary>
247+
/// <param name="priorityParams">Parameters demonstrating XML doc priority.</param>
248+
public static IResult Get21([AsParameters] XmlDocPriorityParametersClass priorityParams)
249+
{
250+
return TypedResults.Ok($"Processed parameters");
211251
}
212252
}
213253
254+
public class FirstParameters
255+
{
256+
/// <summary>
257+
/// The name of the person.
258+
/// </summary>
259+
public string? Name { get; set; }
260+
/// <summary>
261+
/// The age of the person.
262+
/// </summary>
263+
/// <example>30</example>
264+
public int? Age { get; set; }
265+
/// <summary>
266+
/// The user information.
267+
/// </summary>
268+
/// <example>
269+
/// {
270+
/// "username": "johndoe",
271+
/// "email": "[email protected]"
272+
/// }
273+
/// </example>
274+
public User? User { get; set; }
275+
}
276+
277+
public class SecondParameters
278+
{
279+
/// <summary>
280+
/// The description of the project.
281+
/// </summary>
282+
public string? Description { get; set; }
283+
/// <summary>
284+
/// The service used for testing.
285+
/// </summary>
286+
[FromServices]
287+
public Example Service { get; set; }
288+
}
289+
214290
public class User
215291
{
216292
public string Username { get; set; } = string.Empty;
@@ -232,6 +308,69 @@ public Example(Func<object?, int> function, object? state) : base(function, stat
232308
{
233309
}
234310
}
311+
312+
public class MixedParametersClass
313+
{
314+
/// <summary>
315+
/// The user's email address.
316+
/// </summary>
317+
/// <example>"[email protected]"</example>
318+
public string? Email { get; set; }
319+
320+
/// <summary>
321+
/// The user's age in years.
322+
/// </summary>
323+
/// <example>25</example>
324+
public int Age { get; set; }
325+
326+
/// <summary>
327+
/// Whether the user is active.
328+
/// </summary>
329+
/// <example>true</example>
330+
public bool IsActive { get; set; }
331+
}
332+
333+
public class BindingSourceParametersClass
334+
{
335+
/// <summary>
336+
/// Query parameter from URL.
337+
/// </summary>
338+
[FromQuery]
339+
public string? QueryParam { get; set; }
340+
341+
/// <summary>
342+
/// Header value from request.
343+
/// </summary>
344+
[FromHeader]
345+
public string? HeaderParam { get; set; }
346+
}
347+
348+
public class XmlDocPriorityParametersClass
349+
{
350+
/// <summary>
351+
/// Property with only summary documentation.
352+
/// </summary>
353+
public string? SummaryOnlyProperty { get; set; }
354+
355+
/// <summary>
356+
/// Property with summary documentation that should be overridden.
357+
/// </summary>
358+
/// <returns>Returns-based description that should take precedence over summary.</returns>
359+
public string? SummaryAndReturnsProperty { get; set; }
360+
361+
/// <summary>
362+
/// Property with all three types of documentation.
363+
/// </summary>
364+
/// <returns>Returns-based description that should be overridden by value.</returns>
365+
/// <value>Value-based description that should take highest precedence.</value>
366+
public string? AllThreeProperty { get; set; }
367+
368+
/// <returns>Returns-only description.</returns>
369+
public string? ReturnsOnlyProperty { get; set; }
370+
371+
/// <value>Value-only description.</value>
372+
public string? ValueOnlyProperty { get; set; }
373+
}
235374
""";
236375
var generator = new XmlCommentGenerator();
237376
await SnapshotTestHelper.Verify(source, generator, out var compilation);
@@ -304,6 +443,51 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
304443

305444
var path17 = document.Paths["/17"].Operations[HttpMethod.Get];
306445
Assert.Equal("A summary of Get17.", path17.Summary);
446+
447+
var path18 = document.Paths["/18"].Operations[HttpMethod.Post];
448+
Assert.Equal("A summary of Post18.", path18.Summary);
449+
Assert.Equal("The name of the person.", path18.Parameters[0].Description);
450+
Assert.Equal("The age of the person.", path18.Parameters[1].Description);
451+
Assert.Equal(30, path18.Parameters[1].Example.GetValue<int>());
452+
Assert.Equal("The description of the project.", path18.Parameters[2].Description);
453+
Assert.Equal("The user information.", path18.RequestBody.Description);
454+
var path18RequestBody = path18.RequestBody.Content["application/json"];
455+
var path18Example = Assert.IsAssignableFrom<JsonNode>(path18RequestBody.Example);
456+
Assert.Equal("johndoe", path18Example["username"].GetValue<string>());
457+
Assert.Equal("[email protected]", path18Example["email"].GetValue<string>());
458+
459+
var path19 = document.Paths["/19"].Operations[HttpMethod.Post];
460+
Assert.Equal("Tests mixed regular and AsParameters with examples.", path19.Summary);
461+
Assert.Equal("A regular parameter with documentation.", path19.Parameters[0].Description);
462+
Assert.Equal("The user's email address.", path19.Parameters[1].Description);
463+
Assert.Equal("[email protected]", path19.Parameters[1].Example.GetValue<string>());
464+
Assert.Equal("The user's age in years.", path19.Parameters[2].Description);
465+
Assert.Equal(25, path19.Parameters[2].Example.GetValue<int>());
466+
Assert.Equal("Whether the user is active.", path19.Parameters[3].Description);
467+
Assert.True(path19.Parameters[3].Example.GetValue<bool>());
468+
469+
var path20 = document.Paths["/20"].Operations[HttpMethod.Get];
470+
Assert.Equal("Tests AsParameters with different binding sources.", path20.Summary);
471+
Assert.Equal("Query parameter from URL.", path20.Parameters[0].Description);
472+
Assert.Equal("Header value from request.", path20.Parameters[1].Description);
473+
474+
// Test XML documentation priority order: value > returns > summary
475+
var path22 = document.Paths["/21"].Operations[HttpMethod.Get];
476+
// Find parameters by name for clearer assertions
477+
var summaryOnlyParam = path22.Parameters.First(p => p.Name == "SummaryOnlyProperty");
478+
Assert.Equal("Property with only summary documentation.", summaryOnlyParam.Description);
479+
480+
var summaryAndReturnsParam = path22.Parameters.First(p => p.Name == "SummaryAndReturnsProperty");
481+
Assert.Equal("Returns-based description that should take precedence over summary.", summaryAndReturnsParam.Description);
482+
483+
var allThreeParam = path22.Parameters.First(p => p.Name == "AllThreeProperty");
484+
Assert.Equal("Value-based description that should take highest precedence.", allThreeParam.Description);
485+
486+
var returnsOnlyParam = path22.Parameters.First(p => p.Name == "ReturnsOnlyProperty");
487+
Assert.Equal("Returns-only description.", returnsOnlyParam.Description);
488+
489+
var valueOnlyParam = path22.Parameters.First(p => p.Name == "ValueOnlyProperty");
490+
Assert.Equal("Value-only description.", valueOnlyParam.Description);
307491
});
308492
}
309493
}

0 commit comments

Comments
 (0)