Skip to content

Handlers

A handler is an ordinary function. It takes only what it needs and returns data. Which means you can test it without starting a server, and without a fake HTTP request:

fn getUser(db: *Db, id: u32) !User {
    return db.find(id) orelse nilo.fail.notFound("no user {d}", .{id});
}

test "getUser" {
    var fake = Db.fake(.{ .id = 7 });
    try expectEqual(7, (try getUser(&fake, 7)).id);
    try expectError(error.Failed, getUser(&fake, 99));
}

What a handler may ask for

Arguments are matched while compiling, by one rule: a pointer is a service, a value is request data.

Argument What nilo passes in
*Ctx the raw request — the way out when you need full control
*Db, *const Config a service, matched by its type
u32, f64, Str, bool, an enum a path param, in the order they appear in the pattern
Query(T) the query string, read into a struct of yours
FromHeader("X-Staff-Id", T) one request header, converted the way a path param is; ?T when the client may not send it. c.header reads the same thing — what this adds is a parameter in the OpenAPI document
Authorization(.bearer), Authorization(.{ .basic = "realm" }) the Authorization header as one scheme — .value for a token, .user and .password for Basic. Absent or another scheme is a 401 with WWW-Authenticate on it, before the handler runs; a security scheme in the document. See Checking somebody else's token
Idempotent(Replays, .{ .by = account }) the Idempotency-Key header, and with it the route answering once per key: a retry gets the kept answer back and the handler does not run
Form(T) the body as an HTML form, urlencoded or multipart, read into a struct of yours
Bound(T), Bound(Query(T)), Bound(Form(T)) the same three, with every field that failed handed to the handler instead of the first one stopping the request
Session(T) a struct of yours sealed into a cookie — a resolved value nilo supplies
std.mem.Allocator the request arena, freed when the request ends
a type with nilo_resolve a resolved value — the signed-in user, usually
any other struct the request body, parsed from JSON

Order is free, except among path params: those are positional, so the first scalar argument is the first :param in the pattern, the second is the second. Everything else is matched by type, so it can sit anywhere in the list.

fn update(db: *Db, id: u32, arena: std.mem.Allocator, incoming: Patch) !User { … }

Getting any of this wrong stops the compiler with a message that names the route and tells you what to do about it — never a runtime surprise. Asking for a service you forgot to register stops listen() before the socket opens.

What a handler returns

The return value becomes the response body:

Return type Response
void 200, empty
Str, []const u8 200, text/plain
anything else 200, that value as JSON
?T 200 with the value, or 404 when it is null
Status(code, T) that status, and headers if you set any
Response(T) a status picked while the handler runs, and headers
Redirect(status) that status and a Location, no body — Responses
FileBody, ?FileBody a file on disk, sent without passing through your process; ? is the same 404 — Responses
a type with nilo_content_type and nilo_write whatever it writes, under that label — XML, CSV, HTML of your own — Responses
!T any of the above, or a failure

"It might not be there": ?T

The commonest handler in any CRUD app, and the whole 404 is in the signature:

fn getUser(db: *Db, id: u32) !?User {
    return db.find(id);
}

Null goes out as 404 Not Found, and the generated API description says the endpoint answers 404 — which it cannot know about an orelse fail.notFound(…) in the body, because a compile-time check cannot read a function body.

Write the orelse when you want a better sentence than there is no /users/99. You get both: your message, and the 404 in the document.

fn getUser(db: *Db, id: u32) !User {
    return db.find(id) orelse fail.notFound("no user {d}", .{id});
}

What ?T no longer does is answer 200 with the body null. If that really is what you mean, return a struct with a nullable field, which says so.

Where the ? goes

A ? composes with the wrappers, and it goes inside them: the ? is about the body, so it wraps the body and not the status around it. The compiler refuses the other way round, with a message that says which to write (ADR 203).

