Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix form handling and add NuGet pkg docs #55321

Merged
merged 4 commits into from Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/OpenApi/sample/Controllers/TestController.cs
Expand Up @@ -12,6 +12,13 @@ public string GetByIdAndName(RouteParamsContainer paramsContainer)
return paramsContainer.Id + "_" + paramsContainer.Name;
}

[HttpPost]
[Route("/forms")]
public IActionResult PostForm([FromForm] MvcTodo todo)
{
return Ok(todo);
}

public class RouteParamsContainer
{
[FromRoute]
Expand All @@ -21,4 +28,6 @@ public class RouteParamsContainer
[MinLength(5)]
public string? Name { get; set; }
}

public record MvcTodo(string Title, string Description, bool IsCompleted);
}
2 changes: 2 additions & 0 deletions src/OpenApi/sample/Program.cs
Expand Up @@ -43,7 +43,9 @@

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

var v1 = app.MapGroup("v1")
.WithGroupName("v1");
Expand Down
54 changes: 54 additions & 0 deletions src/OpenApi/src/PACKAGE.md
@@ -0,0 +1,54 @@
## About

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.
captainsafia marked this conversation as resolved.
Show resolved Hide resolved

## Key Features

* Supports generating an OpenAPI document at runtime exposed via an endpoint (`/openapi/{documentName}.json`)
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
* Supports generating an OpenAPI document at build-time
* Supports customizing the generated document via document transformers

## How to Use

To start using Microsoft.AspNetCore.OpenApi in your ASP.NET Core application, follow these steps:

### Installation

```sh
dotnet add package Microsoft.AspNetCore.OpenApi
```

### Configuration

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.

```C#
var builder = WebApplication.CreateBuilder();

// Registers the required services
builder.Services.AddOpenApi();

var app = builder.Build();

// Adds the /openapi/{documentName}.json endpoint to the application
app.MapOpenApi();

app.Run();
```

For more information on configuring and using Microsoft.AspNetCore.OpenApi, refer to the [official documentation](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi).
captainsafia marked this conversation as resolved.
Show resolved Hide resolved

## Main Types

<!-- The main types provided in this library -->

The main types provided by this library are:

* `OpenApiOptions`: Options for configuring OpenAPI document generation.
* `IDocumentTransformer`: Transformer that modifies the OpenAPI document generated by the library.

## Feedback & Contributing

<!-- How to provide feedback on this package and contribute to it -->

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).
75 changes: 71 additions & 4 deletions src/OpenApi/src/Services/OpenApiDocumentService.cs
Expand Up @@ -213,7 +213,7 @@ private OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCod
.Select(responseFormat => responseFormat.MediaType);
foreach (var contentType in apiResponseFormatContentTypes)
{
var schema = apiResponseType.Type is {} type ? _componentService.GetOrCreateSchema(type) : new OpenApiSchema();
var schema = apiResponseType.Type is { } type ? _componentService.GetOrCreateSchema(type) : new OpenApiSchema();
response.Content[contentType] = new OpenApiMediaType { Schema = schema };
}

Expand Down Expand Up @@ -296,11 +296,78 @@ private OpenApiRequestBody GetFormRequestBody(IList<ApiRequestFormat> supportedR
Content = new Dictionary<string, OpenApiMediaType>()
};

// Forms are represented as objects with properties for each form field.
var schema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
foreach (var parameter in formParameters)
// Group form parameters by their parameter name because MVC explodes form parameters that are bound from the
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this sentence is distinguishing between "form parameter" and "parameter", but I'm not sure how. I think the latter might correspond to MVC action parameters?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This grouping will apply to all form parameters, regardless of whether we are currently processing them for an MVC action or minimal endpoint. The comment is intended to clarify why grouping by name allows us to "un-explode" the ApiParameterDescriptions that are produced by ApiExplorer specifically for MVC.

// same model instance into separate parameters, while minimal APIs does not.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// same model instance into separate parameters, while minimal APIs does not.
// same model instance into separate parameters in ApiExplorer, while minimal APIs does not.

//
// public record Todo(int Id, string Title, bool Completed, DateTime CreatedAt)
// public void PostMvc([FromForm] Todo person) { }
// app.MapGet("/form-todo", ([FromForm] Todo todo) => Results.Ok(todo));
//
// In the example above, MVC will bind four separate arguments to the Todo model while minimal APIs will
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, in this context, "bind" refers to how things are exposed in the API explorer, rather than to what we call "parameter binding" in aspnetcore?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this is about ApiExplorer. I'll tweak the verbiage.

// bind a single Todo model instance to the todo parameter. Grouping by name allows us to handle both cases.
var groupedFormParameters = formParameters.GroupBy(parameter => parameter.ParameterDescriptor.Name).ToList();
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
// If there is only one real parameter derive from the form body, then set it directly in the schema.
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
if (groupedFormParameters is [var groupedParameter])
{
schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type);
// If the group contains one element, then we're dealing with a minimal API scenario where a
// single form parameter produces a single API description parameter.
if (groupedParameter.Count() == 1)
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
{
var description = groupedParameter.Single();
// 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))
{
schema.Properties[description.Name] = _componentService.GetOrCreateSchema(description.Type);
}
else
{
// POCOs do not need to be subset under their parameter name. The form-binding implementation
// will capture them implicitly.
schema = _componentService.GetOrCreateSchema(description.Type);
}
}
// If there are multiple API description parameters in the group, then we are dealing
// with the MVC scenario where form parameters are exploded into separate API parameters
// for each property within the complex type.
else
{
foreach (var parameter in groupedParameter)
{
schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type);
}
}
}
// If there are multiple parameters sourced from the form, then we use `allOf` to capture each parameter.
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
else
{
// Process the arguments in the same way that we do for the single-parameter case but
// set each sub-schema as an `allOf` item in the schema.
foreach (var parameter in groupedFormParameters)
{
if (parameter.Count() == 1)
{
var description = parameter.Single();
if (description.Type == typeof(IFormFile) || description.Type == typeof(IFormFileCollection))
{
schema.AllOf.Add(new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema> { [description.Name] = _componentService.GetOrCreateSchema(description.Type) } });
captainsafia marked this conversation as resolved.
Show resolved Hide resolved
}
else
{
schema.AllOf.Add(_componentService.GetOrCreateSchema(description.Type));
}
}
else
{
var propertySchema = new OpenApiSchema { Type = "object", Properties = new Dictionary<string, OpenApiSchema>() };
foreach (var description in parameter)
{
propertySchema.Properties[description.Name] = _componentService.GetOrCreateSchema(description.Type);
}
schema.AllOf.Add(propertySchema);
}
}
}

foreach (var requestFormat in supportedRequestFormats)
Expand Down