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@beta
bun add @kizunajs/openapi@beta
npm install @kizunajs/openapi@beta

Install it

openApiPlugin serves the document and an API reference UI for it, on any adapter.

Declare the plugin

kizuna.config.ts
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:

PropServesPath
docsPaththe API reference UI, Scalar by defaultanything
jsonPaththe documentends in .json
yamlPaththe document as YAMLends 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

scripts/generate-openapi.ts
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'));
Terminal
tsx scripts/generate-openapi.ts

Serving it yourself

generateOpenApi renders the document directly:

src/index.ts
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.

scripts/generate-openapi.ts
import { generateOpenApi } from '@kizunajs/openapi';
import kizuna from '../kizuna.config';

const spec = generateOpenApi(kizuna.api);

spec('json'); // OpenApiDocument object
spec('yaml'); // YAML string

Deprecation

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.

kizuna.config.ts
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,
});
OptionDefaultDescription
inforequiredOpenAPI info object
serversnoneServer URLs
setOperationIdfalseSet operationId from the route key. Use 'concatenated-path' to include parent keys.
operationMappernoneCallback to transform each operation before it is added to the spec
derivedHeadfalseDocument 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.

src/identities.ts
export const user = k.identity
    .bearer({
        context: z.object({
            userId: z.string(),
        }),
    })
    .guard(/* ... */);
openapi.yaml
# 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.

On this page