blog post image
Andrew Lock avatar

Andrew Lock

~17 min read

Form binding in minimal APIs

Exploring the .NET 8 preview - Part 7

In this post I look at the new support for binding forms in minimal APIs. There's quite a few rough edges at the moment, but if missing forms support is the reason you were keeping around your MVC controllers, then you may be in luck in .NET 8.

As this post is using the preview 7 build, some of the features may change, be fixed, or be removed before .NET 8 finally ships in November 2023!

Why would you want form support in minimal APIs?

Let's deal with the elephant in the room first. They're called minimal APIs, and are focused on producing and consuming JSON. Why on earth would you want to consume form data, which is commonly associated with HTML forms?

Unlike MVC with Razor templates, Razor Pages, or even Blazor, minimal APIs don't provide any built-in tooling or affordances for working with HTML. You can use them to generate HTML, but you're mostly left either building HTML strings yourself, or building a framework on top of minimal APIs, as opposed to having support plumbed deeply in.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () =>
{
    // You can return HTML from minimal APIs, but it's nothing fancy
    string html = """
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="utf-8">
        <title>Hello from minimal APIs!</title>
      </head>
      <body>
        <p>
          Hello from Razor Slices! The time is @Model
        </p>
      </body>
      </html>
      """;
    return Results.Content(html, "text/html");
});

app.Run();

You can spruce this up a bit, like adding an "HTML generation" framework such as Damien Edwards' Razor Slices, but ultimately this is what you're working with in minimal APIs.

What you can't do in .NET 7 and earlier is receive POSTed form data in a minimal API endpoint and have the endpoint do any sort of model binding. This is bread-and-butter for MVC controllers and Razor Pages, but not in minimal APIs.

Personally, I kind of liked that. It gave a clear separation of what minimal APIs were and weren't for.

Except things weren't quite as clear as I'm making out. Minimal APIs have always supported sending files as form data (multipart/form-data), and binding these to an IFormFile or IFormFileCollection parameter. So in some ways minimal APIs have always partially supported binding to forms.

app.MapPost("/handle-file", async([FromForm] IFormFile myFile) =>
{
    string tempfile = Path.GetTempFileName();
    await using var stream = File.OpenWrite(tempfile);
    await myFile.CopyToAsync(stream);
});

If you accept that minimal APIs may need to receive data from a form Like this:

<form action="/handle-file" method="POST" enctype="multipart/form-data">
  <input type="file" name="myFile" />
  <input type="submit" />
</form>

which renders (without any styling) as

The above HTML rendered in the browser

then it does seem reasonable to also want to have support for a form like this:

<form action="/handle-file" method="POST" enctype="multipart/form-data">
  <input type="file" name="myFile" />
  <input type="text" name="description" placeholder="A description of the file" />
  <input type="submit" />
</form>

which renders (without any styling) as:

The above HTML rendered in the browser

In .NET 6 and 7, if you want to retrieve the value of the description form field, you would need to do something like this:

app.MapPost("/handle-file", async([FromForm] IFormFile myFile, HttpRequest request) =>
{
    var form = await request.ReadFormAsync();
    var description = form["description"]
    // do something with it
});

What people have been asking for (and what you can do in .NET 8) is to do something like this:

app.MapPost("/handle-file", (IFormFile file, [FromForm] string description) =>
{
    // do something with it
});

Also, you HTML isn't the only way to send form data, you can also send multipart/form-data with the JavaScript fetch APIs. In fact, multipart/form-data is about the only way you can use the fetch API to securely send a file with additional data in the same request. This limitation and requirement seems to be the primary reason that form support was added to minimal APIs.

You can see that realisation playing out in this GitHub issue. Initially, David Fowler indicated that they had "…no intention of supporting forms for APIs". But after appreciating this limitation they decided to investigate support for .NET 8!

In the next section I go into a bit more detail about what's supported, and then later we'll look at some of the limitations you need to watch out for!

Binding simple types, complex types, and files

Initial support for binding minimal APIs to forms arrived in .NET 8 preview 1(?). With this support you can bind parameters in your minimal API by adding the [FromForm] attribute.

