Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>();
actualSchema.Metadata[OpenApiConstants.RefDescriptionAnnotation] = description;
}
else
{
actualSchema.Description = description;
}
}
else if (schema is OpenApiSchemaReference schemaReference)
{
schemaReference.Description = description;
}
}

private async Task<OpenApiRequestBody?> GetRequestBodyAsync(OpenApiDocument document, ApiDescription description, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, CancellationToken cancellationToken)
{
// Only one parameter can be bound from the body in each request.
Expand Down Expand Up @@ -601,6 +621,12 @@ private async Task<OpenApiRequestBody> 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))
Expand Down Expand Up @@ -689,7 +715,15 @@ private async Task<OpenApiRequestBody> GetFormRequestBody(
var propertySchema = new OpenApiSchema { Type = JsonSchemaType.Object, Properties = new Dictionary<string, IOpenApiSchema>() };
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);
Expand All @@ -698,8 +732,16 @@ private async Task<OpenApiRequestBody> 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<string, IOpenApiSchema>();
schema.Properties[description.Name] = await _componentService.GetOrCreateSchemaAsync(document, description.Type, scopedServiceProvider, schemaTransformers, description, cancellationToken: cancellationToken);
schema.Properties[description.Name] = propSchema;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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; }
}
}
Loading