Write Meaning
!?T the value, or 404
!Status(201, ?T) 201 with the value, or 404
!Response(?T) the status the handler chose, or 404
!?FileBody, !?Bytes the file or the bytes, or 404
!Versioned(T) with .unchanged(v) the value, or a 304; a thing that is not there is fail.notFound
!Status(204, void) 204, empty
Refused Why, and what to write
?Status(201, T) a 201 with no body is not a thing; Status(201, ?T)
?Response(T) the same; Response(?T)
?Redirect(303) a redirect has no body for the ? to be about; Redirect(303), and fail.notFound
?Versioned(T), Versioned(?T) a thing that is not there has no version; Versioned(T) and fail.notFound
*Ctx and void legal, and undescribed: the document cannot say what the handler wrote (ADR 120)

The ! goes outermost in every row, and every shape in the first table is what the document describes, 404 included.

Choosing the status, or adding headers

When the status is part of the contract, put it in the type — the document can then name it instead of writing default:

fn createUser(db: *Db, arena: std.mem.Allocator, incoming: NewUser) !Status(201, User) {
    const created = try db.add(incoming);
    return .{
        .headers = .of(&.{.{
            .name = "Location",
            .value = try std.fmt.allocPrint(arena, "/users/{d}", .{created.id}),
        }}),
        .value = created,
    };
}

fn deleteUser(db: *Db, id: u32) !Status(204, void) {
    if (!try db.remove(id)) return fail.notFound("no user {d}", .{id});
    return .{};
}

When the status genuinely depends on what the handler found — a 200 or a 201 out of the same upsert — that is what Response(T) is for, and its .status is an ordinary field:

fn upsertUser(db: *Db, id: u32, incoming: NewUser) !Response(User) {
    const result = try db.upsert(id, incoming);
    return .{ .status = if (result.created) 201 else 200, .value = result.user };
}

The two behave identically at runtime. The difference is what the API description can say (ADR 023).

A std.mem.Allocator argument is the request arena — the thing to build a header value in, since it lives exactly as long as the response needs it to and is thrown away afterwards. Nothing to free.

.of(…) is not decoration. A list written inside a handler belongs to that handler's stack frame, and nilo reads the headers after the handler has returned; of copies them into the response while the list is still there. Up to eight per response — a ninth is a compile error pointing at c.setHeader, which has no limit. ADR 018 has the whole story, including why the slice this replaced passed every test and crashed in release.

The way out: *Ctx

When you need something the typed layer has no argument for — a header, a body you want to look at before parsing, an answer written in pieces — the handler asks for a *Ctx:

fn download(c: *nilo.Ctx, files: *Files) !void {
    const wanted = c.header("X-File") orelse return fail.badRequest("no X-File", .{});
    try c.send(200, "application/octet-stream", files.get(wanted.view()));
}

Both layers are the same layer: the typed one compiles down into Ctx calls, and a handler can take a *Ctx alongside its typed arguments. There is no penalty for mixing, and no separate registration.

A handler taking a *Ctx and sending its own answer should return void. One request gets one response, and sending a second is an assertion failure rather than two responses on the wire.

See Responses for everything a Ctx can send, and ADR 002 for why the typed layer is a thin one.

Str, and text that belongs to the request

Text arriving from a request — a param, a header, a query value — is a nilo.Str, not a []const u8. It is valid while the request runs and not afterwards, and the type says so:

s.view() the bytes, for reading now
s.eql("admin") compare against a literal
s.int(u32) parse a number out of it
s.len() how many bytes
s.keep(gpa) a copy that outlives the request — the deliberate way out

Returning a Str from a handler is fine: the response goes out before the request ends. Storing one in a service is the mistake Str exists to catch, and in a debug build reading a stale one panics rather than returning whatever the next request put there:

thread panic: Str used after its request finished. Request data dies with the
request; copy it with .keep() while the handler is still running if you need to
hold on to it. (while handling GET /read)

keep is how you mean it — see Holding on to request text for the pattern a service wants.

What the trap cannot promise is everything: Zig has no ownership system, so this is a debug-build check and not a guarantee. A Str reached through a pointer nilo never walked — inside a const slice, inside an untagged union — carries no marker and is not watched. Release builds drop the whole mechanism, at no cost.

See ADR 003.