# 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 = $""""""; ``` 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
``` ```csharp // Templates/Contact.htmx.cs public sealed class Contact : ContactBase { private readonly byte[] _afTokenData; public Contact(string? afToken = null) { _afTokenData = string.IsNullOrEmpty(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