Skip to content

Commit 6d9dca9

Browse files
authored
Fix form handling and add NuGet pkg docs (#55321)
1 parent 3f68a51 commit 6d9dca9

13 files changed

+1206
-40
lines changed

src/OpenApi/sample/Controllers/TestController.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ public string GetByIdAndName(RouteParamsContainer paramsContainer)
1212
return paramsContainer.Id + "_" + paramsContainer.Name;
1313
}
1414

15+
[HttpPost]
16+
[Route("/forms")]
17+
public IActionResult PostForm([FromForm] MvcTodo todo)
18+
{
19+
return Ok(todo);
20+
}
21+
1522
public class RouteParamsContainer
1623
{
1724
[FromRoute]
@@ -21,4 +28,6 @@ public class RouteParamsContainer
2128
[MinLength(5)]
2229
public string? Name { get; set; }
2330
}
31+
32+
public record MvcTodo(string Title, string Description, bool IsCompleted);
2433
}

src/OpenApi/sample/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@
4343

4444
forms.MapPost("/form-file", (IFormFile resume) => Results.Ok(resume.FileName));
4545
forms.MapPost("/form-files", (IFormFileCollection files) => Results.Ok(files.Count));
46+
forms.MapPost("/form-file-multiple", (IFormFile resume, IFormFileCollection files) => Results.Ok(files.Count + resume.FileName));
4647
forms.MapPost("/form-todo", ([FromForm] Todo todo) => Results.Ok(todo));
48+
forms.MapPost("/forms-pocos-and-files", ([FromForm] Todo todo, IFormFile file) => Results.Ok(new { Todo = todo, File = file.FileName }));
4749

4850
var v1 = app.MapGroup("v1")
4951
.WithGroupName("v1");

