Documentations added

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-04 19:57:48 +05:00
parent 40a7d9018c
commit ee8797c142
35 changed files with 6655 additions and 0 deletions
+200
View File
@@ -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