OpenAPI
Generate an OpenAPI 3.1.0 document from your routes, and serve it with a reference UI.
@kizunajs/openapi generates an OpenAPI 3.1.0 document from your routes.
pnpm add @kizunajs/openapi@betabun add @kizunajs/openapi@betanpm install @kizunajs/openapi@betaInstall it
openApiPlugin serves the document and an API reference UI for it, on any adapter.
Declare the plugin
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { openApiPlugin } from '@kizunajs/openapi';
import { routes } from './src/routes';
export default defineConfig({
adapter: expressAdapter(),
routes,
plugins: [
openApiPlugin({
info: {
title: 'My API',
version: '1.0.0',
},
docsPath: '/docs',
}),
],
});Each prop serves one route, and serves nothing until you give it a path:
| Prop | Serves | Path |
|---|---|---|
docsPath | the API reference UI, Scalar by default | anything |
jsonPath | the document | ends in .json |
yamlPath | the document as YAML | ends in .yaml |
openApiPlugin({
info,
docsPath: '/docs',
jsonPath: '/openapi.json',
});The UI embeds the document, so docsPath alone is enough to browse the API. All three are plugin routes, so they stay out of your fetch client and out of the document itself.
The routes are public. Gate them with your framework's own middleware if that is not what you want.
Writing it to disk
import { writeFileSync } from 'node:fs';
import { generateOpenApi } from '@kizunajs/openapi';
import kizuna from '../kizuna.config';
const spec = generateOpenApi(kizuna.api);
writeFileSync('openapi.yaml', spec('yaml'));tsx scripts/generate-openapi.tsServing it yourself
generateOpenApi renders the document directly:
const spec = generateOpenApi(kizuna.api);
app.get('/openapi.yaml', (_req, res) => {
res.type('text/yaml; charset=utf-8').send(spec('yaml'));
});Leave yamlPath unset when you do, or two handlers claim the path and whichever registered first wins. Kizuna sees collisions between your routes and a plugin's, but a route you register on the app yourself is invisible to it.
Mixing is the useful case: set jsonPath and leave docsPath unset, and the plugin serves the document while you render your own UI.
Swagger instead of Scalar
openApiPlugin({
info,
provider: 'swagger',
});Point cdnUrl at a self-hosted copy for air-gapped deployments or a strict CSP.
generateOpenApi
generateOpenApi returns a renderer. Call it with 'json' for the document object or 'yaml' for a YAML string. Everything it needs, info included, comes off the api, declared once on openApiPlugin.
import { generateOpenApi } from '@kizunajs/openapi';
import kizuna from '../kizuna.config';
const spec = generateOpenApi(kizuna.api);
spec('json'); // OpenApiDocument object
spec('yaml'); // YAML stringDeprecation
Routes with deprecated and fields with .meta({ deprecated: ... }) get deprecated: true in the generated spec, with no extra options here. Routes with a deprecation date or a sunset get the Deprecation, Sunset, and Link headers they send documented on every response. See Deprecations.
Examples
Give a field an example with .meta({ example: ... }):
const UserSchema = Kizuna.model({
title: 'User',
schema: z.object({
id: z.string().meta({
example: 'usr_k7f3q9',
}),
email: z.email().meta({
example: 'ada@example.com',
}),
}),
});Pass an array for several. It lands as JSON Schema examples on the field's schema, and documentation UIs like Scalar and Swagger pick it up from there.
Options
They go on openApiPlugin in your config, and generateOpenApi reads them from there.
openApiPlugin({
info: {
title: 'My API',
version: '1.0.0',
description: 'Optional description',
},
servers: [
{
url: 'https://api.example.com',
description: 'Production',
},
{
url: 'http://localhost:3000',
description: 'Development',
},
],
setOperationId: true,
});| Option | Default | Description |
|---|---|---|
info | required | OpenAPI info object |
servers | none | Server URLs |
setOperationId | false | Set operationId from the route key. Use 'concatenated-path' to include parent keys. |
operationMapper | none | Callback to transform each operation before it is added to the spec |
derivedHead | false | Document the derived head operation on every GET path without a declared one |
Security
Security comes from what you declared. The identities you name under auth.identities on defineConfig are emitted as components.securitySchemes, and each route's auth becomes that operation's security, with the permissions an OAuth route requires as its scopes, the roles a route accepts under x-kizuna-roles, and the permissions it requires under x-kizuna-requires. There are no generator options for it, so the spec cannot disagree with what the server enforces.
export const user = k.identity
.bearer({
context: z.object({
userId: z.string(),
}),
})
.guard(/* ... */);# generated
components:
securitySchemes:
user:
type: http
scheme: bearer
paths:
/workspace:
get:
security:
- user: []Routes with auth: false get no security entry. A custom identity also emits no security, since OpenAPI can't describe its credential, but its routes carry an x-kizuna-guarded extension so they stay distinguishable from public ones.
Validation errors
Routes with a body or query schema automatically include a 400 validation error response, matching what Kizuna returns when request data fails validation. A route that declares its own 400 keeps it; nothing is added.
Refusals
A route whose auth names an identity includes 401 and 403 as Problem Details. The 401 documents the WWW-Authenticate header too, required when every identity on the route has a challenge and optional when only some do. A route behind an API key declares no header.
Streams
A response declared with stream is documented under text/event-stream, with a schema describing one message as event, data, id, and retry, one oneOf branch per named event. OpenAPI 3.1 has no field for a sequence of messages, so it sits under schema. OpenAPI 3.2 adds itemSchema with this shape, and the generator moves to it when it targets 3.2.
Reference
Breaking changes
diffAgainst compares your API against a git ref and reports what each change costs a caller.
Scheduled jobs
Scheduled jobs live under jobs on your config, not routes, so they are not in the document.