OpenAPI Serving
Problem
Section titled “Problem”You have an OpenAPI specification — either generated by
generateOpenAPI() or
assembled manually — and you need to:
- Serve the spec as JSON from an API route (
/openapi.json) - Mount an interactive API explorer so developers can browse and test endpoints from a browser
Both routes should be publicly accessible without authentication.
Solution
Section titled “Solution”Serving the Spec
Section titled “Serving the Spec”import http from "node:http";import { compose, handler } from "@centralping/ergo";
const spec = { openapi: "3.1.0", info: { title: "My API", version: "1.0.0" }, paths: { "/health": { get: { responses: { 200: { description: "OK" } } }, }, },};
const pipeline = compose( (req, res) => ({ response: { body: spec } }),);
const server = http.createServer(handler(pipeline));server.listen(3000);// GET /openapi.json → 200 with spec JSONimport createRouter from "@centralping/ergo-router";import generateOpenAPI from "@centralping/ergo-router/openapi";
const router = createRouter({ defaults: { accepts: { types: ["application/json"] }, authorization: { strategies: [bearerStrategy] }, },});
// Register all application routes firstrouter.get("/users/:id", { validate: { params: { type: "object", properties: { id: { type: "string" } }, }, }, openapi: { summary: "Get user by ID", tags: ["Users"] }, execute: getUser,});
// Generate spec after all routes are registeredconst spec = generateOpenAPI(router, { title: "My API", version: "1.0.0",});
// Serve the spec without authrouter.get("/openapi.json", { authorization: false, execute: () => ({ response: { body: spec } }),});Interactive API Explorer
Section titled “Interactive API Explorer”import http from "node:http";
const spec = { openapi: "3.1.0", info: { title: "My API", version: "1.0.0" }, paths: {},};
const html = `<!doctype html><html> <head> <title>API Reference</title> <meta charset="utf-8" /> </head> <body> <script id="api-reference" data-url="/openapi.json" ></script> <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script> </body></html>`;
const server = http.createServer((req, res) => { if (req.url === "/docs") { res.writeHead(200, { "Content-Type": "text/html" }); res.end(html); } else if (req.url === "/openapi.json") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(spec)); } else { res.writeHead(404); res.end(); }});
server.listen(3000);import createRouter from "@centralping/ergo-router";import generateOpenAPI from "@centralping/ergo-router/openapi";
const router = createRouter({ defaults: { accepts: { types: ["application/json"] }, authorization: { strategies: [bearerStrategy] }, },});
// ... register application routes ...
const spec = generateOpenAPI(router, { title: "My API", version: "1.0.0",});
router.get("/openapi.json", { authorization: false, execute: () => ({ response: { body: spec } }),});
const docsHtml = `<!doctype html><html> <head> <title>API Reference</title> <meta charset="utf-8" /> </head> <body> <script id="api-reference" data-url="/openapi.json" ></script> <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script> </body></html>`;
router.get("/docs", { authorization: false, noSend: true, execute: (req, res) => { res.writeHead(200, { "Content-Type": "text/html" }); res.end(docsHtml); },});Explanation
Section titled “Explanation”Generation Timing
Section titled “Generation Timing”generateOpenAPI() iterates router._routes to build the spec — only
routes registered before the call appear in the output. Generate the
spec at startup time, after all application routes are registered but
before starting the server:
// 1. Create router and register all routesconst router = createRouter({ /* ... */ });router.get("/users", { /* ... */ });router.post("/users", { /* ... */ });
// 2. Generate spec (captures /users GET and POST)const spec = generateOpenAPI(router, { title: "My API", version: "1.0.0" });
// 3. Register spec/docs routes (these won't appear in the spec)router.get("/openapi.json", { authorization: false, execute: () => ({ response: { body: spec } }) });
// 4. Start serverconst { server } = await graceful(router.handle(), { port: 3000 });Auth-Free Routes
Section titled “Auth-Free Routes”The spec and docs routes use authorization: false to bypass the
default authorization middleware. This makes them publicly accessible
without credentials.
The openapi Annotation Key
Section titled “The openapi Annotation Key”Route configs accept an openapi key to enrich the auto-derived spec.
Annotations merge on top of derived values — any property you set in
the annotation overrides the auto-extracted equivalent:
router.post("/users", { validate: { body: userSchema }, openapi: { summary: "Create a new user", tags: ["Users"], responses: { 201: { description: "User created" }, 409: { description: "Email already exists" }, }, }, execute: createUser,});Without annotations, generateOpenAPI still produces a valid spec by
deriving parameters, request bodies, and security requirements from
the route config. Annotations let you add human-readable summaries,
tags for grouping, and custom response descriptions.
The noSend Pattern for HTML Responses
Section titled “The noSend Pattern for HTML Responses”The docs route uses noSend: true because ergo-router’s send()
auto-wraps responses as JSON. Since the API explorer page is an HTML
document, the handler must write the response directly via res.end().
The pipeline still runs (transport middleware, OTEL tracing), but the
framework does not call send() after the handler completes.
See Route Options for more on
noSend.
Config Resolution Interaction
Section titled “Config Resolution Interaction”generateOpenAPI resolves each extractable config key (validate,
authorization, accepts) using the same precedence as the pipeline
builder: route config overrides defaults, false disables, true
enables with empty options. This means the generated spec accurately
reflects the runtime behavior of each route.
See Config Resolution for the full resolution rules.