That means the simple example I showed at the end of the previous section now works!

                            // 👇Add attribute to bind parameters to the form
app.MapPost("/handle-file", ([FromForm] string description) =>
{
    // do something with it
});

Note that as of .NET 8 preview 4, you don't need to add [FromForm] to IFormFile or IFormFileCollection parameters. These will now always be inferred as bound to a form.

You can post to this endpoint either using a simple HTML form

<html>
  <body>
  <form action="/handle-file" method="POST">
    <input type="text" name="description" />
    <input type="submit" />
  </form>
  </body>
</html>

or using JavaScript:

var result = await fetch("http://localhost:5121/handle-file", {
  "headers": {
    "content-type": "application/x-www-form-urlencoded",
  },
  "body": "description=fdsfds",
  "method": "POST",
});

Note however, that if you try using the APIs shown above directly, you'll get an AntiforgeryValidationException and a 400 response returned. That's because forms are inherently susceptible to Cross Site Request Forgery (CSRF) attacks, so you need to use anti-forgery tokens to protect yourself. I'll talk more about these in the next section of this post.

I've written about CSRF and how it intersects with SameSite cookies previously on my blog. I also discuss CSRF attacks and how to defend against them in chapter 29 of my book, ASP.NET Core in Action, Third Edition.

If you want to bind to multiple form fields, you can list them as extra parameters tagged with the [FromForm] attribute:

app.MapPost("/handle-form", ([FromForm] string name, [FromForm] bool isCompleted, [FromForm] DateTime dueDate)
    => Results.Ok(new { name, isCompleted, dueDate }));

Of course, no one wants to list endless parameters, and in .NET 8 preview 6 support was expanded to include binding complex objects. So you can now do the following:

app.MapPost("/handle-form", ([FromForm] ToDo todo)
    => Results.Ok(todo));

public class ToDo
{
    public string Name { get; set; }
    public bool IsCompleted { get; set; }
    public DateTime DueDate { get; set; }
}

All pretty simple, right? Well…yes and no. There are a whole host of limitations and rough edges that you might hit, which I'll go into at the end of the post, but first we need to address the all-important security aspect of anti-forgery tokens.

Securing your APIs against CSRF with anti-forgery tokens

I won't go into detail about why you need anti-forgery tokens in your forms or how they work (you can read about that in the docs or in my book), the important thing to bear in mind is that you probably do need anti-forgery tokens.

In MVC and Razor Pages, that's mostly handled for you automatically, but if you're going to be generating and receiving forms using minimal APIs (as shown in the previous section), then you need to take care of it yourself. You need to both inject the anti-forgery token when rendering forms, and validate the token when receiving a form POST.

The good news is that the second part, validating the anti-forgery token, happens automatically as of .NET 8 preview 7. If you bind a parameter to the request form using [FromForm], the minimal API automatically adds some extra metadata (IAntiforgeryMetadata) to the endpoint indicating that anti-forgery validation is required.

// Automatically validates anti-forgery token before executing
// because it binds to a form using [FromForm], IFormFile, or IFormFileCollection,
// which adds IAntiforgeryMetadata to the endpoint's metadata collection
app.MapPost("/handle-form", ([FromForm] ToDo todo)
    => Results.Ok(todo));

This metadata works in tandem with the AntiforgeryMiddleware, which runs after the routing middleware has selected an endpoint, and performs the anti-forgery verification.

The AntiforgeryMiddleware is automatically added to the middleware pipeline by WebApplicationBuilder, as long as you have registered the IAntiforgery service using builder.Services.AddAntiforgery();

So verification is handled for you, but you need to make sure you add a hidden field in your forms, with the correct name, which contains the anti-forgery token. You can do this using the IAntiforgery service to create and store the anti-forgery tokens. You can then render the token to the hidden field so that it's included in the subsequent form POST:

app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = $"""
        <html>
          <body>
            <form action="/handle-form" method="POST">
              <input name="{token.FormFieldName}" type="hidden" value="{token.RequestToken}" />
              <input type="text" name="comment" />
              <input type="submit" />
            </form>
          </body>
        </html>
        """;
    return Results.Content(html, "text/html");
});

