From 7b8d4404e46c3cea6869255a776ad35d25f3320d Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Wed, 1 Oct 2025 22:18:15 +0200 Subject: [PATCH 1/2] Fix paths without binding in Minimal Api's not discovered from the api explorer Paths without a binding would be omitted by the EndpointMetadataApiDescriptionProvider. Some tests around OpenApi required fixing as arrays are not supported in paths. Fixes #63883 --- .../EndpointMetadataApiDescriptionProvider.cs | 23 + .../OperationTests.MinimalApis.cs | 41 ++ ...ApiXmlCommentSupport.generated.verified.cs | 609 ++++++++++++++++++ .../OpenApiSchemaService.ParameterSchemas.cs | 145 ++++- 4 files changed, 801 insertions(+), 17 deletions(-) create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index cdd33a92fe22..61547e035729 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -114,6 +114,7 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string var hasBodyOrFormFileParameter = false; var parameters = routeEndpoint.Metadata.GetOrderedMetadata(); + var remainingRouteParameters = routeEndpoint.RoutePattern.Parameters.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); foreach (var parameter in parameters) { @@ -122,6 +123,10 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string if (parameterDescription is { }) { apiDescription.ParameterDescriptions.Add(parameterDescription); + if (parameterDescription.Source == BindingSource.Path) + { + remainingRouteParameters.Remove(parameterDescription.Name); + } hasBodyOrFormFileParameter |= parameterDescription.Source == BindingSource.Body || @@ -129,6 +134,24 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string } } + // Add any remaining route parameters that weren't associated with a parameter in the delegate. + foreach (var remainingRouteParameter in remainingRouteParameters.Values) + { + var parameterDescription = new ApiParameterDescription + { + Name = remainingRouteParameter.Name, + Source = BindingSource.Path, + IsRequired = true, + RouteInfo = new ApiParameterRouteInfo + { + IsOptional = false, + Constraints = Array.Empty(), + DefaultValue = null, + }, + }; + apiDescription.ParameterDescriptions.Add(parameterDescription); + } + // Get IAcceptsMetadata. var acceptsMetadata = routeEndpoint.Metadata.GetMetadata(); if (acceptsMetadata is not null) 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 dad4380e2f9a..ff0f0bbcf46c 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 @@ -530,4 +530,45 @@ await SnapshotTestHelper.VerifyOpenApi(compilation, document => Assert.Equal("Property with only value documentation.", valueOnlyParam2.Description); }); } + + [Fact] + public async Task SupportsRouteParametersFromMinimalApis() + { + var source = """ +using System; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Http; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.MapGet("/{userId}", RouteHandlerExtensionMethods.Get); + +app.Run(); + +public static class RouteHandlerExtensionMethods +{ + /// The id of the user. + public static string Get() + { + return "Hello, World!"; + } +} +"""; + + var generator = new XmlCommentGenerator(); + await SnapshotTestHelper.Verify(source, generator, out var compilation); + await SnapshotTestHelper.VerifyOpenApi(compilation, document => + { + var path = document.Paths["/{userId}"].Operations[HttpMethod.Get]; + Assert.NotEmpty(path.Parameters); + Assert.Equal("The id of the user.", path.Parameters[0].Description); + }); + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..2a47a8ca7827 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsRouteParametersFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,609 @@ +//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.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:RouteHandlerExtensionMethods.Get", 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 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: + /// 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); + var operationParameter = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name); + 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.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); + 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 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.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs index 2903064bf31c..4b08fd1eb1cd 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs @@ -29,7 +29,6 @@ public partial class OpenApiSchemaServiceTests : OpenApiDocumentServiceTestBase [(string id) => {}, JsonSchemaType.String, null], [(char id) => {}, JsonSchemaType.String, "char"], [(byte id) => {}, JsonSchemaType.Integer, "uint8"], - [(byte[] id) => {}, JsonSchemaType.String, "byte"], [(short id) => {}, JsonSchemaType.Integer, "int16"], [(ushort id) => {}, JsonSchemaType.Integer, "uint16"], [(uint id) => {}, JsonSchemaType.Integer, "uint32"], @@ -46,7 +45,6 @@ public partial class OpenApiSchemaServiceTests : OpenApiDocumentServiceTestBase [(string? id) => {}, JsonSchemaType.String, null], [(char? id) => {}, JsonSchemaType.String, "char"], [(byte? id) => {}, JsonSchemaType.Integer, "uint8"], - [(byte[]? id) => {}, JsonSchemaType.String, "byte"], [(short? id) => {}, JsonSchemaType.Integer, "int16"], [(ushort? id) => {}, JsonSchemaType.Integer, "uint16"], [(uint? id) => {}, JsonSchemaType.Integer, "uint32"], @@ -72,6 +70,68 @@ await VerifyOpenApiDocument(builder, document => { var operation = document.Paths["/api/{id}"].Operations[HttpMethod.Get]; var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Path, parameter.In); + Assert.Equal(schemaType, parameter.Schema.Type); + Assert.Equal(schemaFormat, parameter.Schema.Format); + }); + } + +#nullable enable + public static object?[][] QueryParametersWithPrimitiveTypes => + [ + [(int query) => {}, JsonSchemaType.Integer, "int32"], + [(long query) => {}, JsonSchemaType.Integer, "int64"], + [(float query) => {}, JsonSchemaType.Number, "float"], + [(double query) => {}, JsonSchemaType.Number, "double"], + [(decimal query) => {}, JsonSchemaType.Number, "double"], + [(bool query) => {}, JsonSchemaType.Boolean, null], + [(string query) => {}, JsonSchemaType.String, null], + [(char query) => {}, JsonSchemaType.String, "char"], + [(byte query) => {}, JsonSchemaType.Integer, "uint8"], + [(byte[] query) => {}, JsonSchemaType.String, "byte"], + [(short query) => {}, JsonSchemaType.Integer, "int16"], + [(ushort query) => {}, JsonSchemaType.Integer, "uint16"], + [(uint query) => {}, JsonSchemaType.Integer, "uint32"], + [(ulong query) => {}, JsonSchemaType.Integer, "uint64"], + [(Uri query) => {}, JsonSchemaType.String, "uri"], + [(TimeOnly query) => {}, JsonSchemaType.String, "time"], + [(DateOnly query) => {}, JsonSchemaType.String, "date"], + [(int? query) => {}, JsonSchemaType.Integer, "int32"], + [(long? query) => {}, JsonSchemaType.Integer, "int64"], + [(float? query) => {}, JsonSchemaType.Number, "float"], + [(double? query) => {}, JsonSchemaType.Number, "double"], + [(decimal? query) => {}, JsonSchemaType.Number, "double"], + [(bool? query) => {}, JsonSchemaType.Boolean, null], + [(string? query) => {}, JsonSchemaType.String, null], + [(char? query) => {}, JsonSchemaType.String, "char"], + [(byte? query) => {}, JsonSchemaType.Integer, "uint8"], + [(byte[]? query) => {}, JsonSchemaType.String, "byte"], + [(short? query) => {}, JsonSchemaType.Integer, "int16"], + [(ushort? query) => {}, JsonSchemaType.Integer, "uint16"], + [(uint? query) => {}, JsonSchemaType.Integer, "uint32"], + [(ulong? query) => {}, JsonSchemaType.Integer, "uint64"], + [(Uri? query) => {}, JsonSchemaType.String, "uri"], + [(TimeOnly? query) => {}, JsonSchemaType.String, "time"], + [(DateOnly? query) => {}, JsonSchemaType.String, "date"] + ]; +#nullable restore + + [Theory] + [MemberData(nameof(QueryParametersWithPrimitiveTypes))] + public async Task GetOpenApiParameters_HandlesQueryParameterWithPrimitiveType(Delegate requestHandler, JsonSchemaType schemaType, string schemaFormat) + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api", requestHandler); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Get]; + var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Query, parameter.In); Assert.Equal(schemaType, parameter.Schema.Type); Assert.Equal(schemaFormat, parameter.Schema.Format); }); @@ -100,6 +160,7 @@ await VerifyOpenApiDocument(builder, document => { var operation = document.Paths["/api/{id}"].Operations[HttpMethod.Get]; var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Path, parameter.In); Assert.Equal(schemaType, parameter.Schema.Type); Assert.Equal(schemaFormat, parameter.Schema.Format); }); @@ -144,6 +205,7 @@ await VerifyOpenApiDocument(builder, document => var operation = path.Value.Operations[HttpMethod.Get]; var parameter = Assert.Single(operation.Parameters); Assert.Equal(type, parameter.Schema.Type); + Assert.Equal(ParameterLocation.Path, parameter.In); Assert.Equal(format, parameter.Schema.Format); Assert.Equal(minimum?.ToString(CultureInfo.InvariantCulture), parameter.Schema.Minimum); Assert.Equal(maximum?.ToString(CultureInfo.InvariantCulture), parameter.Schema.Maximum); @@ -160,21 +222,21 @@ await VerifyOpenApiDocument(builder, document => [(float id = 3f) => { }, (JsonNode defaultValue) => Assert.Equal(3, defaultValue.GetValue())], [(string id = "test") => { }, (JsonNode defaultValue) => Assert.Equal("test", defaultValue.GetValue())], [(bool id = true) => { }, (JsonNode defaultValue) => Assert.True(defaultValue.GetValue())], - [(TaskStatus status = TaskStatus.Canceled) => { }, (JsonNode defaultValue) => Assert.Equal(6, defaultValue.GetValue())], + [(TaskStatus id = TaskStatus.Canceled) => { }, (JsonNode defaultValue) => Assert.Equal(6, defaultValue.GetValue())], // Default value for enums is serialized as string when a converter is registered. - [(Status status = Status.Pending) => { }, (JsonNode defaultValue) => Assert.Equal("Pending", defaultValue.GetValue())], + [(Status id = Status.Pending) => { }, (JsonNode defaultValue) => Assert.Equal("Pending", defaultValue.GetValue())], [([DefaultValue(2)] int id) => { }, (JsonNode defaultValue) => Assert.Equal(2, defaultValue.GetValue())], [([DefaultValue(3f)] float id) => { }, (JsonNode defaultValue) => Assert.Equal(3, defaultValue.GetValue())], [([DefaultValue("test")] string id) => { }, (JsonNode defaultValue) => Assert.Equal("test", defaultValue.GetValue())], [([DefaultValue(true)] bool id) => { }, (JsonNode defaultValue) => Assert.True(defaultValue.GetValue())], - [([DefaultValue(TaskStatus.Canceled)] TaskStatus status) => { }, (JsonNode defaultValue) => Assert.Equal(6, defaultValue.GetValue())], - [([DefaultValue(Status.Pending)] Status status) => { }, (JsonNode defaultValue) => Assert.Equal("Pending", defaultValue.GetValue())], + [([DefaultValue(TaskStatus.Canceled)] TaskStatus id) => { }, (JsonNode defaultValue) => Assert.Equal(6, defaultValue.GetValue())], + [([DefaultValue(Status.Pending)] Status id) => { }, (JsonNode defaultValue) => Assert.Equal("Pending", defaultValue.GetValue())], [([DefaultValue(null)] int? id) => { }, (JsonNode defaultValue) => Assert.True(defaultValue is null)], [([DefaultValue(2)] int? id) => { }, (JsonNode defaultValue) => Assert.Equal(2, defaultValue.GetValue())], [([DefaultValue(null)] string? id) => { }, (JsonNode defaultValue) => Assert.True(defaultValue is null)], [([DefaultValue("foo")] string? id) => { }, (JsonNode defaultValue) => Assert.Equal("foo", defaultValue.GetValue())], - [([DefaultValue(null)] TaskStatus? status) => { }, (JsonNode defaultValue) => Assert.True(defaultValue is null)], - [([DefaultValue(TaskStatus.Canceled)] TaskStatus? status) => { }, (JsonNode defaultValue) => Assert.Equal(6, defaultValue.GetValue())], + [([DefaultValue(null)] TaskStatus? id) => { }, (JsonNode defaultValue) => Assert.True(defaultValue is null)], + [([DefaultValue(TaskStatus.Canceled)] TaskStatus? id) => { }, (JsonNode defaultValue) => Assert.Equal(6, defaultValue.GetValue())], ]; [Theory] @@ -192,6 +254,7 @@ await VerifyOpenApiDocument(builder, document => { var operation = document.Paths!["/api/{id}"].Operations![HttpMethod.Post]; var parameter = Assert.Single(operation.Parameters ?? []); + Assert.Equal(ParameterLocation.Path, parameter.In); var openApiDefault = parameter.Schema!.Default; assert(openApiDefault!); }); @@ -213,6 +276,7 @@ await VerifyOpenApiDocument(builder, document => { var operation = document.Paths["/api"].Operations[HttpMethod.Get]; var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Query, parameter.In); Assert.Equal(JsonSchemaType.Integer, parameter.Schema.Type); Assert.Null(parameter.Schema.Enum); }); @@ -233,6 +297,7 @@ await VerifyOpenApiDocument(builder, document => { var operation = document.Paths["/api"].Operations[HttpMethod.Get]; var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Query, parameter.In); Assert.Null(parameter.Schema.Type); Assert.Collection(parameter.Schema.Enum, value => @@ -292,12 +357,14 @@ await VerifyOpenApiDocument(action, document => { Assert.Equal("Id", parameter.Name); Assert.Equal(JsonSchemaType.String, parameter.Schema.Type); + Assert.Equal(ParameterLocation.Path, parameter.In); Assert.Equal("uuid", parameter.Schema.Format); }, parameter => { Assert.Equal("Date", parameter.Name); Assert.Equal(JsonSchemaType.String, parameter.Schema.Type); + Assert.Equal(ParameterLocation.Path, parameter.In); Assert.Equal("date-time", parameter.Schema.Format); }); }); @@ -317,12 +384,14 @@ await VerifyOpenApiDocument(action, document => { Assert.Equal("Id", parameter.Name); Assert.Equal(JsonSchemaType.String, parameter.Schema.Type); + Assert.Equal(ParameterLocation.Path, parameter.In); Assert.Equal("uuid", parameter.Schema.Format); }, parameter => { Assert.Equal("Name", parameter.Name); Assert.Equal(JsonSchemaType.String, parameter.Schema.Type); + Assert.Equal(ParameterLocation.Path, parameter.In); Assert.Equal(5, parameter.Schema.MaxLength); }); }); @@ -332,19 +401,17 @@ await VerifyOpenApiDocument(action, document => [ [([MaxLength(5)] string id) => {}, (OpenApiSchema schema) => Assert.Equal(5, schema.MaxLength)], [([MinLength(2)] string id) => {}, (OpenApiSchema schema) => Assert.Equal(2, schema.MinLength)], - [([MaxLength(5)] int[] ids) => {}, (OpenApiSchema schema) => Assert.Equal(5, schema.MaxItems)], - [([MinLength(2)] int[] id) => {}, (OpenApiSchema schema) => Assert.Equal(2, schema.MinItems)], - [([Length(4, 8)] int[] id) => {}, (OpenApiSchema schema) => { Assert.Equal(4, schema.MinItems); Assert.Equal(8, schema.MaxItems); }], + [([Length(4, 8)] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(4, schema.MinLength); Assert.Equal(8, schema.MaxLength); }], [([Range(4, 8)]int id) => {}, (OpenApiSchema schema) => { Assert.Equal("4", schema.Minimum); Assert.Equal("8", schema.Maximum); }], [([Range(1234, 5678)]int id) => {}, (OpenApiSchema schema) => { Assert.Equal("1234", schema.Minimum); Assert.Equal("5678", schema.Maximum); }], [([Range(1234.56, 7891.1)] decimal id) => {}, (OpenApiSchema schema) => { Assert.Equal("1234.56", schema.Minimum); Assert.Equal("7891.1", schema.Maximum); }], [([Range(typeof(DateTime), "2024-02-01", "2024-02-031")] DateTime id) => {}, (OpenApiSchema schema) => { Assert.Null(schema.Minimum); Assert.Null(schema.Maximum); }], - [([StringLength(10)] string name) => {}, (OpenApiSchema schema) => { Assert.Equal(10, schema.MaxLength); Assert.Equal(0, schema.MinLength); }], - [([StringLength(10, MinimumLength = 5)] string name) => {}, (OpenApiSchema schema) => { Assert.Equal(10, schema.MaxLength); Assert.Equal(5, schema.MinLength); }], - [([Url] string url) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("uri", schema.Format); }], + [([StringLength(10)] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(10, schema.MaxLength); Assert.Equal(0, schema.MinLength); }], + [([StringLength(10, MinimumLength = 5)] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(10, schema.MaxLength); Assert.Equal(5, schema.MinLength); }], + [([Url] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("uri", schema.Format); }], // Check that multiple attributes get applied correctly - [([Url][StringLength(10)] string url) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("uri", schema.Format); Assert.Equal(10, schema.MaxLength); }], - [([Base64String] string base64string) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("byte", schema.Format); }], + [([Url][StringLength(10)] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("uri", schema.Format); Assert.Equal(10, schema.MaxLength); }], + [([Base64String] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("byte", schema.Format); }], ]; [Theory] @@ -362,6 +429,48 @@ await VerifyOpenApiDocument(builder, document => { var operation = document.Paths["/api/{id}"].Operations[HttpMethod.Get]; var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Path, parameter.In); + var schema = Assert.IsType(parameter.Schema); + verifySchema(schema); + }); + } + + public static object[][] QueryParametersWithValidationAttributes => + [ + [([MaxLength(5)] string id) => {}, (OpenApiSchema schema) => Assert.Equal(5, schema.MaxLength)], + [([MinLength(2)] string id) => {}, (OpenApiSchema schema) => Assert.Equal(2, schema.MinLength)], + [([Length(4, 8)] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(4, schema.MinLength); Assert.Equal(8, schema.MaxLength); }], + [([MaxLength(5)] int[] id) => {}, (OpenApiSchema schema) => Assert.Equal(5, schema.MaxItems)], + [([MinLength(2)] int[] id) => {}, (OpenApiSchema schema) => Assert.Equal(2, schema.MinItems)], + [([Length(4, 8)] int[] id) => {}, (OpenApiSchema schema) => { Assert.Equal(4, schema.MinItems); Assert.Equal(8, schema.MaxItems); }], + [([Range(4, 8)]int id) => {}, (OpenApiSchema schema) => { Assert.Equal("4", schema.Minimum); Assert.Equal("8", schema.Maximum); }], + [([Range(1234, 5678)]int id) => {}, (OpenApiSchema schema) => { Assert.Equal("1234", schema.Minimum); Assert.Equal("5678", schema.Maximum); }], + [([Range(1234.56, 7891.1)] decimal id) => {}, (OpenApiSchema schema) => { Assert.Equal("1234.56", schema.Minimum); Assert.Equal("7891.1", schema.Maximum); }], + [([Range(typeof(DateTime), "2024-02-01", "2024-02-031")] DateTime id) => {}, (OpenApiSchema schema) => { Assert.Null(schema.Minimum); Assert.Null(schema.Maximum); }], + [([StringLength(10)] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(10, schema.MaxLength); Assert.Equal(0, schema.MinLength); }], + [([StringLength(10, MinimumLength = 5)] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(10, schema.MaxLength); Assert.Equal(5, schema.MinLength); }], + [([Url] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("uri", schema.Format); }], + // Check that multiple attributes get applied correctly + [([Url][StringLength(10)] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("uri", schema.Format); Assert.Equal(10, schema.MaxLength); }], + [([Base64String] string id) => {}, (OpenApiSchema schema) => { Assert.Equal(JsonSchemaType.String, schema.Type); Assert.Equal("byte", schema.Format); }], + ]; + + [Theory] + [MemberData(nameof(QueryParametersWithValidationAttributes))] + public async Task GetOpenApiParameters_HandlesQueryParameterWithValidationAttributes(Delegate requestHandler, Action verifySchema) + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/{id}", requestHandler); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/{id}"].Operations[HttpMethod.Get]; + var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Query, parameter.In); var schema = Assert.IsType(parameter.Schema); verifySchema(schema); }); @@ -392,6 +501,7 @@ await VerifyOpenApiDocument(builder, document => { var operation = document.Paths["/api/{id}"].Operations[HttpMethod.Get]; var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Path, parameter.In); var schema = Assert.IsType(parameter.Schema); verifySchema(schema); }); @@ -492,7 +602,7 @@ await VerifyOpenApiDocument(builder, document => // When the element type is not nullable (int[] ints), the binding // will produce [1, 2, 0, 4] Assert.Equal(JsonSchemaType.Array, parameter.Schema.Type); - + Assert.Equal(ParameterLocation.Query, parameter.In); Assert.True(parameter.Schema.Items.Type?.HasFlag(innerSchemaType)); Assert.Equal(isNullable, parameter.Schema.Items.Type?.HasFlag(JsonSchemaType.Null)); }); @@ -512,6 +622,7 @@ await VerifyOpenApiDocument(builder, document => { var operation = document.Paths["/api"].Operations[HttpMethod.Get]; var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Query, parameter.In); Assert.Equal("The ID of the entity", parameter.Description); }); } From b62c6715da6b826a34a56ce442e2561ca95307c7 Mon Sep 17 00:00:00 2001 From: Sjoerd van der Meer Date: Wed, 1 Oct 2025 22:53:27 +0200 Subject: [PATCH 2/2] Add unit test for omitted path parameters --- ...pointMetadataApiDescriptionProviderTest.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index b58fbefa53b6..8773df91c647 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -767,6 +767,33 @@ static void AssertPathParameter(ApiDescription apiDescription) AssertPathParameter(GetApiDescription((TryParseStringRecordStruct foo) => { }, "/{foo}")); } + [Fact] + public void AddsFromRouteParameterAsPathWhenOmittedInHandler() + { + static void AssertPathParameter(ApiDescription apiDescription) + { + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal("foo", param.Name); + Assert.Equal(BindingSource.Path, param.Source); + } + + AssertPathParameter(GetApiDescription(() => { }, "/{foo}")); + } + + [Fact] + public void AddsFromRouteParameterAsPathOnceRegardlessOfCasing() + { + static void AssertPathParameter(ApiDescription apiDescription) + { + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(typeof(int), param.Type); + Assert.Equal(typeof(int), param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Path, param.Source); + } + + AssertPathParameter(GetApiDescription((int foo) => { }, "/{FOO}")); + } + [Fact] public void AddsFromQueryParameterAsQuery() {