APIs and services Advanced
OpenAPI documents
Describe the Task API in an OpenAPI document with @zudojs/openapi. Turn your schemas into components, choose between OpenAPI 3.0 and 3.1, validate the document, save it as JSON or YAML, and serve a documentation page.
What OpenAPI is
Other people want to use your Task API: a mobile developer, a partner company, a teammate who writes the web front end. They ask the same questions. Which paths exist? What do I send? What comes back? Which errors can happen?
OpenAPI is a standard way to answer those questions in one file, called an OpenAPI document (people also say "spec", short for specification). It is plain JSON or YAML, so both people and programs can read it. Tools use it to:
- show browsable documentation, such as Swagger UI or ReDoc,
- generate a client library for your API in another language,
- check in CI that the API did not change by accident.
Writing that file by hand goes out of date the day the code changes. @zudojs/openapi builds it from the schemas and routes you already have. Install it in your Task API folder:
npm install @zudojs/openapi up to date, audited 17 packages in 3s 1 package is looking for funding run `npm fund` for details found 0 vulnerabilities
"up to date": @zudojs/api from the last lesson already brought the package along. Installing it yourself adds it to your package.json. Every example is a .ts file that you run with npx tsx file.ts.
Your first document
An OpenAPI document has three main parts: info (the API's name and version), paths (every URL and what each method does there) and components (shared pieces, such as schemas). One method on one path is called an operation.
OpenAPIManager collects your routes and builds the document. Each route is a method, a path, and its documentation under metadata.openapi:
import { OpenAPIManager } from "@zudojs/openapi";
const manager = new OpenAPIManager({
version: "3.1.0",
info: { title: "Task API", version: "1.0.0" },
branding: false,
});
manager.addRoute({
method: "get",
path: "/tasks/:id",
metadata: {
openapi: {
operationId: "tasks.get",
summary: "Get one task",
tags: ["Tasks"],
parameters: [{ name: "id", in: "path", schema: { type: "integer", minimum: 1 } }],
responses: {
"200": { description: "The task" },
"404": { description: "No task with this id" },
},
},
},
});
const document = manager.generate();
console.log(document.openapi, document.info);
console.log(Object.keys(document.paths));
console.log(JSON.stringify(document.paths["/tasks/{id}"]?.get?.parameters));
npx tsx first.ts3.1.0 { title: 'Task API', version: '1.0.0' }
[ '/tasks/{id}' ]
[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1}}]- The router style path
/tasks/:idbecame/tasks/{id}, the way OpenAPI writes a path parameter. - The
idparameter gotrequired: true. OpenAPI demands that for every path parameter. Even if you forget to declare it, the manager documents every{…}slot of the path, as a string. Declaring it, as here, lets you say that it is a whole number. operationIdis a unique name for the operation. Client generators turn it into a method name.branding: falsekeeps the output short. Without it, the manager adds the ZudoJS logo toinfoasx-logo, a field some viewers read.
Schemas become components
In the validation lesson you described a task with @zudojs/schema. OpenAPI has its own schema language, based on JSON Schema. manager.addSchema(name, schema) translates yours and stores it under components.schemas. An operation then points at it with a reference, { "$ref": "#/components/schemas/Task" }, which createComponentReference builds for you.
This file describes the whole Task API. Later examples import it:
import { OpenAPIManager, createComponentReference } from "@zudojs/openapi";
import { schema } from "@zudojs/schema";
export const NewTask = schema.object({ title: schema.string().trim().min(3).max(100) });
export const Task = schema.object({ id: schema.number().int().min(1), title: schema.string(), done: schema.boolean() });
const ref = (name: string) => createComponentReference("schemas", name);
const json = (schema: object) => ({ "application/json": { schema } });
export function taskApiDocs(version = "3.1.0"): OpenAPIManager {
const manager = new OpenAPIManager({ version, info: { title: "Task API", version: "1.0.0" }, branding: false });
manager.addSecurityScheme("bearerAuth", { type: "http", scheme: "bearer" });
manager.addSecurityRequirement({ bearerAuth: [] });
manager.addSchema("NewTask", NewTask).addSchema("Task", Task);
manager.addRoute({ method: "get", path: "/tasks", metadata: { openapi: {
operationId: "tasks.list", tags: ["Tasks"],
responses: { "200": { description: "All tasks of the user", content: json({ type: "array", items: ref("Task") }) } },
} } });
manager.addRoute({ method: "post", path: "/tasks", metadata: { openapi: {
operationId: "tasks.create", tags: ["Tasks"],
requestBody: { required: true, content: json(ref("NewTask")) },
responses: {
"201": { description: "The new task", content: json(ref("Task")) },
"401": { description: "Not signed in" },
"422": { description: "The body failed validation" },
},
} } });
manager.addRoute({ method: "get", path: "/health", metadata: { openapi: {
operationId: "health", security: [], responses: { "200": { description: "The server is up" } },
} } });
return manager;
}
Three new things:
addSecuritySchemesays how callers prove who they are: a bearer token in theAuthorizationheader.addSecurityRequirementmakes it the rule for every operation.security: []on/healthmeans "no sign-in needed" for that one operation.- The schemas are the same objects that validate requests at runtime. The document cannot drift away from them.
Look at what the schemas became:
import { taskApiDocs } from "./task-api.js";
const schemas = taskApiDocs().generate().components?.schemas ?? {};
for (const [name, converted] of Object.entries(schemas)) console.log(name, JSON.stringify(converted));
npx tsx components.tsNewTask {"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":100}},"required":["title"]}
Task {"type":"object","properties":{"id":{"type":"integer","minimum":1},"title":{"type":"string","maxLength":255},"done":{"type":"boolean"}},"required":["id","title","done"]}.min(3).max(100) became minLength and maxLength. .int() became "type": "integer". The task's title has no .max(), yet it shows maxLength: 255: that is the limit @zudojs/schema applies to every string by default, so the document tells clients the real limit.
OpenAPI 3.0 or 3.1?
Two versions are in use. 3.1 is the current one and matches JSON Schema. 3.0 is older, but some tools, such as older code generators and API gateways, still only read 3.0. A few rules are written differently in the two versions. convertSchema(schema, { version }) shows the difference:
import { convertSchema } from "@zudojs/openapi";
import { schema } from "@zudojs/schema";
const TaskFilter = schema.object({
priority: schema.number().int().gt(0).lt(6),
dueBefore: schema.nullable(schema.string().max(30)),
});
for (const version of ["3.0.3", "3.1.0"]) {
const { schema: converted } = convertSchema(TaskFilter, { version });
console.log(version, JSON.stringify(converted.properties));
}
npx tsx versions.ts3.0.3 {"priority":{"type":"integer","minimum":0,"exclusiveMinimum":true,"maximum":6,"exclusiveMaximum":true},"dueBefore":{"type":"string","maxLength":30,"nullable":true}}
3.1.0 {"priority":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":6},"dueBefore":{"type":["string","null"],"maxLength":30}}gt(0)means "greater than 0, not equal". 3.1 writes"exclusiveMinimum": 0. 3.0 writes"minimum": 0plus"exclusiveMinimum": true.- A value that may be
nullis"nullable": truein 3.0 and a list of types,["string", "null"], in 3.1.
You never write these by hand: pass version to the manager and every schema uses the right spelling. Choose 3.1 unless a tool you must support only reads 3.0.
Validate the document
A document can be valid JSON and still be wrong. manager.validate() checks the OpenAPI rules and returns { valid, errors, warnings }. This document has two mistakes that are easy to make:
import { OpenAPIManager } from "@zudojs/openapi";
const manager = new OpenAPIManager({ info: { title: "Task API", version: "1.0.0" }, branding: false });
manager.addRoute({ method: "delete", path: "/tasks/:id", metadata: { openapi: {
operationId: "tasks.delete", security: [{ bearer: [] }],
responses: { "204": { description: "Deleted" } },
} } });
manager.addRoute({ method: "get", path: "/tasks", metadata: { openapi: {
operationId: "tasks.list",
responses: { "200": { description: "All tasks", content: { "application/json": { schema: { $ref: "#/components/schemas/Task" } } } } },
} } });
const result = manager.validate();
console.log("valid:", result.valid);
for (const issue of result.errors) console.log("-", issue.path.join("."), ":", issue.message);
npx tsx broken.tsvalid: false
- paths./tasks/{id}.delete.security : Security requirement "bearer" does not match any scheme in components.securitySchemes.
- paths./tasks.get.responses.200.content.application/json.schema : Reference "#/components/schemas/Task" does not resolve within the document.- The operation asks for a security scheme called
bearerthat the document never declares. Such a document looks protected, but tools cannot tell how to send a token. - The
$refpoints at aTaskschema that was never added.
The DELETE route did not declare its {id} parameter, and that is not an error: the manager filled it in. Some gaps are only warnings. In a build script, manager.generate(true) validates and throws an OpenAPIValidationError for real errors. Its format() method prints one line per problem:
import { OpenAPIManager, OpenAPIValidationError } from "@zudojs/openapi";
const manager = new OpenAPIManager({ info: { title: "Task API", version: "1.0.0" }, branding: false });
manager.addRoute({ method: "delete", path: "/tasks/:id", metadata: { openapi: {
operationId: "tasks.delete", security: [{ bearer: [] }],
} } });
const operation = manager.generate().paths["/tasks/{id}"]?.delete;
console.log(JSON.stringify(operation?.parameters));
console.log(JSON.stringify(operation?.responses));
console.log(manager.routeWarnings());
try {
manager.generate(true);
} catch (error) {
if (error instanceof OpenAPIValidationError) console.log(error.format());
}
npx tsx strict.ts[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]
{"default":{"description":"Undocumented response"}}
[
'DELETE /tasks/:id: no responses are documented; emitted "default: Undocumented response".'
]
OpenAPI validation failed with 1 error.
error: paths./tasks/{id}.delete.security — Security requirement "bearer" does not match any scheme in components.securitySchemes.- The forgotten
idparameter was documented as a required string. - The route documents no responses. The manager does not invent a
200 OK(wrong for aDELETEthat answers 204). It writes adefaultresponse called "Undocumented response" and records a warning inrouteWarnings(). PassonRouteWarning: (message) => …to the manager to log each one as it happens. Declare the responses and the warning goes away. - The unknown security scheme is a real error, so
generate(true)threw.
Save it as JSON and YAML
toJSON() and toYAML() turn the document into text. YAML is another way to write the same data, with indentation instead of braces. Many people find it easier to read. Pass true to validate first, so a broken document is never written:
import { writeFileSync } from "node:fs";
import { taskApiDocs } from "./task-api.js";
const manager = taskApiDocs();
writeFileSync("openapi.json", manager.toJSON(true));
writeFileSync("openapi.yaml", manager.toYAML(true));
console.log(manager.toYAML(true).split("\n").slice(0, 16).join("\n"));
npx tsx build-docs.tsopenapi: 3.1.0
info:
title: Task API
version: 1.0.0
paths:
/tasks:
get:
operationId: tasks.list
tags:
- Tasks
responses:
"200":
description: All tasks of the user
content:
application/json:
schema:Run it on your computer and look at the files:
npx tsx build-docs.ts openapi: 3.1.0 info: title: Task API … wc -l openapi.json openapi.yaml 125 openapi.json 79 openapi.yaml 204 total tail -n 12 openapi.yaml done: type: boolean required: - id - title - done securitySchemes: bearerAuth: type: http scheme: bearer security: - bearerAuth: []
Commit both files to git. When someone changes a route or a schema, the difference in openapi.yaml shows up in the review, and everybody sees how the API changed.
Serve the document and a docs page
You can also serve the document from the running Task API. manager.toResponse() gives { status, headers, body } for the JSON. manager.toUIResponse({ specUrl }) gives a complete HTML page with Swagger UI that loads the document from specUrl. Both work with any HTTP server; here they go into two @zudojs/http routes:
import { createHttpServer, createNodeHttpAdapter, createResponseContext, createRouter } from "@zudojs/http";
import type { HttpRequestContext } from "@zudojs/http";
import type { OpenAPIDocumentResponse } from "@zudojs/openapi";
import { taskApiDocs } from "./task-api.js";
const docs = taskApiDocs();
function send(answer: OpenAPIDocumentResponse) {
const response = createResponseContext().setStatus(answer.status);
for (const [name, value] of Object.entries(answer.headers)) response.setHeader(name, value);
return response.setBody(answer.body);
}
const router = createRouter();
router.get("/openapi.json", async () => send(docs.toResponse({ validate: true })));
router.get("/docs", async () => send(docs.toUIResponse({ specUrl: "/openapi.json", title: "Task API" })));
const server = await createHttpServer({
adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 0 }),
handler: async (request: HttpRequestContext) => (await router.dispatch(request)).response,
}).start();
const base = `http://127.0.0.1:${server.address?.port}`;
const spec = await fetch(`${base}/openapi.json`);
console.log(spec.status, spec.headers.get("content-type"), Object.keys((await spec.json()).paths));
const page = await fetch(`${base}/docs`);
console.log(page.status, page.headers.get("content-type"));
console.log((await page.text()).match(/<title>.*<\/title>/)?.[0]);
console.log(page.headers.get("content-security-policy")?.split("; ").slice(0, 2));
await server.stop();
npx tsx serve.ts200 application/json; charset=utf-8 [ '/tasks', '/health' ] 200 text/html; charset=utf-8 <title>Task API</title> [ "default-src 'none'", "script-src https://cdn.jsdelivr.net 'sha256-ivBYbHui56j/otciwGSwiCMOVV1Fq8rZhFfaWOWsHZ8='" ]
Open /docs in a browser and you get a page where people can read every operation and try it out. The page loads Swagger UI from a CDN, and the manager protects it:
- The script files are pinned to exact versions with integrity hashes. If the CDN file ever changes, the browser refuses to run it.
- The
content-security-policyheader allows scripts only from that CDN, plus the page's one known inline script.
DOCUMENTATION IS PUBLIC INFORMATION
Everything in the document is visible to anyone who can open/docs. Never put secrets, internal host names or real tokens into descriptions or examples. hidden: true in a route's metadata leaves it out of the document, but that does not protect it: an attacker can still call it. Protect every route with authentication, whether it is documented or not.Docs straight from your router
So far you described each route twice: once in the router that serves it, and once in the manager. Two lists drift apart. With @zudojs/http, a route can carry its own documentation in an openapi option, and generateOpenAPIDocument(router, options) builds the document from the routes the router really has. mountOpenAPI(router, options) goes one step further and serves /openapi.json and a /docs page:
import { createHttpServer, createNodeHttpAdapter, createRouter, generateOpenAPIDocument, mountOpenAPI } from "@zudojs/http";
import { schema } from "@zudojs/schema";
import { NewTask, Task } from "./task-api.js";
const TaskId = schema.object({ id: schema.coerce.number().int().min(1) });
const tasks = [{ id: 1, title: "Buy milk", done: false }];
const router = createRouter();
router.get("/tasks", async () => tasks, { openapi: {
operationId: "tasks.list", tags: ["Tasks"], summary: "List your tasks",
responses: { "200": { schema: schema.array(Task) } },
} });
router.post("/tasks", async () => tasks[0], { openapi: {
operationId: "tasks.create", tags: ["Tasks"], body: NewTask,
responses: { "201": { schema: Task }, "422": { description: "The body failed validation" } },
} });
router.delete("/tasks/:id", async () => undefined, { openapi: {
operationId: "tasks.delete", tags: ["Tasks"], params: TaskId,
responses: { "204": { description: "Deleted" }, "404": { description: "No task with this id" } },
} });
router.get("/health", async () => ({ ok: true }), { openapi: false });
const options = {
info: { title: "Task API", version: "1.0.0" },
securitySchemes: { bearerAuth: { type: "http" as const, scheme: "bearer" } },
security: [{ bearerAuth: [] }],
validate: true,
};
const document = generateOpenAPIDocument(router, options);
for (const [path, item] of Object.entries(document.paths)) console.log(path, Object.keys(item ?? {}));
console.log(JSON.stringify(document.paths["/tasks/{id}"]?.delete?.parameters));
mountOpenAPI(router, options);
const server = await createHttpServer({
adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 0 }),
handler: async (request) => (await router.dispatch(request)).response,
}).start();
const base = `http://127.0.0.1:${server.address?.port}`;
const spec = await fetch(`${base}/openapi.json`);
console.log(spec.status, spec.headers.get("content-type"), Object.keys((await spec.json()).paths));
const page = await fetch(`${base}/docs`);
console.log(page.status, page.headers.get("content-type"));
await server.stop();
npx tsx router-docs.ts/tasks [ 'get', 'post' ]
/tasks/{id} [ 'delete' ]
[{"name":"id","in":"path","required":true,"schema":{"type":"integer","minimum":1}}]
200 application/json; charset=utf-8 [ '/tasks', '/tasks/{id}' ]
200 text/html; charset=utf-8The route options use short forms that save you the OpenAPI boilerplate:
body: NewTaskbecomes a required JSON request body.params,queryandheaderstake an object schema, and each property becomes a parameter:idis now an integer of at least 1.responses: { "201": { schema: Task } }becomes a JSON response with that schema.openapi: falsekeeps/healthout of the document. The two routesmountOpenAPIadded,/openapi.jsonand/docs, stay out too.- Add a route to the router, and it is in the document the next time someone opens
/openapi.json. There is no second list to forget.
The same works for the operations of the previous lesson: toOpenAPIRouteDescriptors(registry, { basePath: "/api" }) turns them into route descriptions, and createOpenAPIDocumentFromRoutes(routes, options) from this package builds the document from any such list. The middleware option of mountOpenAPI can put the docs behind a sign-in when they are only for your team.
Practice
TRY IT YOURSELF
Document PATCH /tasks/:id
Add a route to taskApiDocs()'s manager for PATCH /tasks/:id: it takes an id path parameter and a body { done: boolean }, and answers 200 with a Task or 404. Register the body schema as TaskUpdate. Validate the result.
Show a solution
import { createComponentReference } from "@zudojs/openapi";
import { schema } from "@zudojs/schema";
import { taskApiDocs } from "./task-api.js";
const manager = taskApiDocs();
manager.addSchema("TaskUpdate", schema.object({ done: schema.boolean() }));
manager.addRoute({ method: "patch", path: "/tasks/:id", metadata: { openapi: {
operationId: "tasks.update",
parameters: [{ name: "id", in: "path", schema: { type: "integer", minimum: 1 } }],
requestBody: { required: true, content: { "application/json": { schema: createComponentReference("schemas", "TaskUpdate") } } },
responses: {
"200": { description: "The updated task", content: { "application/json": { schema: createComponentReference("schemas", "Task") } } },
"404": { description: "No task with this id" },
},
} } });
console.log(manager.validate().valid, Object.keys(manager.generate().paths));
npx tsx patch.tstrue [ '/tasks', '/health', '/tasks/{id}' ]TRY IT YOURSELF
A check for CI
Write check-docs.ts: it builds the Task API document for OpenAPI 3.0.3, prints OpenAPI document OK when it is valid, and otherwise prints the problems and sets process.exitCode = 1, so a CI job fails.
Show a solution
import { taskApiDocs } from "./task-api.js";
const manager = taskApiDocs("3.0.3");
const result = manager.validate();
if (result.valid) {
console.log("OpenAPI document OK:", manager.generate().openapi, Object.keys(manager.generate().paths).length, "paths");
} else {
for (const issue of result.errors) console.error(issue.path.join("."), issue.message);
process.exitCode = 1;
}
npx tsx check-docs.tsOpenAPI document OK: 3.0.3 2 paths
Run it in CI next to your tests. A broken reference or a forgotten parameter then fails the build instead of reaching the people who use your API.
Recap
- An OpenAPI document describes every path, method, input and response of an HTTP API, in JSON or YAML, for people and tools.
OpenAPIManagerbuilds it from routes./tasks/:idbecomes/tasks/{id}, and every path parameter is documented, declared or not.addSchematurns@zudojs/schemaschemas into components, so the document and the runtime checks share one source.- Pick 3.1 unless a tool needs 3.0. The converter writes the right keywords for each.
validate()orgenerate(true)catches unknown security schemes and broken references. A route with no responses gets adefaultresponse and a warning, so declare the responses yourself.toJSONandtoYAMLsave the document;toResponseandtoUIResponseserve it with a docs page.- With
@zudojs/http, routes carry their ownopenapioption.generateOpenAPIDocument(router)builds the document andmountOpenAPI(router)serves/openapi.jsonand/docs. - Documentation is public. Hiding a route from it is not security.
Next, you test the Task API: unit tests, fakes and integration tests with @zudojs/testing.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.