Skip to content

Commit 616563c

Browse files
committed
Support processing XML comments on [AsParameters] parameter
1 parent 52b1c18 commit 616563c

9 files changed

+695
-6
lines changed

src/OpenApi/gen/XmlCommentGenerator.Emitter.cs

Lines changed: 68 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,49 @@ 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.Summary ?? propertyComment.Description;
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+
foreach (var mediaType in content)
468+
{
469+
mediaType.Example = jsonString.Parse();
470+
}
471+
}
472+
}
473+
continue;
474+
}
475+
var targetOperationParameter = UnwrapOpenApiParameter(parameter);
476+
if (targetOperationParameter is not null)
477+
{
478+
targetOperationParameter.Description = propertyComment.Summary ?? propertyComment.Description;
479+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
480+
{
481+
targetOperationParameter.Example = jsonString.Parse();
482+
}
483+
}
484+
}
485+
}
486+
}
419487
420488
return Task.CompletedTask;
421489
}

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

Lines changed: 129 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,9 @@ 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);
4751
4852
app.Run();
4953
@@ -207,8 +211,70 @@ public static void Post16(Example example)
207211
public static int[][] Get17(int[] args)
208212
{
209213
return [[1, 2, 3], [4, 5, 6], [7, 8, 9], args];
214+
}
210215
216+
/// <summary>
217+
/// A summary of Post18.
218+
/// </summary>
219+
public static int Post18([AsParameters] FirstParameters queryParameters, [AsParameters] SecondParameters bodyParameters)
220+
{
221+
return 0;
211222
}
223+
224+
/// <summary>
225+
/// Tests mixed regular and AsParameters with examples.
226+
/// </summary>
227+
/// <param name="regularParam">A regular parameter with documentation.</param>
228+
/// <param name="mixedParams">Mixed parameter class with various types.</param>
229+
public static IResult Post19(string regularParam, [AsParameters] MixedParametersClass mixedParams)
230+
{
231+
return TypedResults.Ok($"Regular: {regularParam}, Email: {mixedParams.Email}");
232+
}
233+
234+
/// <summary>
235+
/// Tests AsParameters with different binding sources.
236+
/// </summary>
237+
/// <param name="bindingParams">Parameters from different sources.</param>
238+
public static IResult Get20([AsParameters] BindingSourceParametersClass bindingParams)
239+
{
240+
return TypedResults.Ok($"Query: {bindingParams.QueryParam}, Header: {bindingParams.HeaderParam}");
241+
}
242+
}
243+
244+
public class FirstParameters
245+
{
246+
/// <summary>
247+
/// The name of the person.
248+
/// </summary>
249+
public string? Name { get; set; }
250+
/// <summary>
251+
/// The age of the person.
252+
/// </summary>
253+
/// <example>30</example>
254+
public int? Age { get; set; }
255+
/// <summary>
256+
/// The user information.
257+
/// </summary>
258+
/// <example>
259+
/// {
260+
/// "username": "johndoe",
261+
/// "email": "[email protected]"
262+
/// }
263+
/// </example>
264+
public User? User { get; set; }
265+
}
266+
267+
public class SecondParameters
268+
{
269+
/// <summary>
270+
/// The description of the project.
271+
/// </summary>
272+
public string? Description { get; set; }
273+
/// <summary>
274+
/// The service used for testing.
275+
/// </summary>
276+
[FromServices]
277+
public Example Service { get; set; }
212278
}
213279
214280
public class User
@@ -232,6 +298,42 @@ public Example(Func<object?, int> function, object? state) : base(function, stat
232298
{
233299
}
234300
}
301+
302+
public class MixedParametersClass
303+
{
304+
/// <summary>
305+
/// The user's email address.
306+
/// </summary>
307+
/// <example>"[email protected]"</example>
308+
public string? Email { get; set; }
309+
310+
/// <summary>
311+
/// The user's age in years.
312+
/// </summary>
313+
/// <example>25</example>
314+
public int Age { get; set; }
315+
316+
/// <summary>
317+
/// Whether the user is active.
318+
/// </summary>
319+
/// <example>true</example>
320+
public bool IsActive { get; set; }
321+
}
322+
323+
public class BindingSourceParametersClass
324+
{
325+
/// <summary>
326+
/// Query parameter from URL.
327+
/// </summary>
328+
[FromQuery]
329+
public string? QueryParam { get; set; }
330+
331+
/// <summary>
332+
/// Header value from request.
333+
/// </summary>
334+
[FromHeader]
335+
public string? HeaderParam { get; set; }
336+
}
235337
""";
236338
var generator = new XmlCommentGenerator();
237339
await SnapshotTestHelper.Verify(source, generator, out var compilation);
@@ -304,6 +406,33 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document =>
304406

305407
var path17 = document.Paths["/17"].Operations[HttpMethod.Get];
306408
Assert.Equal("A summary of Get17.", path17.Summary);
409+
410+
var path18 = document.Paths["/18"].Operations[HttpMethod.Post];
411+
Assert.Equal("A summary of Post18.", path18.Summary);
412+
Assert.Equal("The name of the person.", path18.Parameters[0].Description);
413+
Assert.Equal("The age of the person.", path18.Parameters[1].Description);
414+
Assert.Equal(30, path18.Parameters[1].Example.GetValue<int>());
415+
Assert.Equal("The description of the project.", path18.Parameters[2].Description);
416+
Assert.Equal("The user information.", path18.RequestBody.Description);
417+
var path18RequestBody = path18.RequestBody.Content["application/json"];
418+
var path18Example = Assert.IsAssignableFrom<JsonNode>(path18RequestBody.Example);
419+
Assert.Equal("johndoe", path18Example["username"].GetValue<string>());
420+
Assert.Equal("[email protected]", path18Example["email"].GetValue<string>());
421+
422+
var path19 = document.Paths["/19"].Operations[HttpMethod.Post];
423+
Assert.Equal("Tests mixed regular and AsParameters with examples.", path19.Summary);
424+
Assert.Equal("A regular parameter with documentation.", path19.Parameters[0].Description);
425+
Assert.Equal("The user's email address.", path19.Parameters[1].Description);
426+
Assert.Equal("[email protected]", path19.Parameters[1].Example.GetValue<string>());
427+
Assert.Equal("The user's age in years.", path19.Parameters[2].Description);
428+
Assert.Equal(25, path19.Parameters[2].Example.GetValue<int>());
429+
Assert.Equal("Whether the user is active.", path19.Parameters[3].Description);
430+
Assert.True(path19.Parameters[3].Example.GetValue<bool>());
431+
432+
var path20 = document.Paths["/20"].Operations[HttpMethod.Get];
433+
Assert.Equal("Tests AsParameters with different binding sources.", path20.Summary);
434+
Assert.Equal("Query parameter from URL.", path20.Parameters[0].Description);
435+
Assert.Equal("Header value from request.", path20.Parameters[1].Description);
307436
});
308437
}
309438
}

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ namespace Microsoft.AspNetCore.OpenApi.Generated
3939
using System.Threading.Tasks;
4040
using Microsoft.AspNetCore.OpenApi;
4141
using Microsoft.AspNetCore.Mvc.Controllers;
42+
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
4243
using Microsoft.Extensions.DependencyInjection;
4344
using Microsoft.OpenApi;
4445

@@ -134,6 +135,30 @@ public static string CreateDocumentationId(this PropertyInfo property)
134135
return sb.ToString();
135136
}
136137

138+
/// <summary>
139+
/// Generates a documentation comment ID for a property given its container type and property name.
140+
/// Example: P:Namespace.ContainingType.PropertyName
141+
/// </summary>
142+
public static string CreateDocumentationId(Type containerType, string propertyName)
143+
{
144+
if (containerType == null)
145+
{
146+
throw new ArgumentNullException(nameof(containerType));
147+
}
148+
if (string.IsNullOrEmpty(propertyName))
149+
{
150+
throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName));
151+
}
152+
153+
var sb = new StringBuilder();
154+
sb.Append("P:");
155+
sb.Append(GetTypeDocId(containerType, includeGenericArguments: false, omitGenericArity: false));
156+
sb.Append('.');
157+
sb.Append(propertyName);
158+
159+
return sb.ToString();
160+
}
161+
137162
/// <summary>
138163
/// Generates a documentation comment ID for a method (or constructor).
139164
/// For example:
@@ -398,6 +423,49 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform
398423
}
399424
}
400425
}
426+
foreach (var parameterDescription in context.Description.ParameterDescriptions)
427+
{
428+
var metadata = parameterDescription.ModelMetadata;
429+
if (metadata.MetadataKind == ModelMetadataKind.Property
430+
&& metadata.ContainerType is { } containerType
431+
&& metadata.PropertyName is { } propertyName)
432+
{
433+
var propertyDocId = DocumentationCommentIdHelper.CreateDocumentationId(containerType, propertyName!);
434+
if (XmlCommentCache.Cache.TryGetValue(DocumentationCommentIdHelper.NormalizeDocId(propertyDocId), out var propertyComment))
435+
{
436+
var parameter = operation.Parameters?.SingleOrDefault(p => p.Name == metadata.Name);
437+
if (parameter is null)
438+
{
439+
if (operation.RequestBody is not null)
440+
{
441+
operation.RequestBody.Description = propertyComment.Summary ?? propertyComment.Description;
442+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
443+
{
444+
var content = operation.RequestBody.Content?.Values;
445+
if (content is null)
446+
{
447+
continue;
448+
}
449+
foreach (var mediaType in content)
450+
{
451+
mediaType.Example = jsonString.Parse();
452+
}
453+
}
454+
}
455+
continue;
456+
}
457+
var targetOperationParameter = UnwrapOpenApiParameter(parameter);
458+
if (targetOperationParameter is not null)
459+
{
460+
targetOperationParameter.Description = propertyComment.Summary ?? propertyComment.Description;
461+
if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
462+
{
463+
targetOperationParameter.Example = jsonString.Parse();
464+
}
465+
}
466+
}
467+
}
468+
}
401469

402470
return Task.CompletedTask;
403471
}
@@ -547,4 +615,4 @@ public static IServiceCollection AddOpenApi(this IServiceCollection services, st
547615
}
548616

549617
}
550-
}
618+
}

0 commit comments

Comments
 (0)