src/OpenApi/src/PACKAGE.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
## About
2+
3+
Microsoft.AspNetCore.OpenApi is a NuGet package that provides built-in support for generating OpenAPI documents from minimal or controller-based APIs in ASP.NET Core.
4+
5+
## Key Features
6+
7+
* Supports viewing generated OpenAPI documents at runtime via a parameterized endpoint (`/openapi/{documentName}.json`)
8+
* Supports generating an OpenAPI document at build-time
9+
* Supports customizing the generated document via document transformers
10+
11+
## How to Use
12+
13+
To start using Microsoft.AspNetCore.OpenApi in your ASP.NET Core application, follow these steps:
14+
15+
### Installation
16+
17+
```sh
18+
dotnet add package Microsoft.AspNetCore.OpenApi
19+
```
20+
21+
### Configuration
22+
23+
In your Program.cs file, register the services provided by this package in the DI container and map the provided OpenAPI document endpoint in the application.
24+
25+
```C#
26+
var builder = WebApplication.CreateBuilder();
27+
28+
// Registers the required services
29+
builder.Services.AddOpenApi();
30+
31+
var app = builder.Build();
32+
33+
// Adds the /openapi/{documentName}.json endpoint to the application
34+
app.MapOpenApi();
35+
36+
app.Run();
37+
```
38+
39+
For more information on configuring and using Microsoft.AspNetCore.OpenApi, refer to the [official documentation](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/openapi).
40+
41+
## Main Types
42+
43+
<!-- The main types provided in this library -->
44+
45+
The main types provided by this library are:
46+
47+
* `OpenApiOptions`: Options for configuring OpenAPI document generation.
48+
* `IDocumentTransformer`: Transformer that modifies the OpenAPI document generated by the library.
49+
50+
## Feedback & Contributing
51+
52+
<!-- How to provide feedback on this package and contribute to it -->
53+
54+
Microsoft.AspNetCore.OpenApi is released as open-source under the [MIT license](https://licenses.nuget.org/MIT). Bug reports and contributions are welcome at [the GitHub repository](https://github.com/dotnet/aspnetcore).

src/OpenApi/src/Services/OpenApiComponentService.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,26 @@ internal sealed class OpenApiComponentService(IOptions<JsonOptions> jsonOptions)
4242
{
4343
OnSchemaGenerated = (context, schema) =>
4444
{
45-
schema.ApplyPrimitiveTypesAndFormats(context.TypeInfo.Type);
45+
var type = context.TypeInfo.Type;
46+
// Fix up schemas generated for IFormFile, IFormFileCollection, Stream, and PipeReader
47+
// that appear as properties within complex types.
48+
if (type == typeof(IFormFile) || type == typeof(Stream) || type == typeof(PipeReader))
49+
{
50+
schema.Clear();
51+
schema[OpenApiSchemaKeywords.TypeKeyword] = "string";
52+
schema[OpenApiSchemaKeywords.FormatKeyword] = "binary";
53+
}
54+
else if (type == typeof(IFormFileCollection))
55+
{
56+
schema.Clear();
57+
schema[OpenApiSchemaKeywords.TypeKeyword] = "array";
58+
schema[OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject
59+
{
60+
[OpenApiSchemaKeywords.TypeKeyword] = "string",
61+
[OpenApiSchemaKeywords.FormatKeyword] = "binary"
62+
};
63+
}
64+
schema.ApplyPrimitiveTypesAndFormats(type);
4665
if (context.GetCustomAttributes(typeof(ValidationAttribute)) is { } validationAttributes)
4766
{
4867
schema.ApplyValidationAttributes(validationAttributes);

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod
213213
.Select(responseFormat => responseFormat.MediaType);
214214
foreach (var contentType in apiResponseFormatContentTypes)
215215
{
216-
var schema = apiResponseType.Type is {} type ? _componentService.GetOrCreateSchema(type) : new OpenApiSchema();
216+
var schema = apiResponseType.Type is { } type ? _componentService.GetOrCreateSchema(type) : new OpenApiSchema();
217217
response.Content[contentType] = new OpenApiMediaType { Schema = schema };
218218
}
219219

@@ -296,11 +296,101 @@ private OpenApiRequestBody GetFormRequestBody(IList<ApiRequestFormat> supportedR
296296
Content = new Dictionary<string, OpenApiMediaType>()
297297
};
298298

299-
// Forms are represented as objects with properties for each form field.
300299
var schema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
301-
foreach (var parameter in formParameters)
300+
// Group form parameters by their name because MVC explodes form parameters that are bound from the
301+
// same model instance into separate ApiParameterDescriptions in ApiExplorer, while minimal APIs does not.
302+
//
303+
// public record Todo(int Id, string Title, bool Completed, DateTime CreatedAt)
304+
// public void PostMvc([FromForm] Todo person) { }
305+
// app.MapGet("/form-todo", ([FromForm] Todo todo) => Results.Ok(todo));
306+
//
307+
// In the example above, MVC's ApiExplorer will bind four separate arguments to the Todo model while minimal APIs will
308+
// bind a single Todo model instance to the todo parameter. Grouping by name allows us to handle both cases.
309+
var groupedFormParameters = formParameters.GroupBy(parameter => parameter.ParameterDescriptor.Name);
310+
// If there is only one real parameter derived from the form body, then set it directly in the schema.
311+
var hasMultipleFormParameters = groupedFormParameters.Count() > 1;
312+
foreach (var parameter in groupedFormParameters)
302313
{
303-
schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type);
314+
// ContainerType is not null when the parameter has been exploded into separate API
315+
// parameters by ApiExplorer as in the MVC model.
316+
if (parameter.All(parameter => parameter.ModelMetadata.ContainerType is null))
317+
{
318+
var description = parameter.Single();
319+
var parameterSchema = _componentService.GetOrCreateSchema(description.Type);
320+
// Form files are keyed by their parameter name so we must capture the parameter name
321+
// as a property in the schema.
322+
if (description.Type == typeof(IFormFile) || description.Type == typeof(IFormFileCollection))
323+
{
324+
if (hasMultipleFormParameters)
325+
{
326+
schema.AllOf.Add(new OpenApiSchema
327+
{
328+
Type = "object",
329+
Properties = new Dictionary<string, OpenApiSchema>
330+
{
331+
[description.Name] = parameterSchema
332+
}
333+
});
334+
}
335+
else
336+
{
337+
schema.Properties[description.Name] = parameterSchema;
338+
}
339+
}
340+
else
341+
{
342+
if (hasMultipleFormParameters)
343+
{
344+
// Here and below: POCOs do not need to be need under their parameter name in the grouping.
345+
// The form-binding implementation will capture them implicitly.
346+
if (description.ModelMetadata.IsComplexType)
347+
{
348+
schema.AllOf.Add(parameterSchema);
349+
}
350+
else
351+
{
352+
schema.AllOf.Add(new OpenApiSchema
353+
{
354+
Type = "object",
355+
Properties = new Dictionary<string, OpenApiSchema>
356+
{
357+
[description.Name] = parameterSchema
358+
}
359+
});
360+
}
361+
}
362+
else
363+
{
364+
if (description.ModelMetadata.IsComplexType)
365+
{
366+
schema = parameterSchema;
367+
}
368+
else
369+
{
370+
schema.Properties[description.Name] = parameterSchema;
371+
}
372+
}
373+
}
374+
}
375+
else
376+
{
377+
if (hasMultipleFormParameters)
378+
{
379+
var propertySchema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
380+
foreach (var description in parameter)
381+
{
382+
propertySchema.Properties[description.Name] = _componentService.GetOrCreateSchema(description.Type);
383+
}
384+
schema.AllOf.Add(propertySchema);
385+
}
386+
else
387+
{
388+
foreach (var description in parameter)
389+
{
390+
schema.Properties[description.Name] = _componentService.GetOrCreateSchema(description.Type);
391+
}
392+
}
393+
}
304394
}
305395

306396
foreach (var requestFormat in supportedRequestFormats)

0 commit comments

Comments
 (0)