From 3ade4fb04b725a5059c18defecd828a644d53fe3 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 29 Jul 2025 12:57:32 +0100 Subject: [PATCH 1/2] [OpenAPI] Get description with [FromQuery] Get the description from the associated object's property when `[FromQuery]` is applied to a property of an object used as a `[FromQuery]` parameter. Resolves #61297. --- .../src/Services/OpenApiDocumentService.cs | 21 ++++++-- .../OpenApiDocumentServiceTestsBase.cs | 2 +- .../OpenApiSchemaService.ParameterSchemas.cs | 52 +++++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index d8676530f00e..97ae0da5a96d 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -494,11 +494,22 @@ private static bool IsRequired(ApiParameterDescription parameter) } // Apply [Description] attributes on the parameter to the top-level OpenApiParameter object and not the schema. - private static string? GetParameterDescriptionFromAttribute(ApiParameterDescription parameter) => - parameter.ParameterDescriptor is IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo } && - parameterInfo.GetCustomAttributes().OfType().LastOrDefault() is { } descriptionAttribute ? - descriptionAttribute.Description : - null; + private static string? GetParameterDescriptionFromAttribute(ApiParameterDescription parameter) + { + if (parameter.ParameterDescriptor is IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo } && + parameterInfo.GetCustomAttributes().LastOrDefault() is { } parameterDescription) + { + return parameterDescription.Description; + } + + if (parameter.ModelMetadata is Mvc.ModelBinding.Metadata.DefaultModelMetadata { Attributes.PropertyAttributes.Count: > 0 } metadata && + metadata.Attributes.PropertyAttributes.OfType().LastOrDefault() is { } propertyDescription) + { + return propertyDescription.Description; + } + + return null; + } private async Task GetRequestBodyAsync(OpenApiDocument document, ApiDescription description, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, CancellationToken cancellationToken) { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs index f18197120b86..9ab90d9f52a0 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs @@ -229,7 +229,7 @@ public ControllerActionDescriptor CreateActionDescriptor(string methodName = nul action.AttributeRouteInfo = new() { - Template = action.MethodInfo.GetCustomAttribute()?.Template, + Template = action.MethodInfo.GetCustomAttribute()?.Template ?? string.Empty, Name = action.MethodInfo.GetCustomAttribute()?.Name, Order = action.MethodInfo.GetCustomAttribute()?.Order ?? 0, }; 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 7929b27f0eca..58d2b803d880 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 @@ -509,6 +509,39 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiParameters_HandlesAsParametersParametersWithDescriptionAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api", ([AsParameters] FromQueryModel model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Get]; + var parameter = Assert.Single(operation.Parameters); + Assert.Equal("The ID of the entity", parameter.Description); + }); + } + + [Fact] + public async Task GetOpenApiParameters_HandlesFromQueryParametersWithDescriptionAttribute() + { + // Arrange + var actionDescriptor = CreateActionDescriptor(nameof(TestFromQueryController.GetWithFromQueryDto), typeof(TestFromQueryController)); + + // Assert + await VerifyOpenApiDocument(actionDescriptor, document => + { + var operation = document.Paths["/"].Operations[HttpMethod.Get]; + var parameter = Assert.Single(operation.Parameters); + Assert.Equal("The ID of the entity", parameter.Description); + }); + } + [Route("/api/{id}/{date}")] private void AcceptsParametersInModel(RouteParamsContainer model) { } @@ -809,4 +842,23 @@ public override void Write(Utf8JsonWriter writer, EnumArrayType value, JsonSeria writer.WriteEndObject(); } } + + [ApiController] + [Route("[controller]/[action]")] + private class TestFromQueryController : ControllerBase + { + [HttpGet] + public Task GetWithFromQueryDto([FromQuery] FromQueryModel query) + { + return Task.FromResult(Ok()); + } + } + + [Description("A query model.")] + private record FromQueryModel + { + [Description("The ID of the entity")] + [FromQuery(Name = "id")] + public int Id { get; set; } + } } From 832c45a99206aa8f93570215dc367b511d3ec073 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 Aug 2025 12:22:00 +0100 Subject: [PATCH 2/2] [OpenAPI] Get default value with [FromQuery] Get the default value from the associated object's property when `[FromQuery]` is applied to a property of an object used as a `[FromQuery]` parameter. Resolves #61934. --- .../Extensions/JsonNodeSchemaExtensions.cs | 5 ++ .../OpenApiSchemaService.ParameterSchemas.cs | 60 +++++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index bde53bfd7f2e..8692f0b7dfc6 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -309,6 +309,11 @@ internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescri var attributes = validations.OfType(); schema.ApplyValidationAttributes(attributes); } + if (parameterDescription.ModelMetadata is Mvc.ModelBinding.Metadata.DefaultModelMetadata { Attributes.PropertyAttributes.Count: > 0 } metadata && + metadata.Attributes.PropertyAttributes.OfType().LastOrDefault() is { } metadataDefaultValueAttribute) + { + schema.ApplyDefaultValue(metadataDefaultValueAttribute.Value, jsonTypeInfo); + } if (parameterDescription.ParameterDescriptor is IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo }) { if (parameterInfo.HasDefaultValue) 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 58d2b803d880..ede9a0b3d1b2 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 @@ -522,8 +522,7 @@ public async Task GetOpenApiParameters_HandlesAsParametersParametersWithDescript await VerifyOpenApiDocument(builder, document => { var operation = document.Paths["/api"].Operations[HttpMethod.Get]; - var parameter = Assert.Single(operation.Parameters); - Assert.Equal("The ID of the entity", parameter.Description); + Assert.Contains(operation.Parameters, actualMemory => actualMemory.Name == "id" && actualMemory.Description == "The ID of the entity"); }); } @@ -537,8 +536,56 @@ public async Task GetOpenApiParameters_HandlesFromQueryParametersWithDescription await VerifyOpenApiDocument(actionDescriptor, document => { var operation = document.Paths["/"].Operations[HttpMethod.Get]; - var parameter = Assert.Single(operation.Parameters); - Assert.Equal("The ID of the entity", parameter.Description); + Assert.Contains(operation.Parameters, actualMemory => actualMemory.Name == "id" && actualMemory.Description == "The ID of the entity"); + }); + } + + [Fact] + public async Task GetOpenApiParameters_HandlesAsParametersParametersWithDefaultValueAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api", ([AsParameters] FromQueryModel model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Get]; + Assert.Contains( + operation.Parameters, + actualMemory => + { + return actualMemory.Name == "limit" && + actualMemory.Schema != null && + actualMemory.Schema.Default != null && + actualMemory.Schema.Default.GetValueKind() == JsonValueKind.Number && + actualMemory.Schema.Default.GetValue() == 20; + }); + }); + } + + [Fact] + public async Task GetOpenApiParameters_HandlesFromQueryParametersWithDefaultValueAttribute() + { + // Arrange + var actionDescriptor = CreateActionDescriptor(nameof(TestFromQueryController.GetWithFromQueryDto), typeof(TestFromQueryController)); + + // Assert + await VerifyOpenApiDocument(actionDescriptor, document => + { + var operation = document.Paths["/"].Operations[HttpMethod.Get]; + Assert.Contains( + operation.Parameters, + actualMemory => + { + return actualMemory.Name == "limit" && + actualMemory.Schema != null && + actualMemory.Schema.Default != null && + actualMemory.Schema.Default.GetValueKind() == JsonValueKind.Number && + actualMemory.Schema.Default.GetValue() == 20; + }); }); } @@ -860,5 +907,10 @@ private record FromQueryModel [Description("The ID of the entity")] [FromQuery(Name = "id")] public int Id { get; set; } + + [Description("The maximum number of results")] + [FromQuery(Name = "limit")] + [DefaultValue(20)] + public int Limit { get; set; } } }