diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index a2b070ae7981..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. @@ -601,6 +621,12 @@ 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) + { + ApplyDescriptionToSchema(parameterSchema, parameterDescription); + } + // 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 +715,15 @@ 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) + { + ApplyDescriptionToSchema(propSchema, parameterDescription); + } + + propertySchema.Properties[description.Name] = propSchema; } schema.AllOf ??= []; schema.AllOf.Add(propertySchema); @@ -698,8 +732,16 @@ 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) + { + ApplyDescriptionToSchema(propSchema, parameterDescription); + } + 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/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; } + } }