Skip to content
96 changes: 68 additions & 28 deletions src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -153,30 +154,6 @@ public static string CreateDocumentationId(this PropertyInfo property)
return sb.ToString();
}

/// <summary>
/// Generates a documentation comment ID for a property given its container type and property name.
/// Example: P:Namespace.ContainingType.PropertyName
/// </summary>
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();
}

/// <summary>
/// Generates a documentation comment ID for a method (or constructor).
/// For example:
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -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<string> GetModelNames(ICustomAttributeProvider attributeProvider, string? name)
{
var modelNames = attributeProvider
.GetCustomAttributes(inherit: false)
.OfType<IModelNameProvider>()
.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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ public class TestController : ControllerBase
{
/// <param name="userId">The id of the user.</param>
[HttpGet("{userId}")]
public string Get()
public string Get(int userId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I should have given this test a better name. It's added to make sure the xml document generator works for the case where the parameter is omitted or added. See #63872

Copy link
Member Author

@khellang khellang Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sorry about that. Will revert and move to another test case. Should I rename it to something clearer?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine if you rename it. Or maybe we can just add a comment in the description of that controller, to keep the diff a bit smaller in regards to snapshots

{
return "Hello, World!";
}
Expand All @@ -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
{
/// <param name="userId">The id of the user.</param>
[HttpGet("{user_id}")]
public string Get([FromRoute(Name = "user_id")] int userId)
{
return "Hello, World!";
}

[HttpGet]
public IEnumerable<Person> Search(Query query)
{
return [];
}
}

public partial class Program {}

public record Person(int Id, string Name);

public class Query
{
/// <summary>
/// The full name of the person.
/// </summary>
[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
{
/// <param name="cancellationToken">The cancellation token.</param>
[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);
});
}
}
Loading
Loading