Keel OpenAPI generates an OpenAPI 3 spec
from your routes and serves Swagger UI to
explore it. It's a Keel package: one register() mounts the docs
at /docs and the spec at /docs/openapi.json.
Nothing is scraped or guessed. The generator reads Keel's own route table —
methods, paths, names, and param constraints are always correct — and enriches
each operation with whatever the route attaches via .config(apiDoc(...)).
Install
// bootstrap/providers.ts
import { OpenApiServiceProvider } from "@shaferllc/keel/openapi";
export const providers = [AppServiceProvider, OpenApiServiceProvider];
Open http://localhost:3000/docs. That's enough for a spec of every route (paths,
methods, path params). To add summaries, request/response schemas, and tags,
document the routes.
Documenting a route
apiDoc() returns route config the generator understands. Its request field is
the same { body, query, params } shape you hand validateRequest, so one set of
Zod schemas both validates and documents:
import { apiDoc } from "@shaferllc/keel/openapi";
import { validateRequest } from "@shaferllc/keel/core";
import { z } from "zod";
const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) });
router
.post("/users", [Users, "store"])
.config(apiDoc({
summary: "Create a user",
tags: ["users"],
request: { body: NewUser },
responses: { 201: { description: "The created user", schema: UserShape } },
}))
.middleware([validateRequest({ body: NewUser })]);
What the generator does with it:
- Path params —
/users/:idbecomes/users/{id}; a.where("id", /\d+/)constraint becomes apattern. - Query params — a
request.queryschema's fields expand into query parameters (eachrequiredper the schema). - Request body — a
request.bodyschema becomes a JSON request body (Zod → JSON Schema via Zod 4'sz.toJSONSchema). - Responses — your documented responses, plus an automatic
422when the route validates input. Undocumented routes get a default200. - Tags —
tags, or the first path segment. - operationId — the route's
.name(), elsemethod_path.
Fields on apiDoc: summary, description, tags, operationId,
deprecated, request, responses, and hidden (leave the route out entirely).
Response and request schemas accept a Zod schema or a plain JSON Schema
object.
Configuration
config/openapi.ts (publish with keel vendor:publish --tag openapi-config):
export default {
enabled: true,
path: "docs", // /docs and /docs/openapi.json
title: "", // defaults to config("app.name")
version: "1.0.0",
servers: [], // e.g. ["https://api.example.com"]
public: false, // serve in production too
cdn: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14",
ignorePaths: ["/watch"], // route prefixes to leave out
};
Access
Like Watch, the docs are gated shut in production by default (open
only when app.debug is on or the app isn't in production). Set public: true to
serve them everywhere, or plug in your own check:
import { OpenApi } from "@shaferllc/keel/openapi";
OpenApi.auth((c) => auth().check());
The gate guards the spec endpoint too.
Exporting the spec
Write the spec to a file — for CI, client generation, or committing it:
keel openapi:export --out openapi.json
On the UI dependency
The spec (/docs/openapi.json) is generated with zero dependencies and runs
anywhere Keel does, including the edge. The Swagger UI loads its assets from
the configured cdn — the one external dependency, confined to the browser. Pin
the version (the default is pinned) or point cdn at a copy you host if you need
a fully self-contained deployment.