ee8797c142
Co-authored-by: Copilot <copilot@github.com>
201 lines
7.4 KiB
Markdown
201 lines
7.4 KiB
Markdown
# Form Submission and AppJsonSerializerContext
|
|
|
|
This guide explains how to wire a form POST endpoint, why `AppJsonSerializerContext` must be kept up to date, and what happens if you forget.
|
|
|
|
## How form POST endpoints work
|
|
|
|
Form submissions use standard HTML `method="post"` forms. The server-side handler receives the posted fields as a strongly-typed `record` using `[FromForm]` bindings:
|
|
|
|
```csharp
|
|
[Handler]
|
|
[MapPost("/login")]
|
|
public static partial class PostLoginHandler
|
|
{
|
|
public record Command(
|
|
[property: FromForm] string Email,
|
|
[property: FromForm] string Password
|
|
);
|
|
|
|
private static async ValueTask HandleAsync(
|
|
[AsParameters] Command command,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IAntiforgery antiforgery,
|
|
AuthService authService,
|
|
CancellationToken token)
|
|
{
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
Key parts:
|
|
- `[property: FromForm]` on each record property tells the Minimal API binder to read the value from the form body, not from the route or query string
|
|
- `[AsParameters]` on the `Command` argument tells Minimal API to bind the record's properties individually rather than deserializing the whole body as JSON
|
|
- The handler is discovered and registered by the `Immediate.Apis` source generator — no `app.MapPost(...)` call is needed
|
|
|
|
---
|
|
|
|
## Antiforgery tokens
|
|
|
|
All mutating form POST endpoints must be protected against CSRF. The middleware chain includes `app.UseAntiforgery()` (added in `Program.cs`), which validates the `__RequestVerificationToken` field automatically for any `POST`/`PUT`/`DELETE` form submission.
|
|
|
|
To include the token in a form rendered server-side:
|
|
|
|
```csharp
|
|
// Inside a page/component constructor, inject IAntiforgery:
|
|
var afTokens = antiforgery.GetAndStoreTokens(ctx);
|
|
var tokenField = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afTokens.RequestToken)}" />""";
|
|
```
|
|
|
|
The `MainLayout` constructor and all auth page constructors already do this. Any new form page must follow the same pattern.
|
|
|
|
---
|
|
|
|
## Why AppJsonSerializerContext is required
|
|
|
|
The project uses `WebApplication.CreateSlimBuilder`, which enables the Minimal API **Request Delegate Generator**. This generator produces the endpoint binding code at compile time instead of using runtime reflection.
|
|
|
|
For form-body binding to work under AOT, the JSON serializer's type resolver chain must know about every request/response type it will encounter. This is configured in `Program.cs`:
|
|
|
|
```csharp
|
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
{
|
|
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
|
|
});
|
|
```
|
|
|
|
`AppJsonSerializerContext` is a source-generated `JsonSerializerContext`. It is declared in `AppJsonSerializerContext.cs`:
|
|
|
|
```csharp
|
|
[JsonSerializable(typeof(string))]
|
|
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
|
|
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
|
|
[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")]
|
|
internal partial class AppJsonSerializerContext : JsonSerializerContext
|
|
{
|
|
}
|
|
```
|
|
|
|
The `[JsonSerializable]` attribute tells the source generator to emit the serialization metadata for that type at compile time. Without this, the runtime falls back to reflection — which is stripped under AOT and will throw at runtime.
|
|
|
|
---
|
|
|
|
## What breaks if you forget to register a type
|
|
|
|
If you add a new form POST endpoint with a new `Command` record and do not register the record in `AppJsonSerializerContext`:
|
|
|
|
- A `dotnet build` will succeed and may even run fine in Development with the JIT
|
|
- A `dotnet publish -c Release` (AOT) will either emit a trim warning or silently produce a binary that throws `NotSupportedException: Serialization and deserialization of ... is not supported` at runtime when the endpoint is first hit
|
|
|
|
This is one of the most common mistakes when adding new endpoints.
|
|
|
|
---
|
|
|
|
## Step-by-step: adding a new form POST
|
|
|
|
### 1. Define the command record
|
|
|
|
```csharp
|
|
[Handler]
|
|
[MapPost("/contact")]
|
|
public static partial class PostContactHandler
|
|
{
|
|
public record Command(
|
|
[property: FromForm] string Name,
|
|
[property: FromForm] string Message
|
|
);
|
|
|
|
private static ValueTask HandleAsync(
|
|
[AsParameters] Command command,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
CancellationToken token)
|
|
{
|
|
// handle the form submission
|
|
var ctx = httpContextAccessor.HttpContext!;
|
|
ctx.Response.Redirect("/");
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Register the command in AppJsonSerializerContext
|
|
|
|
Open `Htmx.ApiDemo/AppJsonSerializerContext.cs` and add a `[JsonSerializable]` entry:
|
|
|
|
```csharp
|
|
[JsonSerializable(typeof(string))]
|
|
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
|
|
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
|
|
[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")]
|
|
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")] // ← add this
|
|
internal partial class AppJsonSerializerContext : JsonSerializerContext
|
|
{
|
|
}
|
|
```
|
|
|
|
`TypeInfoPropertyName` is optional but recommended — it gives the generated type-info property a readable name and avoids collisions when two commands have the same type name in different namespaces.
|
|
|
|
### 3. Include the antiforgery token in the form template
|
|
|
|
```html
|
|
<!-- Templates/Contact.htmx -->
|
|
<form method="post" action="/contact" class="space-y-4">
|
|
$$AntiforgeryToken$$
|
|
<input name="name" type="text" placeholder="Your name" />
|
|
<input name="message" type="text" placeholder="Message" />
|
|
<button type="submit">Send</button>
|
|
</form>
|
|
```
|
|
|
|
```csharp
|
|
// Templates/Contact.htmx.cs
|
|
public sealed class Contact : ContactBase
|
|
{
|
|
private readonly byte[] _afTokenData;
|
|
|
|
public Contact(string? afToken = null)
|
|
{
|
|
_afTokenData = string.IsNullOrEmpty(afToken)
|
|
? []
|
|
: $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afToken)}" />""".ToUtf8Bytes();
|
|
}
|
|
|
|
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx)
|
|
=> ctx.Writer.WriteUtf8(_afTokenData);
|
|
}
|
|
```
|
|
|
|
### 4. Inject and pass the token from the GET handler
|
|
|
|
```csharp
|
|
[Handler]
|
|
[MapGet("/contact")]
|
|
public static partial class GetContactHandler
|
|
{
|
|
public record Query;
|
|
|
|
private static ValueTask HandleAsync(
|
|
Query _,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IAntiforgery antiforgery,
|
|
CancellationToken token)
|
|
{
|
|
var ctx = httpContextAccessor.HttpContext!;
|
|
var afTokens = antiforgery.GetAndStoreTokens(ctx);
|
|
ctx.WriteHtmxPage(new Contact(afToken: afTokens.RequestToken), title: "Contact");
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
- [ ] `Command` record properties use `[property: FromForm]`
|
|
- [ ] Handler uses `[AsParameters]` on the command
|
|
- [ ] `Command` type is registered in `AppJsonSerializerContext` with `[JsonSerializable]`
|
|
- [ ] Form template includes the antiforgery hidden input
|
|
- [ ] GET handler resolves `IAntiforgery`, calls `GetAndStoreTokens`, and passes the token to the template
|
|
- [ ] Tested with `dotnet publish -c Release` (not just `dotnet run`) before considering it done
|