Skip to content

Forms

An HTML form is not JSON. A browser posts application/x-www-form-urlencoded, and the moment the form has a file in it, multipart/form-data. Form(T) reads both.

const SignIn = struct {
    email: nilo.Str,
    password: nilo.Str,
    remember: bool = false,   // an unticked checkbox is not sent at all
};

fn signIn(incoming: nilo.Form(SignIn)) !nilo.Redirect(303) {
    ... incoming.value.email ...
    return .to("/welcome");
}

It is the same idea as Query(T), moved from the query string to the body: one field per form field, a field's type says what its text has to become, and a default is what "not sent" means.

Field Means
email: Str required — absent is a 400 saying which
page: u32 converted, and page=soon is a 400 saying so
sort: enum { newest, oldest } one of those words, or a 400 listing them
nickname: ?Str = null optional: absent is null
limit: u32 = 20 absent means the default
remember: bool = false a checkbox — see below
tags: []const Str = &.{} a checkbox group or a <select multiple> — see below
avatar: Upload a file — see below

The messages are the ones a query param gets, because it is the same code: "age" has to be a whole number, not "soon".

A checkbox is a bool

A ticked checkbox posts on. An unticked one posts nothing at all — the name does not appear in the body — so both halves need the default:

newsletter: bool = false,

Ticked gives true, unticked leaves the default, and that is the whole of it. true and false are taken as well, for a client that is not a browser.

Only a form reads on this way. The same field in a Query(T) or a JSON body is true or false and nothing else, because on is a fact about HTML rather than about booleans — a JSON client sending "on" has a bug, and hearing about it is more use than having it guessed at. off is not accepted anywhere: no browser sends it, and an unticked box is an absent field rather than a present false one.

A checkbox group is a list

Three boxes named tags post tags=zig&tags=http when two are ticked, and a <select multiple> posts the same shape. A field that is a slice takes every value sent under its name, in the order the browser put them, and each one is converted the way a single field would be (ADR 132):

const NewPost = struct {
    title: nilo.Str,
    tags: []const nilo.Str = &.{},
    notify: []const enum { comment, mention } = &.{},
};

fn create(incoming: nilo.Form(NewPost)) !nilo.Redirect(303) {
    for (incoming.value.tags) |tag| _ = tag;
    return .to("/posts");
}

Nothing ticked is the empty list, never a 400: a group with no box ticked sends no name at all, and that is what every filter and every opt-in already means by not being sent. Give the field = &.{} and the document says it is optional. An empty value contributes nothing, so a row of text boxes named alias with two left blank is a list of the ones filled in. A value with a comma in it is a value with a comma in it: a browser never joins a group with commas, so unlike a query list there is no second spelling to read, and tags=a%2Cb is one tag. A list of Upload is refused while compiling — a file is a part, not a value, and a field takes one.

A value that will not convert — notify=nonsense — is the 400 the single field would have got, naming the field. Behind a Bound(Form(T)) the first bad value is the one recorded and the rest of the list is still read, so a group with one bad box is a group rather than a form with nothing in it.

Which encoding arrived is not your problem

A browser picks urlencoded or multipart depending on whether the form has a file in it. That is a fact about the browser, so Form(T) reads either and the handler never asks — the same way c.body() reads a chunked body and a Content-Length one without saying which turned up.

A body that is neither gets a 400 naming what was sent:

this endpoint takes a form, so the body has to be sent as
application/x-www-form-urlencoded or multipart/form-data — this one arrived
as "application/json"

Files

A field typed nilo.Upload is a file. Three pieces, all Str:

const NewAvatar = struct {
    caption: nilo.Str,
    image: nilo.Upload,
};

fn upload(incoming: nilo.Form(NewAvatar)) !nilo.Status(201, Avatar) {
    const image = incoming.value.image;
    image.filename.view()      // "me.png"
    image.content_type.view()  // "image/png"
    image.bytes.view()         // the file
    image.len()                // how big it is
}

?Upload = null is a file that may not have been chosen.

A form with an Upload in it can only arrive as multipart, so one that does not gets told which to send rather than being reported as a missing field:

this endpoint takes a file, so the form has to be sent as
multipart/form-data — this one arrived as application/x-www-form-urlencoded.
In HTML that is <form enctype="multipart/form-data">.

The filename is not a path

filename is whatever the client sent. ../../etc/passwd is a filename a browser will happily send if asked to. Store the bytes under a name of your own; treat this one as a label to show somebody. content_type is likewise the client's claim, not a fact — sniff the bytes if it matters.

