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.