Skip to content

OpenAPI

You already wrote the contract. One line serves it:

app.docs(.{ .title = "Orders", .version = "2.1.0" });

An OpenAPI 3.1 document at /openapi.json, and a page for reading it at /docs. Both are built when listen() runs, so it doesn't matter whether this line comes before or after your routes.

Nothing to keep in step

Nothing is annotated, because there's nothing to keep in step — fn getUser(db: *Db, id: u32) !User is read by exactly the same pass that decides what to pass in:

"/users/{id}": { "get": {
  "operationId": "getUsersId",
  "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],
  "responses": {"200": {"content": {"application/json": {"schema": … User … }}}}
}}
In the signature In the document
a path param a path parameter, typed, required
Query(T) one query parameter per field; a defaulted field is not required
a struct argument the request body schema
an enum the list of its names
an optional field a nullable property
!T the 200 response schema
!?T the 200 schema and a 404
!Status(201, T) a "201" response, named
!Response(T) default — the status is picked at runtime
a *Ctx and no return value default — nilo cannot tell whether the handler wrote an answer of its own
anything nilo can refuse first a 400

The name of an operation

operationId is derived from the method and the path — getUsersId above — which is a good default and a poor key. It is not a word anybody chose, and it changes when the route moves path. If something on your side is written against it, a route can say its own:

try app.named("addPartnerCapability")
    .put("/partners/:id/capabilities/:capability", addCapability);

That composes with groups and with with, and two routes sharing a name stop the process at registration (ADR 119). It says what the route is called and nothing about what it does — everything else in the document still comes from the signature, which is the point of the whole page. A name is letters, digits, _ and -, starting with a letter or _: a port whose contract was generated by somebody else's tool may have auth-login in it, and that is a name every client generator carries (ADR 119).

The same word is on the request. c.routeName() is the operationId of the route that matched — given, or the derived getUsersId — which is what lets one middleware hold an authorisation table keyed by it over every route, and refuse the route the table forgot rather than let it through (the middleware page).

Named shapes

A struct that came from a type with a name is written once under components/schemas and referred to everywhere else:

"components": {"schemas": {
  "Todo": {"type":"object","properties":{"id":{"type":"integer"}, …}},
  "Failure": {"type":"object","properties":{"error":{"type":"string"},"status":{"type":"integer"}}}
}}

so a route says {"$ref":"#/components/schemas/Todo"} rather than carrying a copy. Generated clients get one Todo type instead of five identical ones.

Failure is nilo's own shape, or the struct app.failures(T) named — read from its fields, under that name and not again under the type's own (ADR 024).

An instantiated generic gets a name too, read back out of the one the compiler gives it:

Zig in the document
Page(Order) Page_Order
Addressed(Str) Addressed_Str
Addressed([]const u8) Addressed_Text

That matters more than it looks. A generic is how Zig says "the same shape twice", and the same shape twice is exactly what a request struct and a response struct are — one holding Str, one holding []const u8 (ADR 003). Writing them as Addressed(Text) instead of two structs should not cost the shape its name in every generated client.

An anonymous struct still has no name worth putting in anybody's client, so those are written out in place. Two types that share a short name — an a.User and a b.User — both keep their full names, because a generator handed one User meaning two shapes produces code that does not compile; and where two generics render to the same name and are not the same shape, neither gets it and both are written out in place. There is no ceiling on how many shapes a document names: there was one, at sixty-four, and a product of six contexts reached it (ADR 170).

Failure is the shape every error body takes (ADR 024).

What a guard promises

A handler that takes nilo.Authorization(.bearer) gets a security entry, because the header is in the type. A session cookie is not in any type — it is read by the middleware in front of the group — so until you say so, the document describes every route behind it as open. Saying so is one line:

const api = app.group("/api");
try api.use(requireSession);
try app.guard(requireSession, nilo.session.cookie_name);

try api.get("/me", me);                            // cookieAuth, and a 401
try api.without(requireSession).post("/sign-in", signIn);   // open, as it is

Every route requireSession is in front of — through use, useOn or with, less what without took out — is written with a cookieAuth requirement and a 401, and components.securitySchemes gains {"type":"apiKey","in":"cookie","name":"session"}, which a generated client reads as "send the cookie". Which routes those are is read from the middleware wiring when the document is written, not from the declaration, so moving a without moves the document in the same line. The one thing taken on your word is the cookie's name and that the middleware refuses without it (ADR 153).

A route behind the guard whose handler also asks for Authorization is written with both schemes in one requirement, which means both: the guard ran first, and the handler still asked.

One guard per App, because a program has one session cookie; declaring it installs nothing, so use the middleware as before.

What it won't claim

It won't say what your signature doesn't.

Statuses. A handler returning Response(T) picks its status at runtime, so the document says default rather than guessing 200. Status(code, T) puts the code in the type, and then the document names it.

Failures. The only one a signature can state is "this might not be there", which is !?T and comes out as a 404. A fail.conflict(…) inside a handler is invisible here, and deliberately so: a compile-time check cannot read a function body, and an annotation saying otherwise would be a second thing to keep in step with the code — which is what this whole feature exists to avoid (ADR 023).

Shapes. A type with no JSON shape is {} — "anything", which is true. An untagged union is one: nothing in the type says which arm is live. A union(enum) is not, and gets a oneOf of whichever encoding it actually sends — the one std.json writes by default, or, if the type carries nilo_json, a discriminated one with discriminator and each arm pinned to its own tag value (JSON shapes).

One shape, two lifetimes

