OpenAPI
@architectine/xil/openapi walks routes() into an OpenAPI 3.1 document and serves it with a Scalar UI. No code generation — this module only produces the spec.
import { openapi } from "@architectine/xil/openapi";
const spec = openapi({ info: { title: "Shop API", version: "1.0.0" }, servers: [{ url: "https://api.example.com" }], security: { session: { type: "apiKey", in: "cookie", name: "sid" } },}).from(app);
app.use(spec.docs("/docs")); // UI at /docs, spec at /docs/openapi.json.from() accepts an App, one or more Routers, or a mix — anything with .routes().
What gets documented
Section titled “What gets documented”Every route not marked .hidden() becomes one OpenAPI operation, built entirely from what the router already knows:
- Path, query and header parameters from
.params(),.query(),.headers(). A path parameter without a schema is documented as a required string. Query and header parameters are only listed when a schema declares them — there’s no way to know their shape otherwise. - Request body:
.body()becomesapplication/json. A route with.uploads()becomesmultipart/form-datainstead, merging its upload fields (as{ type: "string", format: "binary" }, or an array whenmaxCountis set, with size/type/count constraints noted indescription) alongside any.body()schema’s properties, since a multipart form’s text fields validate through.body()too. - Responses: every
.response()entry (both the main one and.response(status, schema)extras), plus one entry per class passed to.errors(), rendered with the unified{ code, message, details?, requestId }error body. A422is added automatically for any route with an input or upload schema;401and403are added automatically for a route secured byauth.require(). A route with nothing to document at all falls back to a bare200. summary,tags,deprecatedpass straight through from the route builder.
Schema conversion
Section titled “Schema conversion”Any Standard Schema works, dispatched by its ~standard.vendor:
| Vendor | How |
|---|---|
| Zod | zod’s own z.toJSONSchema() (Zod 4+) |
| Valibot | the @valibot/to-json-schema package |
| ArkType | the schema’s native .toJsonSchema() method |
Both are optional dependencies, imported lazily; using a schema from a library you haven’t installed throws a message naming the install command. Register a converter for anything else:
openapi({ info, schemaConverters: { myLib: (schema) => ({ type: "object" /* ... */ }) } });Named schemas are hoisted; unnamed ones are inlined
Section titled “Named schemas are hoisted; unnamed ones are inlined”A schema with a title is hoisted into components.schemas and referenced by $ref everywhere it’s used; a schema without one is inlined at each use site, however many times it’s reused. How you name one depends on the library:
z.object({ id: z.string() }).meta({ id: "Order", title: "Order" }); // Zodv.pipe(v.object({ id: v.string() }), v.title("Order")); // Valibottype({ id: "string" }).configure({ title: "Order" }); // ArkTypeTwo different schemas can’t share a title — building the document throws, naming the collision.
Security
Section titled “Security”auth.require() marks the middleware it returns with a well-known symbol; the OpenAPI module reads that symbol directly rather than depending on the auth package, so a route secured by anything else — a hand-rolled check, a third-party middleware — is invisible, exactly as if it carried no auth at all.
const auth = createAuth({ strategies: { session: cookieSession(), bearer: jwt({ secret }) }, default: "session" });
router.get("/orders").use(auth.require("session", "bearer")).handle(...);Give openapi({ security }) a scheme for each strategy name you want documented, keyed to match:
openapi({ info, security: { session: { type: "apiKey", in: "cookie", name: "sid" }, bearer: { type: "http", scheme: "bearer" }, },});Only strategy names present in security are referenced on an operation; an auth.require() call naming a strategy you didn’t add a scheme for still gets its 401/403 responses, just no security entry pointing at a scheme that doesn’t exist.
wrapResponse
Section titled “wrapResponse”For an app that wraps every JSON response in an envelope the router itself doesn’t know about:
openapi({ info, wrapResponse: (schema) => ({ type: "object", properties: { data: schema }, required: ["data"] }),});Applied to every schema built from .response(), documentation only — it has no effect on what actually gets sent.
Limitations
Section titled “Limitations”- OpenAPI has no concept of an optional path segment. A route declared with
{...}groups (/users{/:id}) is documented with the parameter always present, since there’s no way to express “this segment might not be here” in the spec. - Query and header parameters are only documented when a schema declares their shape; an undeclared
.query()/.headers()means nothing to list. - No code generation.
spec.documentis a plain JSON-serializable object — a CLI export or a generated client is out of scope; see What xil does not do.