From c4e65f63c25d9a54e898ed3620208c510e9c2dd4 Mon Sep 17 00:00:00 2001 From: AbdelrahmanHassan131 Date: Tue, 23 Dec 2025 16:42:17 +0200 Subject: [PATCH 1/2] fix: Ensure OpenAPI request bodies include (#59042) `DescriptionAttribute` for `[FromForm]` parameters and add verification tests. --- .../src/Services/OpenApiDocumentService.cs | 38 +++++- .../ReproduceIssue59042Tests.cs | 121 ++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/ReproduceIssue59042Tests.cs diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index a2b070ae7981..7797c0242eb1 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -601,6 +601,16 @@ private async Task GetFormRequestBody( { var description = parameter.Single(); var parameterSchema = await _componentService.GetOrCreateSchemaAsync(document, description.Type, scopedServiceProvider, schemaTransformers, description, cancellationToken: cancellationToken); + + if (GetParameterDescriptionFromAttribute(description) is { } parameterDescription) + { + parameterSchema = new OpenApiSchema + { + Description = parameterDescription, + AllOf = [parameterSchema] + }; + } + // Form files are keyed by their parameter name so we must capture the parameter name // as a property in the schema. if (description.Type == typeof(IFormFile) || description.Type == typeof(IFormFileCollection)) @@ -689,7 +699,19 @@ private async Task GetFormRequestBody( var propertySchema = new OpenApiSchema { Type = JsonSchemaType.Object, Properties = new Dictionary() }; foreach (var description in parameter) { - propertySchema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(document, description.Type, scopedServiceProvider, schemaTransformers, description, cancellationToken: cancellationToken); + var propSchema = await _componentService.GetOrCreateSchemaAsync(document, description.Type, scopedServiceProvider, schemaTransformers, description, cancellationToken: cancellationToken); + + // Apply description from [Description] attribute if present + if (GetParameterDescriptionFromAttribute(description) is { } parameterDescription) + { + propSchema = new OpenApiSchema + { + Description = parameterDescription, + AllOf = [propSchema] + }; + } + + propertySchema.Properties[description.Name] = propSchema; } schema.AllOf ??= []; schema.AllOf.Add(propertySchema); @@ -698,8 +720,20 @@ private async Task GetFormRequestBody( { foreach (var description in parameter) { + var propSchema = await _componentService.GetOrCreateSchemaAsync(document, description.Type, scopedServiceProvider, schemaTransformers, description, cancellationToken: cancellationToken); + + // Apply description from [Description] attribute if present + if (GetParameterDescriptionFromAttribute(description) is { } parameterDescription) + { + propSchema = new OpenApiSchema + { + Description = parameterDescription, + AllOf = [propSchema] + }; + } + schema.Properties ??= new Dictionary(); - schema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(document, description.Type, scopedServiceProvider, schemaTransformers, description, cancellationToken: cancellationToken); + schema.Properties[description.Name] = propSchema; } } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/ReproduceIssue59042Tests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/ReproduceIssue59042Tests.cs new file mode 100644 index 000000000000..9e3486170e34 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/ReproduceIssue59042Tests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Net.Http; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; + +public class ReproduceIssue59042Tests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormProperty() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/form", ([FromForm] FormWithDescription form) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = operation.RequestBody.Content; + + // Check multipart/form-data + Assert.Contains("multipart/form-data", content.Keys); + var multipartMediaType = content["multipart/form-data"]; + Assert.NotNull(multipartMediaType.Schema); + Assert.NotNull(multipartMediaType.Schema.Properties); + + Assert.Contains("name", multipartMediaType.Schema.Properties); + var nameProperty = multipartMediaType.Schema.Properties["name"]; + Assert.Equal("The name of the item", nameProperty.Description); + + Assert.Contains("file", multipartMediaType.Schema.Properties); + var fileProperty = multipartMediaType.Schema.Properties["file"]; + Assert.Equal("The file to upload", fileProperty.Description); + + // Check application/x-www-form-urlencoded + Assert.Contains("application/x-www-form-urlencoded", content.Keys); + var urlEncodedMediaType = content["application/x-www-form-urlencoded"]; + Assert.NotNull(urlEncodedMediaType.Schema); + Assert.NotNull(urlEncodedMediaType.Schema.Properties); + + Assert.Contains("name", urlEncodedMediaType.Schema.Properties); + var urlEncodedNameProperty = urlEncodedMediaType.Schema.Properties["name"]; + Assert.Equal("The name of the item", urlEncodedNameProperty.Description); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormParameter() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/form-param", ([FromForm, Description("The ID")] int id) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = operation.RequestBody.Content; + + var mediaType = content["multipart/form-data"]; + Assert.NotNull(mediaType.Schema); + Assert.NotNull(mediaType.Schema.Properties); + + Assert.Contains("id", mediaType.Schema.Properties); + var property = mediaType.Schema.Properties["id"]; + + Assert.Equal("The ID", property.Description); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormComplexParameter() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/form-complex", ([FromForm, Description("The Complex Object")] FormWithDescription form) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = operation.RequestBody.Content; + + var mediaType = content["multipart/form-data"]; + Assert.NotNull(mediaType.Schema); + Assert.NotNull(mediaType.Schema.AllOf); + + Assert.Equal("The Complex Object", mediaType.Schema.Description ?? mediaType.Schema.AllOf.FirstOrDefault(s => s.Description != null)?.Description); + }); + } + + private class FormWithDescription + { + [Description("The name of the item")] + public string Name { get; set; } + + [Description("The file to upload")] + public IFormFile File { get; set; } + } +} From 76193f040fbe68d91d226962b43e202f7c695887 Mon Sep 17 00:00:00 2001 From: AbdelrahmanHassan131 Date: Fri, 26 Dec 2025 17:37:04 +0200 Subject: [PATCH 2/2] Address review feedback for #59042 --- .../src/Services/OpenApiDocumentService.cs | 38 +++--- .../OpenApiDocumentServiceTests.Parameters.cs | 109 ++++++++++++++++ .../ReproduceIssue59042Tests.cs | 121 ------------------ 3 files changed, 132 insertions(+), 136 deletions(-) delete mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/ReproduceIssue59042Tests.cs diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 7797c0242eb1..2ba8f58f7e08 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -536,6 +536,26 @@ private static bool IsRequired(ApiParameterDescription parameter) return null; } + private static void ApplyDescriptionToSchema(IOpenApiSchema schema, string description) + { + if (schema is OpenApiSchema actualSchema) + { + if (actualSchema.IsComponentizedSchema()) + { + actualSchema.Metadata ??= new Dictionary(); + actualSchema.Metadata[OpenApiConstants.RefDescriptionAnnotation] = description; + } + else + { + actualSchema.Description = description; + } + } + else if (schema is OpenApiSchemaReference schemaReference) + { + schemaReference.Description = description; + } + } + private async Task GetRequestBodyAsync(OpenApiDocument document, ApiDescription description, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, CancellationToken cancellationToken) { // Only one parameter can be bound from the body in each request. @@ -604,11 +624,7 @@ private async Task GetFormRequestBody( if (GetParameterDescriptionFromAttribute(description) is { } parameterDescription) { - parameterSchema = new OpenApiSchema - { - Description = parameterDescription, - AllOf = [parameterSchema] - }; + ApplyDescriptionToSchema(parameterSchema, parameterDescription); } // Form files are keyed by their parameter name so we must capture the parameter name @@ -704,11 +720,7 @@ private async Task GetFormRequestBody( // Apply description from [Description] attribute if present if (GetParameterDescriptionFromAttribute(description) is { } parameterDescription) { - propSchema = new OpenApiSchema - { - Description = parameterDescription, - AllOf = [propSchema] - }; + ApplyDescriptionToSchema(propSchema, parameterDescription); } propertySchema.Properties[description.Name] = propSchema; @@ -725,11 +737,7 @@ private async Task GetFormRequestBody( // Apply description from [Description] attribute if present if (GetParameterDescriptionFromAttribute(description) is { } parameterDescription) { - propSchema = new OpenApiSchema - { - Description = parameterDescription, - AllOf = [propSchema] - }; + ApplyDescriptionToSchema(propSchema, parameterDescription); } schema.Properties ??= new Dictionary(); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Parameters.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Parameters.cs index faa002fb973f..1427a1f27282 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Parameters.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Parameters.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; using System.Net.Http; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -216,4 +217,112 @@ public Task BindModelAsync(ModelBindingContext bindingContext) return Task.CompletedTask; } } + + [Fact] + public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormProperty() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/form", ([FromForm] FormWithDescription form) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = operation.RequestBody.Content; + + // Check multipart/form-data + Assert.Contains("multipart/form-data", content.Keys); + var multipartMediaType = content["multipart/form-data"]; + Assert.NotNull(multipartMediaType.Schema); + Assert.NotNull(multipartMediaType.Schema.Properties); + + Assert.Contains("name", multipartMediaType.Schema.Properties); + var nameProperty = multipartMediaType.Schema.Properties["name"]; + Assert.Equal("The name of the item", nameProperty.Description); + + Assert.Contains("file", multipartMediaType.Schema.Properties); + var fileProperty = multipartMediaType.Schema.Properties["file"]; + Assert.Equal("The file to upload", fileProperty.Description); + + // Check application/x-www-form-urlencoded + Assert.Contains("application/x-www-form-urlencoded", content.Keys); + var urlEncodedMediaType = content["application/x-www-form-urlencoded"]; + Assert.NotNull(urlEncodedMediaType.Schema); + Assert.NotNull(urlEncodedMediaType.Schema.Properties); + + Assert.Contains("name", urlEncodedMediaType.Schema.Properties); + var urlEncodedNameProperty = urlEncodedMediaType.Schema.Properties["name"]; + Assert.Equal("The name of the item", urlEncodedNameProperty.Description); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormParameter() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/form-param", ([FromForm, Description("The ID")] int id) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = operation.RequestBody.Content; + + var mediaType = content["multipart/form-data"]; + Assert.NotNull(mediaType.Schema); + Assert.NotNull(mediaType.Schema.Properties); + + Assert.Contains("id", mediaType.Schema.Properties); + var property = mediaType.Schema.Properties["id"]; + + Assert.Equal("The ID", property.Description); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormComplexParameter() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/form-complex", ([FromForm, Description("The Complex Object")] FormWithDescription form) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = operation.RequestBody.Content; + + var mediaType = content["multipart/form-data"]; + Assert.NotNull(mediaType.Schema); + + // With x-ref-description approach, description is applied to the schema reference + Assert.Equal("The Complex Object", mediaType.Schema.Description); + }); + } + + private class FormWithDescription + { + [Description("The name of the item")] + public string Name { get; set; } + + [Description("The file to upload")] + public IFormFile File { get; set; } + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/ReproduceIssue59042Tests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/ReproduceIssue59042Tests.cs deleted file mode 100644 index 9e3486170e34..000000000000 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/ReproduceIssue59042Tests.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.ComponentModel; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System.Net.Http; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.Extensions.DependencyInjection; - -public class ReproduceIssue59042Tests : OpenApiDocumentServiceTestBase -{ - [Fact] - public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormProperty() - { - // Arrange - var builder = CreateBuilder(); - - // Act - builder.MapPost("/form", ([FromForm] FormWithDescription form) => { }); - - // Assert - await VerifyOpenApiDocument(builder, document => - { - var paths = Assert.Single(document.Paths.Values); - var operation = paths.Operations[HttpMethod.Post]; - Assert.NotNull(operation.RequestBody); - Assert.NotNull(operation.RequestBody.Content); - var content = operation.RequestBody.Content; - - // Check multipart/form-data - Assert.Contains("multipart/form-data", content.Keys); - var multipartMediaType = content["multipart/form-data"]; - Assert.NotNull(multipartMediaType.Schema); - Assert.NotNull(multipartMediaType.Schema.Properties); - - Assert.Contains("name", multipartMediaType.Schema.Properties); - var nameProperty = multipartMediaType.Schema.Properties["name"]; - Assert.Equal("The name of the item", nameProperty.Description); - - Assert.Contains("file", multipartMediaType.Schema.Properties); - var fileProperty = multipartMediaType.Schema.Properties["file"]; - Assert.Equal("The file to upload", fileProperty.Description); - - // Check application/x-www-form-urlencoded - Assert.Contains("application/x-www-form-urlencoded", content.Keys); - var urlEncodedMediaType = content["application/x-www-form-urlencoded"]; - Assert.NotNull(urlEncodedMediaType.Schema); - Assert.NotNull(urlEncodedMediaType.Schema.Properties); - - Assert.Contains("name", urlEncodedMediaType.Schema.Properties); - var urlEncodedNameProperty = urlEncodedMediaType.Schema.Properties["name"]; - Assert.Equal("The name of the item", urlEncodedNameProperty.Description); - }); - } - - [Fact] - public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormParameter() - { - // Arrange - var builder = CreateBuilder(); - - // Act - builder.MapPost("/form-param", ([FromForm, Description("The ID")] int id) => { }); - - // Assert - await VerifyOpenApiDocument(builder, document => - { - var paths = Assert.Single(document.Paths.Values); - var operation = paths.Operations[HttpMethod.Post]; - Assert.NotNull(operation.RequestBody); - Assert.NotNull(operation.RequestBody.Content); - var content = operation.RequestBody.Content; - - var mediaType = content["multipart/form-data"]; - Assert.NotNull(mediaType.Schema); - Assert.NotNull(mediaType.Schema.Properties); - - Assert.Contains("id", mediaType.Schema.Properties); - var property = mediaType.Schema.Properties["id"]; - - Assert.Equal("The ID", property.Description); - }); - } - - [Fact] - public async Task GetOpenApiRequestBody_RespectsDescriptionOnFromFormComplexParameter() - { - // Arrange - var builder = CreateBuilder(); - - // Act - builder.MapPost("/form-complex", ([FromForm, Description("The Complex Object")] FormWithDescription form) => { }); - - // Assert - await VerifyOpenApiDocument(builder, document => - { - var paths = Assert.Single(document.Paths.Values); - var operation = paths.Operations[HttpMethod.Post]; - Assert.NotNull(operation.RequestBody); - Assert.NotNull(operation.RequestBody.Content); - var content = operation.RequestBody.Content; - - var mediaType = content["multipart/form-data"]; - Assert.NotNull(mediaType.Schema); - Assert.NotNull(mediaType.Schema.AllOf); - - Assert.Equal("The Complex Object", mediaType.Schema.Description ?? mediaType.Schema.AllOf.FirstOrDefault(s => s.Description != null)?.Description); - }); - } - - private class FormWithDescription - { - [Description("The name of the item")] - public string Name { get; set; } - - [Description("The file to upload")] - public IFormFile File { get; set; } - } -}