OpenAPI
Generate an OpenAPI 3.1.0 document from your contract, and serve it with a reference UI.
@ts-kizuna/openapi generates an OpenAPI 3.1.0 document from your contract.
pnpm add @ts-kizuna/openapibun add @ts-kizuna/openapinpm install @ts-kizuna/openapiopenApiPlugin
openApiPlugin serves the document and an API reference UI for it. It generates the document from your contract, and api.mount serves its routes on any adapter.
import { Kizuna } from '@ts-kizuna/core';
import { openApiPlugin } from '@ts-kizuna/openapi';
export const k = new Kizuna({
tags,
plugins: {
openApi: openApiPlugin({
info: {
title: 'My API',
version: '1.0.0',
},
}),
},
});Then pass its server half to server.api, where the generator itself lives:
import { openApiPluginServer } from '@ts-kizuna/openapi/server';
export const api = server.api({
router,
plugins: {
openApi: openApiPluginServer(),
},
});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 'fs';
import { generateOpenApi } from '@ts-kizuna/openapi/server';
import { contract } from '../src/contract';
const spec = generateOpenApi(contract);
writeFileSync('openapi.yaml', spec('yaml'));tsx scripts/generate-openapi.tsServing it yourself
The plugin is the batteries-included path, not the only one:
const spec = generateOpenApi(contract);
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 cannot catch that one for you: it sees collisions between the contract and a plugin, 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 contract: you declare it once on openApiPlugin in k.ts, and nothing here can disagree with it.
import { generateOpenApi } from '@ts-kizuna/openapi/server';
import { contract } from '@shared/contract';
const spec = generateOpenApi(contract);
spec('json'); // OpenApiDocument object
spec('yaml'); // YAML stringDeprecation
Routes and fields marked /** @deprecated */ get deprecated: true in the generated spec, with no extra options here. generateOpenApi(contract) reads them straight off the contract, once you've added the one-time build step. See Deprecations for setup.
Options
They go on openApiPlugin in k.ts, 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. |
tags | none | Top-level tag definitions |
operationMapper | none | Callback to transform each operation before it is added to the spec |
Security
Security comes from the contract, not from generator options. The identities you register on new Kizuna() are emitted as components.securitySchemes, and the contract's auth map becomes each operation's security, with oauth2 scopes included. There is nothing to declare here, and the spec can never disagree with what the server actually enforces.
export const user = Kizuna.identity.bearer({
context: z.object({
userId: z.string(),
}),
});# generated
components:
securitySchemes:
user:
type: http
scheme: bearer
paths:
/workspace:
get:
security:
- user: []Routes marked false in the auth map get no security entry, so they document as public, because they are. A custom identity also emits no security (OpenAPI can't describe its credential), but its routes carry an x-kizuna-guarded extension, so protected-out-of-band stays distinct from public.
Validation errors
Routes with a body or query schema automatically include a 400 validation error response in the generated spec. This matches the error kizuna returns when request data fails schema validation.
If a route already declares a 400 response, the automatic validation error is not added, because your declaration takes precedence.
Reference
Breaking changes
Use oasdiff to compare your generated spec against a previous version and catch breaking changes before they ship. See the Breaking Changes guide for setup and CI integration.
Scheduled jobs
Scheduled jobs live under jobs on the contract rather than routes, so they are not in the document. A job has no path or method of its own to describe, and the two endpoints that serve them have one legitimate caller each.