Config
Assemble your API in kizuna.config.ts, mount it, and say where its generated clients go.
kizuna.config.ts sits at the root of your app, beside package.json. It assembles everything the API is made of, hands back the api you mount, and says where each generated client is written. The tooling looks for it by name.
The file
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { swiftClient } from '@kizunajs/swift';
import { routes } from './src/routes';
import { user } from './src/identities';
export default defineConfig({
adapter: expressAdapter(),
routes,
auth: {
identities: {
user,
},
},
clients: [
swiftClient({
output: './ios/MyApp/Generated/APIClient.swift',
namespace: 'API',
}),
],
});It default-exports two things: api, which you mount and which every generator reads, and clients, which the CLI writes.
import kizuna from '../kizuna.config';
kizuna.api.mount(app);adapter decides what every handler receives beside its validated inputs. Express here puts req and res in scope for all of them, typed.
See defineConfig for every field it takes.
What each key brings
| Key | What it brings |
|---|---|
auth | The identities a route's auth can require. Each carries its own guard, so this is all the config needs. An identity used without a guard refuses to assemble. See Authentication. |
requestContext | Values every handler receives, resolved once per request. Resolvers run on every route, before the guards, and never deny. See Request Context. |
jobs | Scheduled work. api.mount serves the two job endpoints, and every handler receives a typed jobs runner so a route can run one in process. |
plugins | Routes and helpers installed beside your own, as a list. See below, and Create a Plugin to write one. |
validation | The code values k.issue may emit beyond Zod's own. |
tags | The OpenAPI tags route groups sit under. See k.tags. |
Using what a plugin offers
A plugin can hand your handlers functions to call, under plugins:
updateUser: k
.route({
method: 'PATCH',
path: '/users/:id',
body: UpdateUserSchema,
responses: {
200: UserSchema,
},
})
.handler(async ({ params, body, plugins }) => {
const user = await db.users.update(params.id, body);
plugins.audit.record('users.updateUser');
return {
status: 200,
body: user,
};
}),Destructure it for the short form:
.handler(async ({ params, body, plugins: { audit } }) => {Mounting
Import the config and mount kizuna.api:
Express:
import express from 'express';
import kizuna from '../kizuna.config';
const app = express();
app.use(express.json());
kizuna.api.mount(app);
app.listen(3000);Fastify:
import Fastify from 'fastify';
import kizuna from '../kizuna.config';
const app = Fastify();
await kizuna.api.mount(app);
app.listen({
port: 3000,
});Hono:
import { Hono } from 'hono';
import kizuna from '../kizuna.config';
const app = new Hono();
kizuna.api.mount(app);
export default app;Next.js:
import kizuna from '../../../../kizuna.config';
export const { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS } = kizuna.api.mount({
basePath: '/api',
});Each adapter page covers its own options, error handling, and middleware.
Middleware
Rate limiting, multipart parsing, and the rest go in your framework's own middleware (app.use, Fastify hooks). Authentication belongs in guards, request-scoped values in requestContext.
Clients
Each client comes from the package that generates it, so its options are typed there and a generator you have not installed cannot be configured by accident.
| Target | Import from | Writes |
|---|---|---|
fetchClient | @kizunajs/fetch | A typed TypeScript client |
swiftClient | @kizunajs/swift | A native Swift client |
kotlinClient | @kizunajs/kotlin | A native Kotlin client |
openApiDocument | @kizunajs/openapi | The OpenAPI document, to disk |
clients: [
fetchClient({
output: './packages/api-client/src/generated.ts',
}),
openApiDocument({
output: './openapi.yaml',
}),
],The generated client holds the route shapes and the schemas, and is what a browser imports.
Client options
| Option | Applies to | Description |
|---|---|---|
output | all | Path the generated file is written to |
namespace | Swift, Kotlin | Name of the generated namespace, API by default |
package | Kotlin | Package the generated file declares |
camelCaseProperties | Swift, Kotlin | Convert wire field names to camelCase, mapping the wire name back |
unknownEnumCase | Swift, Kotlin | Give every enum an unknown case, so an unrecognised value still decodes |
openApiDocument takes the same overrides generateOpenApi does, and picks its format from the output extension: .json renders JSON, anything else renders YAML.
Two clients of one language
Add two entries.
clients: [
swiftClient({
output: './ios/MyApp/Generated/APIClient.swift',
namespace: 'API',
}),
swiftClient({
output: './ios/MyApp/Generated/OpenEnumAPIClient.swift',
namespace: 'OpenEnumAPI',
unknownEnumCase: true,
}),
],More than one API
One config is one API. A second config is a second API, with its own routes, its own identities and its own generated clients.
When to split
Split when the two have different callers. A public API versioned for outside integrators and an internal admin API answer to different people, change on different schedules, and want different clients, so one OpenAPI document describing both serves neither.
Split when they are deployed apart. A config has one adapter, so an API on Express and an API on Next.js are already two configs.
Split when the surfaces should not mix. Everything in one config shares a namespace: every route reaches every plugin's exports, one guardSchema shapes every refusal, and every route lands in the same generated client. Two configs keep those apart.
Stay with one config when the routes differ only by prefix. A /admin group under the same identities is a route group, not a second API, and splitting it costs you the shared types.
Two configs
Give each one a file, and each its own clients:
my-repo/
├── kizuna.config.ts # the public API
├── kizuna.admin.config.ts # the admin API
└── src/
├── k.ts # typed by the public Config
├── admin/
│ └── k.ts # typed by the admin Config
└── routes/import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { fetchClient } from '@kizunajs/fetch/server';
import { operator } from './src/admin/identities';
import { admin } from './src/admin/routes';
export default defineConfig({
adapter: expressAdapter(),
routes: {
admin,
},
auth: {
identities: {
operator,
},
},
typescript: {
outputFile: './src/admin/kizuna.types.ts',
},
clients: [
fetchClient({
output: './src/admin/api-client.generated.ts',
}),
],
});Each config writes its own Config, so each gets its own k:
import { Kizuna } from 'kizunajs';
import type { Config } from './kizuna.types';
export const k = new Kizuna<Config>();A route declared against the admin k sees the admin identities and nothing else, so a public identity on an admin route is a type error rather than a runtime surprise.
Running them
kizuna generate reads one config, so point it at each in turn:
{
"scripts": {
"kizuna:generate": "kizuna generate && kizuna generate --config kizuna.admin.config.ts"
}
}The native generators take --config the same way, suffixing an export when a file holds more than the default:
kizuna-swift generate --config kizuna.admin.config.ts --output ... --namespace-name AdminAPISharing between them
What both APIs need lives in a module they both import: schemas, models, an identity, or a whole route group. Kizuna.model keeps a shared schema's name, so User is one named type in both generated clients rather than two shapes that happen to match.
export const UserSchema = Kizuna.model({
title: 'User',
schema: z.object({
id: z.string(),
name: z.string(),
}),
});Serving both from one process is ordinary mounting. On Express, mount takes anything with a use, so a router mounted under a path keeps the admin API off the public one:
import express from 'express';
import publicApi from '../kizuna.config';
import adminApi from '../kizuna.admin.config';
const app = express();
app.use(express.json());
publicApi.api.mount(app);
const adminRouter = express.Router();
adminApi.api.mount(adminRouter);
app.use('/admin', adminRouter);On Next.js each API is its own catch-all route file, and mount({ basePath }) is what tells it which prefix it answers on.
kizuna generate
pnpm add -D @kizunajs/cli@betabun add -d @kizunajs/cli@betanpm install --save-dev @kizunajs/cli@betakizuna generateIt writes two things from kizuna.config.ts: the Config in kizuna.types.ts, and every client under clients. A file that already matches is left alone, so watchers and build tools see no change.
| Option | Default | Description |
|---|---|---|
--check | off | Report what would be rewritten and write nothing. See below |
--config | kizuna.config.ts | The config to read |
--types | the config's own | Where the Config is written, overriding typescript.outputFile |
Where the types land is usually the config's business rather than the command's:
export default defineConfig({
adapter: expressAdapter(),
routes,
typescript: {
outputFile: './src/kizuna.types.ts',
},
});Wire it into your scripts
Two scripts cover everywhere it needs to run:
{
"scripts": {
"kizuna:generate": "kizuna generate",
"kizuna:check": "kizuna generate --check"
}
}Run kizuna:generate after changing a route, an identity, or the config. Run kizuna:check in CI, where it fails on anything nobody regenerated.
Whoever changes your routes needs to know about that first script, and a coding agent working in the repository reads the same files your team does. Say it where they look:
After changing a route, run `pnpm kizuna:generate`.kizuna.types.ts
The Config interface is read from your config's syntax, not by running it. That is what lets k be typed by the config that serves your routes without the routes waiting on a config that loads.
/**
* This file was automatically generated by kizuna.
* DO NOT MODIFY IT BY HAND. Instead, modify your source kizuna config,
* and re-run `kizuna generate` to regenerate this file.
*/
import type { expressAdapter } from '@kizunajs/express';
import type { tags, user } from '@my-repo/api';
export interface Config {
adapter: ReturnType<typeof expressAdapter>;
tags: typeof tags;
auth: {
identities: {
user: typeof user;
};
};
}It carries adapter, tags, auth, requestContext, validation, jobs and plugins. Your routes stay out: they are read at runtime, and typing against them would make the config a prerequisite for declaring the routes it serves.
Point new Kizuna<Config>() at it once and every name you write is checked against it:
import { Kizuna } from 'kizunajs';
import type { Config } from '../kizuna.types';
export const k = new Kizuna<Config>();kizuna generate --check
kizuna generate --checkThe same work without writing. It names whatever has fallen behind and exits 1, so a pipeline fails on a generated client nobody regenerated:
kizuna.types.ts is behind the config
src/lib/api-client.generated.ts is behind the configCommit what kizuna generate writes. The check compares the files on disk against the config, so a generated client that is gitignored reads as missing on every clean checkout and the pipeline fails on it.
Run it in CI beside your typecheck. See Breaking changes for the rest of the pipeline.
writeClients and checkClients from @kizunajs/cli are the same two steps as functions, for a script that does more than the command does.
Reference
defineConfig- Routes for what you put under
routes - Fetch client, Swift, Kotlin