diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index fed6794bf191..481eea934053 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -381,7 +381,10 @@ private async Task 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() }; @@ -410,6 +413,10 @@ private async Task 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 @@ -444,6 +451,10 @@ private async Task GetFormRequestBody( } else { + if (IsRequired(description)) + { + schema.Required.Add(description.Name); + } schema.AllOf.Add(new OpenApiSchema { Type = "object", @@ -462,6 +473,10 @@ private async Task GetFormRequestBody( } else { + if (IsRequired(description)) + { + schema.Required.Add(description.Name); + } schema.Properties[description.Name] = parameterSchema; } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 8f7c03b51160..5f8abe054fd2 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -79,7 +79,8 @@ } } } - } + }, + "required": true }, "responses": { "200": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index b5fe811c2545..3e341cabab82 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt @@ -14,6 +14,9 @@ "content": { "multipart/form-data": { "schema": { + "required": [ + "resume" + ], "type": "object", "properties": { "resume": { @@ -41,6 +44,9 @@ "content": { "multipart/form-data": { "schema": { + "required": [ + "files" + ], "type": "object", "properties": { "files": { @@ -68,6 +74,10 @@ "content": { "multipart/form-data": { "schema": { + "required": [ + "resume", + "files" + ], "type": "object", "allOf": [ { @@ -135,6 +145,9 @@ "content": { "multipart/form-data": { "schema": { + "required": [ + "file" + ], "type": "object", "allOf": [ { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs index 482daeac4cbe..739d6f3dd012 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs @@ -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); @@ -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 @@ -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); @@ -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 @@ -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 => { @@ -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() { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs index b7ecdcc6445b..e7d0ea13af19 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs @@ -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)