Skip to content

OpenAPI Serving

You have an OpenAPI specification — either generated by generateOpenAPI() or assembled manually — and you need to:

  1. Serve the spec as JSON from an API route (/openapi.json)
  2. Mount an interactive API explorer so developers can browse and test endpoints from a browser

Both routes should be publicly accessible without authentication.

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 JSON
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);

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 routes
const 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 server
const { server } = await graceful(router.handle(), { port: 3000 });

The spec and docs routes use authorization: false to bypass the default authorization middleware. This makes them publicly accessible without credentials.

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 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.

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.