This guide gives the API’s consumers — a frontend, another service — a
client with typed paths, parameters, bodies and errors. It is generated
from the OpenAPI document that Tetsu builds from
your routes.
Tetsu has no RPC client that imports the server’s types, as Elysia’s Eden
or Hono’s client do. The document is the boundary instead: a client in
another repository or another language reads it without the server’s
code, and a change to the API shows up as a diff of one file. The cost is
one generation step.
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "GET",
path: "/users/:id"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
path: "/users/:id",
schema: {
params: z.object({ id: z.coerce.number() }),
response: { 200: User, 404: UserNotFound },
},
handler: (ctx: {
readonlyout:Outgoing&DeclaredOutgoing<404|200>;
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:number;
};
}) => {
id: number;
name: string;
}
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
Title, version and the rest of the document's info block.
info: {
title: string
title: "Users API",
version: string
version: "1.0.0" },
ui?:false|undefined
Which renderer the page bootstraps. Defaults to "scalar".
false serves the document without a page — what production wants
on an origin that carries a session. The page runs its renderer on the
application's origin, so a renderer that is not what it should be acts
in the signed-in user's name there; the document alone runs nothing.
uiPath, title and assets are then unused.
ui: false })],
});
ui: false serves only the document; leave it out to also get a
documentation page at /docs.
error is the body of any other status, typed as the union of what the
document lists for the operation. That includes the failures the
framework answers by itself, such as 422 for a validation failure and
500. Branch on the error code, not on the message, which is written for
people and may change. See Errors.
An error that is thrown but not declared is not in the document. A route
lists its own in its response map, as 404 above. A hook that refuses
describes its refusal with
documented() or secured().
A custom error format is described with
errors.
A generator that writes a function per operation names it after the
operationId. Tetsu builds it from the controller’s name and the route’s
field: get in controller("Users", …) is usersGet. Renaming the
controller renames the functions, so on a public API state the id on each
route with docs: { operationId }. Two routes with the same id are an
error when the document is built. See
Operation ids.
The client belongs to its consumers: the frontend’s repository, or a
package of its own in a monorepo. The server only hands over the document,
either served, as above, or written to a file and committed. A committed
file makes every API change a visible diff and allows the CI check below.
openapi() builds the document without serving it. The script imports the
application, not main.ts, so nothing starts listening:
scripts/openapi.ts
import { openapi } from"@tetsujs/openapi";
import { app } from"../src/app";
const {
constdocument:OpenApiDocument
document,
constwarnings:readonlyGeneratorWarning[]
warnings } =openapi(app, {
info: DocumentInfo
Title, version and the rest of the document's info block.
info: {
title: string
title: "Users API",
version: string
version: "1.0.0" } });
for (const
constwarning:GeneratorWarning
warningof
constwarnings:readonlyGeneratorWarning[]
warnings) console.error(`${
constwarning:GeneratorWarning
warning.
route: string
The route the warning is about, as GET /path — empty for a warning
about the document as a whole, such as a described tag nothing uses.
route}: ${
constwarning:GeneratorWarning
warning.
message: string
message}`);
if (
constwarnings:readonlyGeneratorWarning[]
warnings.
length: number
Gets the length of the array. This is a number one higher than the highest element defined in an array.
A warning means part of a route could not be described, most often a
schema from a validator that emits no JSON Schema. The script fails rather
than write an incomplete document. Keeping the application apart from
main.ts is the layout
Structuring an application describes.
With the document committed, CI regenerates it and fails when it differs
from the committed file:
Terminal window
bunscripts/openapi.ts
gitdiff--exit-codeopenapi.json
A pull request that changes the API without updating the document fails,
and one that updates it shows the contract change next to the code. A tool
such as oasdiff can list which changes break existing clients.
To check the other direction, that real responses match the document, use
assertDescribed
in tests.