Rewrote all the docs - more noob friendly now.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-05 23:55:26 +05:00
parent e483bf73e7
commit f6ae86617c
35 changed files with 2159 additions and 2341 deletions
+177 -146
View File
@@ -1,191 +1,221 @@
# Form Submission and AppJsonSerializerContext
# Form Submission
This guide explains how to wire a form POST endpoint, why `AppJsonSerializerContext` must be kept up to date, and what happens if you forget.
Think of a form submission as **sending a letter with a security seal**. The form is your letter, the fields are the contents, and the antiforgery token is the wax seal that proves the letter genuinely came from your site and not from a malicious third party trying to impersonate the user.
## How form POST endpoints work
This guide walks through wiring up a form POST from start to finish.
Form submissions use standard HTML `method="post"` forms. The server-side handler receives the posted fields as a strongly-typed `record` using `[FromForm]` bindings:
---
## What you want to achieve
By the end of this guide you will have a working form that:
- Posts data to a typed C# handler
- Is protected against CSRF attacks with an antiforgery token
- Registers its types with `AppJsonSerializerContext` so AOT compilation does not break it
---
## How a form POST works
Forms use standard HTML `method="post"`. The browser serialises the form fields and sends them as a URL-encoded body. On the server, each field is read from that body and bound to a strongly-typed C# `record`.
```csharp
[Handler]
[MapPost("/login")]
public static partial class PostLoginHandler
// A handler for POST /contact
public static class PostContactHandler
{
// The Command record maps exactly to your form field names
public record Command(
[property: FromForm] string Email,
[property: FromForm] string Password
[property: FromForm] string Name,
[property: FromForm] string Message
);
private static async ValueTask HandleAsync(
[AsParameters] Command command,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
AuthService authService,
CancellationToken token)
public static void Map(IEndpointRouteBuilder app)
{
// ...
app.MapPost("/contact", Handle);
}
private static IResult Handle(
[AsParameters] Command command,
HttpContext ctx)
{
// command.Name and command.Message are already populated
ctx.Response.Redirect("/");
return Results.Empty;
}
}
```
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
Key points:
- `[property: FromForm]` on each record property tells the binder to read that value from the form body, not from the URL or route
- `[AsParameters]` on the `Command` argument tells Minimal API to bind each property individually instead of trying to deserialize the whole body as a single JSON object
- The handler is a plain static method — no special base class, no framework magic
Register it in `Program.cs`:
```csharp
PostContactHandler.Map(app);
```
---
## 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.
Every mutating form (POST, PUT, DELETE) must include an antiforgery token. This is the wax seal that proves the form was generated by your server — not forged by a third-party page trying to submit on the user's behalf (a CSRF attack).
To include the token in a form rendered server-side:
The middleware `app.UseAntiforgery()` in `Program.cs` validates this token automatically on every mutating request. If the token is missing or wrong, the request is rejected before your handler even runs.
To include the token in a form, inject `IAntiforgery` into the GET handler that renders the page, then pass the token to the template:
```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
// GET handler that renders the form page
private static IResult HandleGet(
HttpContext ctx,
IAntiforgery antiforgery)
{
var tokens = antiforgery.GetAndStoreTokens(ctx);
var page = new ContactPage(antiforgeryToken: tokens.RequestToken ?? "");
ctx.WriteHtmxPage(page, title: "Contact");
return Results.Empty;
}
```
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
In the template or component, render the token as a hidden field:
```csharp
[Handler]
[MapPost("/contact")]
public static partial class PostContactHandler
// ContactPage.htmx.cs
public ContactPage(string antiforgeryToken)
{
// HTML-encode the token value — it is user-visible in the source
_tokenFieldData = $"""
<input type="hidden"
name="__RequestVerificationToken"
value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />
""".ToUtf8Bytes();
}
```
---
## AppJsonSerializerContext — the AOT requirement
The project uses `WebApplication.CreateSlimBuilder`, which generates endpoint binding code at compile time instead of using runtime reflection. For this to work, the serializer must know about every type it might encounter at compile time too.
Every `Command` record you create must be registered in `AppJsonSerializerContext.cs`:
```csharp
// AppJsonSerializerContext.cs
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")] // ← add this
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
```
`TypeInfoPropertyName` gives the generated property a readable name and prevents collisions if two commands happen to share the same type name.
### What happens if you forget
- `dotnet run` (Debug, JIT) — works fine, JIT fills in the gaps at runtime
- `dotnet publish -c Release` (AOT) — either emits a trim warning during build, or throws `NotSupportedException` at runtime the first time the endpoint is hit
This is the single most common mistake when adding a new form endpoint. Add the `[JsonSerializable]` entry at the same time as you write the `Command` record.
---
## Complete example — a contact form
### 1. The template
```html
<!-- Templates/ContactPage.htmx -->
<form method="post" action="/contact" class="space-y-4 max-w-md">
$$Token$$
<div>
<label for="name">Name</label>
<input id="name" name="Name" type="text" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="Message" required></textarea>
</div>
<button type="submit">Send</button>
</form>
```
### 2. The page class
```csharp
// Templates/ContactPage.htmx.cs
namespace Htmx.ApiDemo.Templates;
public sealed class ContactPage : ContactPageBase
{
private readonly byte[] _tokenData;
public ContactPage(string antiforgeryToken)
{
_tokenData = string.IsNullOrEmpty(antiforgeryToken)
? []
: $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />"""
.ToUtf8Bytes();
}
protected override void RenderToken(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_tokenData);
}
```
### 3. The GET handler (renders the form)
```csharp
public static class GetContactHandler
{
public static void Map(IEndpointRouteBuilder app)
=> app.MapGet("/contact", Handle);
private static IResult Handle(HttpContext ctx, IAntiforgery antiforgery)
{
var tokens = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new ContactPage(tokens.RequestToken ?? ""), title: "Contact");
return Results.Empty;
}
}
```
### 4. The POST handler (processes the form)
```csharp
public static class PostContactHandler
{
public record Command(
[property: FromForm] string Name,
[property: FromForm] string Message
);
private static ValueTask HandleAsync(
[AsParameters] Command command,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
public static void Map(IEndpointRouteBuilder app)
=> app.MapPost("/contact", Handle);
private static IResult Handle([AsParameters] Command command, HttpContext ctx)
{
// handle the form submission
var ctx = httpContextAccessor.HttpContext!;
// process command.Name and command.Message here
ctx.Response.Redirect("/");
return ValueTask.CompletedTask;
return Results.Empty;
}
}
```
### 2. Register the command in AppJsonSerializerContext
Open `Htmx.ApiDemo/AppJsonSerializerContext.cs` and add a `[JsonSerializable]` entry:
### 5. Register both in `Program.cs`
```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
{
}
GetContactHandler.Map(app);
PostContactHandler.Map(app);
```
`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>
```
### 6. Register the Command in `AppJsonSerializerContext.cs`
```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;
}
}
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")]
```
---
@@ -193,8 +223,9 @@ public static partial class GetContactHandler
## Checklist
- [ ] `Command` record properties use `[property: FromForm]`
- [ ] Handler uses `[AsParameters]` on the command
- [ ] `Command` type is registered in `AppJsonSerializerContext` with `[JsonSerializable]`
- [ ] Handler method uses `[AsParameters]` on the `Command` parameter
- [ ] `Command` type added to `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
- [ ] GET handler calls `antiforgery.GetAndStoreTokens(ctx)` and passes the token to the page
- [ ] Both GET and POST handlers registered in `Program.cs`
- [ ] Tested with `dotnet publish -c Release` before merging