nilo reads the plain filename and not RFC 6266's filename*=UTF-8''…, which is the encoded form a browser sends alongside it for a name that is not Latin-1. A part carrying only the encoded one is a 400 naming the part (ADR 073) — refused rather than read as a text field full of upload bytes, which is what it used to become. No browser sends that shape; a hand-rolled HTTP client can.

Writing it to disk

saveTo puts the bytes in a directory, under a name you choose:

const Uploads = struct { dir: nilo.Dir };

const Avatar = struct {
    caption: nilo.Str,
    image: nilo.Upload,
};

fn setAvatar(uploads: *Uploads, account: u32, incoming: nilo.Form(Avatar)) !nilo.Status(201, void) {
    var buf: [32]u8 = undefined;
    const name = try std.fmt.bufPrint(&buf, "{d}.png", .{account});
    try incoming.value.image.saveTo(uploads.dir, name);
    return .{};
}

The Dir is opened once at startup and held as a service, exactly as FileBody takes one — and it can be the same one, which is the case saveTo is careful about. The file is replaced or it is not touched: the bytes go to a temporary name beside it and one rename puts them in place, so a request serving that name while this one writes reads the old file rather than a truncated one (ADR 097).

Passing image.filename in as the name is error.NameNotAllowed rather than a path resolved against the directory — the same check sendFile makes on the way out, for the same reason.

The fiber parks for the write and the thread carries on serving every other connection it holds, so there is nothing to hand to nilo.blocking.

How big a form can be

The whole body is read into the request arena, bounded by listen()'s max_body — 1 MB by default. A form is read into a struct, and a struct is not something you can have half of.

For an upload bigger than that, turn max_body up — for the one route, with app.with(nilo.maxBody(50 << 20)), rather than for the whole server — or take the body in pieces yourself with c.bodyStream(), where nothing is held in memory at all. Form(T) is the convenient one; the stream is the one with no ceiling.

Inside the ceiling nothing is copied: a file's bytes are a slice of the body that was already read, not a second copy of it.

When one field is wrong and the rest are fine

Form(T) is all-or-nothing: the first field that will not convert is a 400 and the request is over. For an API that is usually what you want. For a page, it is not — somebody mistypes their age and loses everything else they typed.

Bound(Form(T)) hands the failures to the handler instead:

fn signUp(b: nilo.Bound(nilo.Form(SignUp))) !nilo.Redirect(303) {
    const form = b.value() orelse return b.fail();
    ...
}

b.fail() is a 422 naming every field that did not bind:

2 fields did not fit: the form is missing "email" (text);
"age" has to be a whole number, not "soon"

value() is an optional and there is no way past it. A field that did not bind holds nothing worth reading, so the binding withholds the whole struct rather than letting you read a zero nobody sent.

Showing the form again

What a page needs is not the converted values — it is what the person typed. You put soon back in the age box, not 0. That is given, and it works for every field whether or not it bound:

fn signUp(arena: std.mem.Allocator, b: nilo.Bound(nilo.Form(SignUp))) !Page {
    if (b.value()) |form| return welcome(form);

    var wrong: std.ArrayList(Problem) = .empty;
    var it = b.failures();
    while (it.next()) |f| {
        f.field      // "age"
        f.reason     // .not_a_number — null when it is a rule of yours
        f.given      // Str "soon" — what arrived
        f.expected   // "a whole number"
        try f.say(w) // nilo's own sentence, so yours cannot drift from it
    }

    return signUpPage(.{
        .email = b.given("email").view(),   // still in the box
        .age = b.given("age").view(),       // "soon", so they can see it
        .problems = wrong.items,
    });
}

The field name in given("…") is checked while compiling — a typo there would otherwise be an empty box nobody notices.

Text with a shape

The reasons above are exactly the conversions nilo performs: .missing, .not_a_number, .not_true_or_false, .not_a_choice, .wrong_kind. This is not a validator. But a u8 refuses 300 and nobody calls that one, and text can have a shape the same way a number has a range (ADR 193):

fn startsWithSku(text: []const u8) bool {
    return std.mem.startsWith(u8, text, "SKU-");
}

const SignUp = struct {
    email: nilo.Email,
    password: nilo.Text(.{ .min = 10, .max = 72 }),
    nickname: nilo.Text(.{ .max = 30 }) = .of(""),
    sku: nilo.Text(.{ .check = startsWithSku, .said = "has to be a SKU code" }),
    confirm: Str,

    pub fn nilo_check(self: SignUp, r: *nilo.Rules(SignUp)) void {
        r.must("confirm", self.password.eql(self.confirm.view()), "has to match the password");
    }
};

