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.