In the example above, you can see that we're rendering the anti-forgery token to a hidden field, with a specific name defined by token.FormFieldName (which is "__RequestVerificationToken" by default). The token itself looks something like this CfDJ8AyTDgPnKy

If you attempt to submit a form that doesn't have an anti-forgery token, you'll get an AntiforgeryValidationException and a 400 response. In some cases you might decide you don't need anti-forgery verification. Be very careful how you come to this conclusion. CSRF vulnerabilities are less common these days (mostly thanks to SameSite cookies), but you still need to take care. If you're absolutely sure you don't need anti-forgery tokens, you can disable verification for an endpoint by calling DisableAntiforgery():

// Won't automatically validate anti-forgery tokens
// because it uses DisableAntiforgery()
app.MapPost("/handle-form", ([FromForm] ToDo todo)
    => Results.Ok(todo))
    .DisableAntiforgery();

If you wish, you can use the IAntiforgery service to validate the request yourself manually:

app.MapPost("/todo", async Task<IResult> ([FromForm] Todo todo, HttpContext context, IAntiforgery antiforgery) =>
{
    try
    {
        await antiforgery.ValidateRequestAsync(context);
        return Results.Ok(todo);
    }
    catch (AntiforgeryValidationException)
    {
        return TypedResults.BadRequest("Invalid anti-forgery token");
    }
}).DisableAntiforgery(); // disable automatic validation

That's all there is to anti-forgery tokens, so for the remainder of this post I explore some of the limitations I found working with forms and minimal APIs.

Limitations and rough edges

While playing with form binding in minimal APIs I ran into several limitations and edge cases. Some of these are definitely known limitations, while others may not yet be. I haven't dug through GitHub to see if there are issues for all of these, but I'll be doing that soon!

Restrictions on form binding parameter types

You can use form binding in minimal APIs to bind a variety of different parameter types. In all cases (unless otherwise stated) you must apply the [FromForm] attribute to the parameter.

  • Simple types like string, StringValues, int, or anything that supports TryParse (just like "normal" simple model binding).
  • Arrays of simple types.
  • IFormFile and IFormFileCollection parameters. From .NET 8 preview 6 you no longer need to apply the [FromForm] attribute to these parameters; they'll be inferred automatically.
  • Complex types attributed with [AsParameters], where the properties are attributed with [FromForm]. The [AsParameters] attribute essentially "flattens" the parameters, so you treat each property as a "top-level" parameter.
  • Complex class attributed with [FromForm]. Note that:
    • record types are not supported, you must use a class with settable properties.
    • You can't have recursive references in the class hierarchy

To clarify on that final point, while you can't have recursive references, you can have nested references. So you can have an API like this:

app.MapPost("/handle-form", ([FromForm] ToDo todo)
    => Results.Ok(todo));

public class ToDo
{
    public string Name { get; set; }
    public bool IsCompleted { get; set; }
    public SubType Sub { get; set; }
}

public class SubType 
{
    public string Name { get; set;}
}

The ToDo.Sub.Name property would bind to a form field called Sub.Name (ignoring case), similar to the way form naming works in MVC and Razor Pages.

There's nothing particularly untoward in these limitations, the main annoyance currently is not supporting record, but hopefully that will be supported before .NET 8 drops finally in November.

Nevertheless there are some rough edges that I ran into in my brief testing with the feature.

Checkbox binding

The first thing I ran into was trying to run the sample code from the .NET 8 preview 6 announcement post. A stripped down reproduction of the problem (using .NET 8 preview 7) looks something like this:

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () =>
{
    // Simple form with a checkbox and a submit button
    var html = """
        <html>
          <body>
            <form action="/todo" method="POST">
              <input type="checkbox" name="isCompleted" />
              <input type="submit" />
            </form>
          </body>
        </html>
        """;
    return Results.Content(html, "text/html");
});

// receive the checkbox as a boolean
app.MapPost("/todo", ([FromForm] bool isCompleted)
    => Results.Ok(isCompleted)).DisableAntiforgery(); // disable anti-forgery for simplicity

app.Run();

In this simple example we have a form consisting of a single checkbox and a submit button. On the handler side we have a matching bool parameter called isCompleted that we're binding to the form using [FromForm].

