@@ -0,0 +1,200 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user