Meta(Str) for the body and Meta(Text) for the row is the split nilo asks for, and it used to cost a generated client two identical types. It doesn't: where a _Str and a _Text half render the same all the way down, they are one component called Meta (ADR 016). Two shapes that merely look alike — Page_Order and Page_User — keep their own names.

A type that writes its own JSON

If your type has a jsonStringify, std.json calls it and never looks at your fields — so nilo doesn't either. Reflecting the struct would describe something the server doesn't send, which is worse than saying nothing: it broke every generated client that read a nilo_id value, because a Uuid goes out as 36 characters and its struct is sixteen bytes (ADR 016).

So say what you send, beside the function that sends it:

const Uuid = struct {
    bytes: [16]u8,

    pub fn jsonStringify(self: Uuid, jw: anytype) !void {
        try jw.write(&self.toText());
    }

    pub const nilo_openapi = .{ .type = "string", .format = "uuid" };
};

type is required and is one of "string", "integer", "number", "boolean". format is optional and is a hint to a client generator — "uuid", "date-time", "email". Two fields is the whole of it; this is a way for a custom writer to stop lying, not a second language for describing types.

nilo's own types already do it: nilo_id's Uuid, and nilo_sql's Timestamp, Decimal, Interval and Inet. You need this only for a type of your own that writes itself.

A custom writer that says nothing gets {} and a note saying the writer is custom and how to describe it. Visibly silent, rather than confidently wrong.

A type that parses itself

A type with nilo_parse and a nilo_openapi is described as what it said, whether or not it writes its own JSON — an sql.Ordering reads from ?order=due:desc and is {"type":"string"}, not the struct of terms it holds (ADR 166). A nilo_expects beside it — "a ticket number like T-1234" — is what every 400 for the field asks for, in place of the type's name.

What a number promises

An unsigned integer says {"type":"integer","minimum":0}: it refuses -1 with a 400, so the document may promise it. A signed one is any integer. And nilo.Within(1, 200) says both ends — {"type":"integer","minimum":1,"maximum":200} — read off the type that holds the range, so a generated client refuses 500 before sending it (ADR 167):

const ListQuery = struct {
    limit: nilo.Within(1, 200) = .of(50),
    offset: u32 = 0,
};

minimum and maximum are not keys of nilo_openapi, on purpose: a marker is a claim, and a range the document promises is one the type enforces.

What text promises

The same for text (ADR 193). nilo.Text(.{ .min = 10, .max = 72 }) says {"type":"string","minLength":10,"maxLength":72}, nilo.Email says {"type":"string","maxLength":254,"format":"email"}, and nilo.Url says format: uri — each read off a type that refuses what it does not fit. A check of your own, and a nilo_check on the struct, have no JSON Schema and are not claimed: the field is a string, and the 422 is where those are told.

A type that writes its own answer

The same rule, one step further out. A type carrying nilo_content_type and nilo_write answers with whatever bytes it wrote, under its own label (Responses), and the document says so: the response's content key is the type's own application/xml or text/csv rather than application/json, which is the first time the description names a content type it did not pick. The schema under it is whatever the type says with nilo_openapi, and {} with a note — this type writes its own body, and has not said what it looks like — when it says nothing. nilo cannot read a schema off a function that writes XML, and does not try (ADR 157).

An alias is not a name. pub const NewDoc = Filing(Str); reads well in Zig and the document still calls the shape Filing_Str — the name comes from the compiler's name for the instantiation, and a Zig alias creates no new one. Write the struct out if the client's type name matters.

Answers it cannot see. A handler that takes a *Ctx and returns nothing may have sent its answer itself, somewhere in its body, and no reading of its signature will find out whether it did. The document says exactly that:

"responses": {"default": {"description": "this endpoint holds the Ctx and returns
                                          nothing, so it may write its own response
                                          — its signature does not settle what it
                                          answers"}}

and listen() says how many there are, once, at the moment somebody is looking:

info: 1 of 12 routes hold the Ctx and return nothing, so the API description
      cannot say what they answer — a handler that means "200, empty" says so by
      returning `Status(200, void)` (ADR 120)

Holding a *Ctx is not itself the disqualification — a handler that reads a header and then returns its answer is described like any other. Returning nothing while holding one is.

The count is your routes, not nilo's. app.health(path) and app.metrics(opts) register handlers of that shape, and both are described: the health page as a 200 of {"status":…}, the readout as 200 under the Prometheus text type. Neither is counted in the line above, so a program with a health page and every handler of its own described reads no line at all (ADR 120).

A handler that really does mean "200, empty" says so. Status(200, void) is the return type for it, and the document then carries the 200 rather than the default. nilo cannot tell the two apart from the signature, which is why the wording hedges rather than guesses (ADR 120).

That is the trade the whole feature rests on: a document that under-promises is useful, and one that guesses is worse than none (ADR 016).

Authentication is not described at all — there is nothing in a signature that says a route needs a token, since that lives in middleware. That is a known gap rather than a decision.

Options

title default "API"
version default "1.0.0"
description
path where the document is served. Default /openapi.json
ui_path where the reading page is served. Default /docs; empty for none

The /docs page pulls its viewer from a CDN, so set .ui_path = "" on a server with no outbound network. The document itself never needs one.

What it costs

The document is served from memory like a static file, so it arrives with an ETag and a repeat visit is a 304. It costs the request path nothing.

What it does cost is binary size, and unconditionally: +14 KB on the hello example, +34 KB on rest, whether or not docs() is ever called. The linker can't see that nobody wants it. Making that conditional needs a build option every dependent would have to thread through, which is not there yet.