Note that I disabled anti-forgery tokens for simplicity in this demo by calling DisableAntiforgery() on the endpoint.

To test this form we can hit the / endpoint, click the checkbox, and hit Submit. Unfortunately, instead of binding the parameter, we see this:

BadHttpRequestException: Failed to bind parameter "bool isCompleted" from "on".

That's not great. The problem is the form sends the value "on" when the checkbox is set, not the value "true". So you can't just directly bind to a bool value like the post suggests.

If you try with an un-checked checkbox, you probably won't be surprised to know that doesn't work either:

BadHttpRequestException: Required parameter "bool isCompleted" was not provided from form

Note that in this case it's not a failure to bind the string, it's the fact that the value wasn't sent in the request at all. This is normal browser behaviour, but it just doesn't play nicely with minimal API binding expectations.

What's worse is that it took me a long time to figure out what's going on here, because if you put the bool checkbox value into a complex object (as shown in the original post) you get the very confusing following error:

FormDataMappingException: An error occurred while trying to map a value from form data. For more details, see the 'Error' property and the 'InnerException' property.

This is made even more confusing by the fact that AFAICT the Error property and InnerException property don't have more details. It was trial-and-error removing properties and inspecting requests that led me to finally figuring it out 😬

The interesting question for me here is: what should happen? Should minimal APIs "special-case" binding bool to the string "on" when the [FromForm] attribute is applied? How should it handle the fact the value is expected not to be there?

On the one hand you can certainly work around these limitations yourself with custom types and default values, but that feels like a lot of effort considering how fundamental checkboxes are. On the other hand, building in "workarounds" into minimal APIs doesn't sound great either, as it introduces inconsistencies and increases overall complexity.

There's also the fact that minimal APIs effectively now have two implementations to maintain: the Reflection.Emit version and the new Source Generator version.

Problems with the minimal API source generator

The minimal API source generator was introduced primarily as a solution for Ahead-of-Time (AOT) compilation, which is a big focus of .NET 8, but you can also enable it in any .NET 8 ASP.NET Core application by setting <EnableRequestDelegateGenerator> to true in your .csproj file:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- 👇 Add this line -->
    <EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
  </PropertyGroup>

</Project>

I discuss AOT compilation, the minimal API generator, and it's evolution to support interceptors in previous posts in this series.

I'm a big fan of this generator. Aside from the huge improvements to startup time, it's great to be able to easily see exactly how the model binding is happening. This is particularly useful for understanding how new features like [FromForm] work and interact with existing binding.

Unfortunately, [FromForm] on a complex object doesn't appear to work in the source generator in .NET 8 preview 7. 😢

Enabling the source generator and creating an API like this:

app.MapPost("/handle-form", ([FromForm] ToDo todo)
    => Results.Ok(todo));

Results in a build error:

Error CS0029 : Cannot implicitly convert type 'string' to 'Todo'

If we F12 into the source generator we can easily see what the problem is:

async Task RequestHandler(HttpContext httpContext)
{
    // ...
    string? todo_raw = (string?)httpContext.Request.Form["todo"];
    if (todo_raw == null)
    {
        wasParamCheckFailure = true;
        logOrThrowExceptionHelper.RequiredParameterNotProvided("Todo", "todo", "form");
    }
    string? todo_temp = todo_raw;
    global::Todo todo_local = todo_temp!; // ❌ Error CS0029 : Cannot implicitly convert type 'string' to 'Todo'
    // ... 
}

The generator just doesn't know what to do with the complex Todo object at all, and treats it as though it's a string!

This is such a glaring bug I'd be surprised if it's not already fixed, but it does highlight one of the difficulties in maintaining two different implementations for the exact same functionality!

Using IFormFile in complex objects

At the start of this post I mentioned that one of the primary use-cases for forms in minimal APIs was so that you can send form data along with files. I had something like the following in mind:

app.MapPost("/handle-form", ([FromForm] MyForm myForm)
    => Results.Ok(myForm));

public class MyForm
{
    public string Comment { get; set; }
    public IFormFile MyFile { get; set; }
}

Here we're handling a simple text box form field which has a comment about the file, and the IFormFile attachment itself, and encapsulating that all in a complex object.

