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/openapi
bun add @ts-kizuna/openapi
npm install @ts-kizuna/openapi

openApiPlugin

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.

src/contract/k.ts
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:

src/server/api.ts
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:

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 '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.ts

Serving it yourself

The plugin is the batteries-included path, not the only one:

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

Deprecation

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.

src/contract/k.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.
tagsnoneTop-level tag definitions
operationMappernoneCallback 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.

On this page