Skip to content
Merged
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
17 changes: 16 additions & 1 deletion src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,10 @@ private async Task<OpenApiRequestBody> GetFormRequestBody(

var requestBody = new OpenApiRequestBody
{
Required = formParameters.Any(IsRequired),
// Form bodies are always required because the framework doesn't support
// serializing a form collection from an empty body. Instead, requiredness
// must be set on a per-parameter basis. See below.
Required = true,
Content = new Dictionary<string, OpenApiMediaType>()
};

Expand Down Expand Up @@ -410,6 +413,10 @@ private async Task<OpenApiRequestBody> GetFormRequestBody(
// as a property in the schema.
if (description.Type == typeof(IFormFile) || description.Type == typeof(IFormFileCollection))
{
if (IsRequired(description))
{
schema.Required.Add(description.Name);
}
if (hasMultipleFormParameters)
{
schema.AllOf.Add(new OpenApiSchema
Expand Down Expand Up @@ -444,6 +451,10 @@ private async Task<OpenApiRequestBody> GetFormRequestBody(
}
else
{
if (IsRequired(description))
{
schema.Required.Add(description.Name);
}
schema.AllOf.Add(new OpenApiSchema
{
Type = "object",
Expand All @@ -462,6 +473,10 @@ private async Task<OpenApiRequestBody> GetFormRequestBody(
}
else
{
if (IsRequired(description))
{
schema.Required.Add(description.Name);
}
schema.Properties[description.Name] = parameterSchema;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@
}
}
}
}
},
"required": true
},
"responses": {
"200": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"content": {
"multipart/form-data": {
"schema": {
"required": [
"resume"
],
"type": "object",
"properties": {
"resume": {
Expand Down Expand Up @@ -41,6 +44,9 @@
"content": {
"multipart/form-data": {
"schema": {
"required": [
"files"
],
"type": "object",
"properties": {
"files": {
Expand Down Expand Up @@ -68,6 +74,10 @@
"content": {
"multipart/form-data": {
"schema": {
"required": [
"resume",
"files"
],
"type": "object",
"allOf": [
{
Expand Down Expand Up @@ -135,6 +145,9 @@
"content": {
"multipart/form-data": {
"schema": {
"required": [
"file"
],
"type": "object",
"allOf": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ await VerifyOpenApiDocument(builder, document =>
var paths = Assert.Single(document.Paths.Values);
var operation = paths.Operations[OperationType.Post];
Assert.NotNull(operation.RequestBody);
Assert.False(operation.RequestBody.Required);
Assert.True(operation.RequestBody.Required);
Assert.NotNull(operation.RequestBody.Content);
var content = Assert.Single(operation.RequestBody.Content);
Assert.Equal("multipart/form-data", content.Key);
Expand Down Expand Up @@ -72,7 +72,16 @@ await VerifyOpenApiDocument(builder, document =>
var paths = Assert.Single(document.Paths.Values);
var operation = paths.Operations[OperationType.Post];
Assert.NotNull(operation.RequestBody);
Assert.Equal(!isOptional, operation.RequestBody.Required);
Assert.True(operation.RequestBody.Required);
var schema = operation.RequestBody.Content["multipart/form-data"].Schema;
if (!isOptional)
{
Assert.Contains("formFile", schema.Required);
}
else
{
Assert.DoesNotContain("formFile", schema.Required);
}
});
}
#nullable restore
Expand Down Expand Up @@ -101,7 +110,7 @@ await VerifyOpenApiDocument(builder, document =>
var paths = Assert.Single(document.Paths.Values);
var operation = paths.Operations[OperationType.Post];
Assert.NotNull(operation.RequestBody);
Assert.False(operation.RequestBody.Required);
Assert.True(operation.RequestBody.Required);
Assert.NotNull(operation.RequestBody.Content);
var content = Assert.Single(operation.RequestBody.Content);
Assert.Equal("multipart/form-data", content.Key);
Expand Down Expand Up @@ -140,7 +149,16 @@ await VerifyOpenApiDocument(builder, document =>
var paths = Assert.Single(document.Paths.Values);
var operation = paths.Operations[OperationType.Post];
Assert.NotNull(operation.RequestBody);
Assert.Equal(!isOptional, operation.RequestBody.Required);
Assert.True(operation.RequestBody.Required);
var schema = operation.RequestBody.Content["multipart/form-data"].Schema;
if (!isOptional)
{
Assert.Contains("formFile", schema.Required);
}
else
{
Assert.DoesNotContain("formFile", schema.Required);
}
});
}
#nullable restore
Expand Down Expand Up @@ -401,6 +419,10 @@ await VerifyOpenApiDocument(builder, document =>
Assert.NotNull(item.Schema);
Assert.Equal("object", item.Schema.Type);
Assert.NotNull(item.Schema.Properties);
Assert.Contains("id", item.Schema.Required);
Assert.Contains("title", item.Schema.Required);
Assert.Contains("completed", item.Schema.Required);
Assert.Contains("createdAt", item.Schema.Required);
Assert.Collection(item.Schema.Properties,
property =>
{
Expand All @@ -427,6 +449,57 @@ await VerifyOpenApiDocument(builder, document =>
});
}

// Test coverage for https://github.com/dotnet/aspnetcore/issues/57112
[Fact]
public async Task GetOpenApiRequestBody_HandlesFromFormWithRequiredPrimitive()
{
// Arrange
var builder = CreateBuilder();

// Act
builder.MapPost("/form", ([FromForm] int id, [FromForm] DateTime date, [FromForm] short? value) => { });

// Assert
await VerifyOpenApiDocument(builder, document =>
{
var paths = Assert.Single(document.Paths.Values);
var operation = paths.Operations[OperationType.Post];
Assert.NotNull(operation.RequestBody);
Assert.NotNull(operation.RequestBody.Content);
var content = operation.RequestBody.Content;
// Forms can be provided in both the URL and via form data
Assert.Contains("application/x-www-form-urlencoded", content.Keys);
// Same schema should be produced for both content-types
foreach (var item in content.Values)
{
Assert.NotNull(item.Schema);
Assert.Equal("object", item.Schema.Type);
Assert.NotNull(item.Schema.Properties);
// Assert that requiredness has been set for primitives
Assert.Contains("id", item.Schema.Required);
Assert.Contains("date", item.Schema.Required);
Assert.DoesNotContain("value", item.Schema.Required);
Assert.Collection(item.Schema.AllOf,
subSchema =>
{
Assert.Contains("id", subSchema.Properties);
Assert.Equal("integer", subSchema.Properties["id"].Type);
},
subSchema =>
{
Assert.Contains("date", subSchema.Properties);
Assert.Equal("string", subSchema.Properties["date"].Type);
Assert.Equal("date-time", subSchema.Properties["date"].Format);
},
subSchema =>
{
Assert.Contains("value", subSchema.Properties);
Assert.Equal("integer", subSchema.Properties["value"].Type);
});
}
});
}

[Fact]
public async Task GetOpenApiRequestBody_HandlesFromFormWithPoco_MvcAction()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,10 @@ await VerifyOpenApiDocument(builder, document =>
{
Assert.True(GetRequestBodyForPath(document, "/required-poco").Required);
Assert.False(GetRequestBodyForPath(document, "/non-required-poco").Required);
// Form bodies are always required for form-based requests Individual elements
// within the form can be optional.
Assert.True(GetRequestBodyForPath(document, "/required-form").Required);
Assert.False(GetRequestBodyForPath(document, "/non-required-form").Required);
Assert.True(GetRequestBodyForPath(document, "/non-required-form").Required);
});

static OpenApiRequestBody GetRequestBodyForPath(OpenApiDocument document, string path)
Expand Down