Unfortunately, this doesn't work currently. If you send a request to this endpoint you get the very confusing error:

InvalidOperationException: No converter registered for type 'Microsoft.Extensions.Primitives.StringValues'.

Luckily there's already an issue on GitHub tracking this one, so hopefully it will get resolved. Alternatively, you can use workarounds such as not using complex objects:

app.MapPost("/handle-form", ([FromForm] string comment, IFormFile myFile)
    => Results.Ok(comment));

Or you could leverage [AsParameters] instead:

app.MapPost("/handle-form", ([AsParameters] MyForm myForm)
    => Results.Ok(myForm));

public class MyForm
{
    [FromForm] public string Comment { get; set; }
    public IFormFile MyFile { get; set; }
}

We're nearly there, just one more limitation to point out…

Must set multipart/form-data if sending files

In general, there are two main formats you can use to send form data. You can send it as application/x-www-form-urlencoded, in which the data is URL encoded and embedded in the body, something like this:

Name=MyName&DueDate=1988-08-01&IsCompleted=on

This is the "default" approach when you submit an HTML form in the browser, and is also required by some specifications like OAuth and OpenID Connect.

The other approach is to encode as multipart/form-data. In which case the data is embedded in the body with each field getting its own section:

------WebKitFormBoundaryhVWeqAEFZLFVAKm5
Content-Disposition: form-data; name="Name"

MyName
------WebKitFormBoundaryhVWeqAEFZLFVAKm5
Content-Disposition: form-data; name="DueDate"

1988-08-01
------WebKitFormBoundaryhVWeqAEFZLFVAKm5
Content-Disposition: form-data; name="IsCompleted"

on
------WebKitFormBoundaryhVWeqAEFZLFVAKm5--

Obviously this is way more verbose than application/x-www-form-urlencoded, so you probably wouldn't choose to use it in most situations. However, the exception to the rule is for the original scenario of wanting to send form fields along with files. IFormFile and IFormFileCollection are only supported with multipart/form-data, so you must use this format if you're going to be sending any files.

You can specify that a <form> element should be encoded as multipart/form-data when submitted using the enctype property:

<!-- Encode the POST using multipart/form-data 👇 -->
<form action="/handle-form" method="POST" enctype="multipart/form-data">
  <input type="text" name="comment" />
  <input type="file" name="myFile" />
  <input type="submit" />
</form>

If you're sending requests using JavaScript and fetch then you can use FormData to encode the data:

const result = await fetch("handle-form", {
    body: new FormData(document.getElementsById('form')),
    method: "post",
})

What's next?

So what's next for minimal API forms? Obviously I hope that many of the limitations, bugs, and rough edges I described above will be addressed! In the next release (.NET 8 RC1?!) we should see support for providing options controlling form binding which was merged recently. That will let you do things like:

app.MapPost("/handle-form", ([AsParameters] MyForm myForm)
    => Results.Ok(myForm))
    .WithFormMappingOptions(10) // control the maximum number of elements in a collection
    .WithFormOptions(bufferBody: true); // buffer the body so we can read it twice

These options already exist in the MVC implementation of anti-forgery tokens and model binding, so it's mostly about providing feature parity.

On top of these already merged features, there's a few minor outstanding features in the form-binding meta-issue, but most of these are minor, so I think we're getting close. Which is lucky as the final release of .NET 8 is less than 3 months away!

Summary

In this post I described the new form binding support for minimal APIs coming in .NET 8. I showed how you can bind your minimal API endpoint handlers to HTML forms or JavaScript fetch POSTs using either multipart/form-data or application/x-www-form-urlencoded. I described how to use the IAntiforgery service to inject anti-forgery tokens into generated HTML, and showed how minimal APIs automatically validate the tokens in the latest versions of the .NET 8 previews.

In the second half of the post I described some of the rough edges and limitations of the form binding support. I described some issues with the minimal API source generator, surprising behaviour in binding to checkboxes, and some limitations when sending files. I expect most of these will be resolved soon, but in the mean time, bear them in mind!

Andrew Lock | .Net Escapades
Want an email when
there's new posts?