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

Install it

openApiPlugin writes the document on kizuna generate, and serves it with an API reference UI 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: {
        title: 'My API',
        version: '1.0.0',
    },
    docsPath: '/docs',
    jsonPath: '/openapi.json',
});

The UI embeds the document, so docsPath alone is enough to browse the API. All three are plugin routes, which are hidden, so they stay out of your clients 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

Give the plugin an output, and kizuna generate writes the document there:

kizuna.config.ts
openApiPlugin({
    info: {
        title: 'My API',
        version: '1.0.0',
    },
    output: './openapi.yaml',
}),

It's the same document the plugin serves. The format comes from the extension, JSON for .json and YAML for anything else, and kizuna generate --check fails CI when the file falls behind your routes.

To write it from a script of your own instead, like a build step in CI, render it with generateOpenApi:

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

Which routes it documents

The document describes every route that isn't hidden. A health check or a webhook route marked hidden isn't in it, so a document you serve publicly never maps out your internal routes.

To document every route the server serves, opt in:

kizuna.config.ts
openApiPlugin({
    info: {
        title: 'My API',
        version: '1.0.0',
    },
    output: './openapi.internal.yaml',
    includeHidden: true,
}),

That's the one for your own team, written to disk or served behind auth.

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: {
        title: 'My API',
        version: '1.0.0',
    },
    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
outputnoneWhere kizuna generate writes the document. .json for JSON, anything else for YAML
includeHiddenfalseDocument hidden routes too
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.