A nilo.Text is a Str that parses itself, so it is read wherever a Str is — a form field, a query value, a JSON body, a path param — and refused with one sentence in all four. min and max count characters (code points, the same thing JSON Schema's minLength counts). check is any fn ([]const u8) bool of your own, with said as its sentence in must's shape. Email and Url are presets. The Str is .value, and view, len, eql and blank are on the Text itself; .of("…") is the default, checked against the shape while compiling.

A Text never quotes the text back. A password in a 422 body is a leak, so the sentence says the count: "password" has to be text of 10 to 72 characters, not 7. Email does quote, because seeing the address is how the typo is found: "email" has to look like an address, not "wati".

A rule about the struct goes on the struct. nilo_check runs once every field has bound, in whichever slot the struct arrived through, and what it says with must comes out in the same 422 as everything else — so the second handler binding SignUp cannot forget the rule. It takes the value and nothing else: a rule that needs the request stays below.

On a plain Form(SignUp) a field outside its shape is the 400 a bad number gets, and a nilo_check that does not hold is a 422 naming every rule that did not. Under Bound all of it is collected:

3 fields did not fit: "email" has to look like an address, not "wati";
"password" has to be text of 10 to 72 characters, not 7;
"confirm" has to match the password

The document says the shape — minLength, maxLength, format: email — read off the type, so a generated client refuses the same text before sending it. A check and a nilo_check have no JSON Schema and are not claimed.

Your own rules, in the same answer

What is left for the handler is the rule that needs the request — "that address is already registered" wants a database. Write it, hand over the sentence, and it comes out beside nilo's own in one 422 rather than as a second shape a client has to handle:

fn signUp(db: *Db, b: nilo.Bound(nilo.Form(SignUp))) !nilo.Status(201, User) {
    const in = b.value() orelse return b.fail();

    const checked = b.must("email", !try db.exists(in.email.view()), "is already registered");
    if (checked.failed()) return checked.fail();

    return db.create(in);
}
"email" is already registered

The bool is the rule holding, not failing — read the call as the sentence it makes: password must be at least 10 characters. The label is nilo's, so a rule in a query string says ?page stops at 100 without you knowing that slot spells things differently.

must returns a Checked, which has value, failed, failedCount, given, failures and fail — the same names, so nothing above has to be rewritten to use it. A Failure from a rule has reason == null and its own words in said; conversion failures still come first, because a rule checked against a field that never bound was checked against nothing.

A handler that checks no rules never builds a Checked and pays nothing for this (ADR 034).

And three things stay a plain 400, because none of them leaves a binding to hand back: a body that is not a form at all, text that is not JSON, and a field the endpoint has never heard of.

The same wrapper works on the other two slots: Bound(T) for a JSON body and Bound(Query(T)) for the query string — and a nilo.Text or a nilo_check on the struct works in all three the same way, with or without the wrapper.

A form is the body

Form(T) sits exactly where a plain struct argument would have read JSON. They are the same bytes read two ways, so asking for both stops compilation:

// nilo: the handler for route "/sign-up" asks for both a request body
// (argument 1, a main.Profile) and a form (argument 2) — and a request only
// has one body.
fn signUp(profile: Profile, incoming: nilo.Form(SignIn)) !void { … }

From a Ctx

c.form(T) is the same thing for a handler holding a *Ctx, the way c.json(T) is for a JSON body:

fn signIn(c: *nilo.Ctx) !void {
    const incoming = try c.form(SignIn);
    ...
}

c.formCollecting(T, &outcomes) and c.jsonCollecting(T, &outcomes) are what Bound(…) is built on, for a *Ctx handler that wants the failures.

Testing one

A Form(T) is an ordinary struct, so a test builds one and never writes a request body:

const answer = try signIn(&sessions, arena, .{ .value = .{
    .email = .static("wati@example.dev"),
    .password = .static("hunter2"),
} });

For the multipart case — where the framing is the thing being tested — the test client posts a real body:

const answer = try client.postWith(
    &app,
    "/sign-in",
    "application/x-www-form-urlencoded",
    "email=wati%40example.dev&password=hunter2",
);

In the document

The generated API description says which encoding the endpoint takes — application/x-www-form-urlencoded, or multipart/form-data once there is a file — and describes the file as bytes rather than as the struct carrying it. See OpenAPI.

See also

  • examples/forms — a form, a session cookie, an upload and a redirect, end to end.
  • Cookies, which is what a sign-in does next.
  • ADR 030 — why Form(T) is explicit rather than sniffed, and what the multipart parser is careful about.
  • ADR 034 — why value() is an optional, why the reason list stops where it does, and what stays a plain 400.