Routes
Declare a route, the schemas it takes and answers with, and the handler that serves it.
A route is a method, a path, the schemas it takes and answers with, and the handler that serves it. Everything else is read from that: defineConfig assembles your routes into the api you mount, and the generators write your OpenAPI document and your clients from the same declarations.
Create the surface
Construct it once, usually in src/k.ts, and export k. Its type parameter is the Config that kizuna generate writes from your kizuna.config.ts, and that is what types everything a handler receives and checks every name you write.
import { Kizuna } from 'kizunajs';
import type { Config } from '../kizuna.types';
export const k = new Kizuna<Config>();Declare a tag set beside it, so route groups have a tag to sit under. Its keys become the group names k.routes accepts.
import { k } from './k';
export const tags = k.tags({
users: 'Users',
});Declare your routes
k.route takes what the route is, and .handler() takes what answers it. k.routes groups them under one of your tags:
import { z } from 'zod';
import { ProblemDetailsSchema } from 'kizunajs/schemas';
import { k } from '../k';
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
});
export const users = k.routes('users', {
listUsers: k
.route({
method: 'GET',
path: '/users',
query: z.object({
page: z.int().min(1).default(1),
limit: z.int().min(1).max(100).default(10),
}),
responses: {
200: z.object({
users: z.array(UserSchema),
total: z.number(),
}),
},
})
.handler(async ({ query }) => ({
status: 200,
body: {
users: await db.users.findMany({
skip: (query.page - 1) * query.limit,
take: query.limit,
}),
total: await db.users.count(),
},
})),
createUser: k
.route({
method: 'POST',
path: '/users',
body: z.object({
name: z.string().min(1),
email: z.email(),
}),
responses: {
201: UserSchema,
400: ProblemDetailsSchema,
},
})
.handler(async ({ body }) => ({
status: 201,
body: await db.users.create(body),
})),
});The handler's query and body are typed from the declaration right above it, and its return is checked against that route's responses. See Routes for everything a handler receives.
A route only a generator or a client reads can skip .handler(). One your config serves cannot.
Route fields
| Field | Required | Description |
|---|---|---|
method | Yes | GET POST PUT PATCH DELETE HEAD OPTIONS |
path | Yes | URL path. Use :name for path parameters |
responses | Yes | Response schemas keyed by status code |
pathParams | No | Path parameter schema, keyed by placeholder name |
body | No | Request body schema |
query | No | Query string schema |
headers | No | Request headers schema |
contentType | No | 'application/json' (default), 'multipart/form-data', 'application/x-www-form-urlencoded' |
summary | No | Short description shown in OpenAPI, and what a model reads when the route publishes as a tool |
description | No | The longer account, for OpenAPI and for a model |
tags | No | OpenAPI tags |
auth | No | The identity this route requires, and the roles or permissions it accepts. Required once your config declares an identity, so public takes an explicit false. See Access Control |
tool | No | Publish this route as a tool a model can call |
deprecated | No | true, or the migration message. See Deprecations |
cache | No | The Cache-Control policy its responses send. See Caching |
Typing path parameters
params is typed from the path itself, so /users/:id gives the handler params.id as a string with nothing else to declare. Add pathParams when you want it parsed or narrowed:
getUser: k
.route({
method: 'GET',
path: '/users/:id',
pathParams: z.object({
id: z.uuid(),
}),
responses: {
200: UserSchema,
},
})
.handler(/* ... */),Its keys are checked against the path, so a name the path does not carry is a compile error naming the path it looked in:
pathParams declares "userId", which is not a parameter in path "/users/:id"Each value must be a scalar, since a path segment arrives as one string. Structured values belong in query. To parse one into something richer, use z.string().transform(...):
pathParams: z.object({
tags: z.string().transform((value) => value.split(',')),
}),Handler arguments
Each handler receives an object with the validated inputs for that route:
| Property | Available when |
|---|---|
params | Route path has :param placeholders |
query | Route has a query schema |
body | Route has a body schema |
headers | Route has a headers schema |
throwError | Always |
auth | Route's auth names an identity |
requestContext | Config declares request context |
plugins | Config declares plugins |
jobs | Config declares jobs |
req, res | Express adapter |
c | Hono adapter |
request, reply | Fastify adapter |
request | Next.js adapter |
Which framework object arrives is decided by the adapter your config names, so a handler reads req or c without importing either framework's types.
Identity context
When a route's auth names an identity, the handler receives that identity's context under auth, keyed by the identity's name. The context is whatever its guard returned, typed by the identity's context schema, with role beside it when the identity declares roles, and permissions when those roles come from a catalog. A row check goes here too, with auth in hand. See Access Control.
listMembers: k
.route({
method: 'GET',
path: '/members',
auth: 'user',
responses: {
200: MemberListSchema,
},
})
.handler(({ auth }) => ({
status: 200,
body: {
members: findMembersExcept(auth.user.userId),
},
})),A route requiring several identities receives all of them under auth:
.handler(({ body, auth }) => {
// auth.user.userId and auth.member.workspaceUserId are both there, typed
});A route whose auth names roles or requires is checked before the handler runs, so the handler never asks whether the caller may be there. It reads the role when it needs it for something else, such as an audit line:
deleteWorkspace: k
.route({
method: 'DELETE',
path: '/workspace',
auth: {
identity: 'member',
requires: {
workspace: ['delete'],
},
},
responses: {
200: OkSchema,
},
})
.handler(async ({ auth }) => {
await audit('workspace.deleted', {
by: auth.member.workspaceUserId,
as: auth.member.role,
});
return {
status: 200,
body: {
ok: true,
},
};
}),Values declared as request context arrive the same way, under requestContext on every route.
Return type
Return { status, body } matching one of the route's declared responses. TypeScript enforces that the status and body match.
// Route: responses: { 200: UserSchema, 404: ProblemDetailsSchema }
return {
status: 200,
body: user,
};
return {
status: 404,
body: {
detail: 'User not found',
},
};For routes with typed response headers, include headers too:
return {
status: 200,
body: user,
headers: {
'x-request-id': req.headers['x-request-id'] ?? '',
},
};A status declared with stream takes an async generator function as body. Kizuna sends the status and headers, then writes each yield as it happens:
return {
status: 200,
body: async function* ({ signal }) {
yield {
event: 'delta',
data: {
text: 'Hello',
},
};
},
};signal fires when the client goes away. throwError takes no streamed status.
A response with a cache policy sends its Cache-Control whenever your handler returns that status. The declaration wins, so returning a cache-control of your own does not change it.
Error responses
Use ProblemDetailsSchema from kizunajs/schemas for error status codes. This is the same shape deny() produces in guards, and the one every built-in Kizuna error uses.
In the handler, throwError returns a typed error response. It takes the same { status, body } as a normal return but throws internally, which is useful in branching logic where you want to bail out early:
.handler(async ({ params, throwError }) => {
const user = await db.users.findById(params.id);
if (!user) {
return throwError({
status: 404,
body: {
detail: 'Not found',
},
});
}
return {
status: 200,
body: user,
};
});TypeScript enforces the status and body the same way it does for a return value.
Validation errors
When a request body or query fails schema validation, Kizuna returns a 400 before the handler runs:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Request validation failed",
"errors": [
{
"code": "invalid_type",
"path": ["email"],
"message": "Expected string, received number"
},
{
"code": "invalid_phone_number",
"path": ["phone"],
"message": "Must include country code"
}
]
}Each issue includes a code that tells you why the field failed, not just that it failed. Codes come from Zod's built-in validation:
| Code | When it fires |
|---|---|
invalid_type | Wrong type or missing required field |
too_small | Below min() / minLength() |
too_big | Above max() / maxLength() |
invalid_string_format | Failed email(), url(), uuid(), etc. |
unrecognized_keys | Extra keys when using strict() |
not_multiple_of | Failed multipleOf() |
custom | .refine() or .superRefine() check |
User-defined checks (.refine()) report as custom by default. To emit a typed code instead, like invalid_phone_number above, declare it under validation.issueCodes on defineConfig and raise it with k.issue. It then shows up in errors[].code as a typed literal on both the server and the generated client, not a bare string.
The message and path fields come from your Zod schemas, custom messages (z.string().min(1, 'Name is required')) included.
This 400 is reflected in the fetch client return type and the OpenAPI spec for any route that declares a body or query schema.
If you declare your own 400 response, the client sees a union of both your type and Kizuna's ValidationError. Use isValidationError on the client to distinguish them.
Query, path, and header coercion
Values in the URL and in headers always arrive as strings. Kizuna coerces them to the types you declare, so both the client and the server see the parsed value:
| Declare | Accepts on the wire | You get |
|---|---|---|
z.number() / z.int() | "42" | 42 |
z.boolean() | "true" / "false" | true / false |
z.bigint() | "9007199254740993" | 9007199254740993n |
z.date() | an ISO 8601 string | Date |
Arrays of these coerce too (z.array(z.number())). The fetch client serializes Date and bigint values for you, so a z.date() query param round-trips without manual formatting. Request bodies are sent as JSON and keep their types.
Because coercion is built in, z.coerce is not supported, and k.routes throws if any schema uses it. Declare the plain schema (z.number(), z.date(), z.bigint()). To receive a different type than you send, use z.string().transform(...).
Response schemas
One Zod schema per status code:
responses: {
200: UserSchema,
404: ProblemDetailsSchema,
},Use z.void() for a status that carries no body:
responses: {
204: z.void(),
404: ProblemDetailsSchema,
},To type response headers, give the status a { body, headers } object:
responses: {
200: {
body: UserSchema,
headers: z.object({
'x-request-id': z.string().optional(),
}),
},
},To send a response piece by piece, declare stream in place of body, with the schema of one message, or a record of event name to schema:
responses: {
200: {
stream: {
delta: z.object({
text: z.string(),
}),
},
},
},The handler then yields messages instead of returning one body, and the fetch client reads them with for await. See Streaming.
One route per file
A route and its handler travel together, so a route that outgrows its group moves to its own file and k.routes still groups it:
import { z } from 'zod';
import { ProblemDetailsSchema } from 'kizunajs/schemas';
import { k } from '../../k';
import { UserSchema } from '../schemas';
export const createUser = k
.route({
method: 'POST',
path: '/users',
body: z.object({
name: z.string().min(1),
email: z.email(),
}),
responses: {
201: UserSchema,
400: ProblemDetailsSchema,
},
})
.handler(async ({ body }) => ({
status: 201,
body: await db.users.create(body),
}));import { k } from '../../k';
import { createUser } from './create-user';
import { getUser } from './get-user';
export const users = k.routes('users', {
createUser,
getUser,
});Nothing runs at import
kizuna generate imports your route files to read them, so whatever a module does at the top level, it does on every generate. Importing a database client is fine. Opening a connection, reading a file, or throwing on a missing environment variable is not, so keep that inside the handler.
Non-JSON responses
For non-JSON responses, set contentType to send the body as-is instead of JSON. Responses default to application/json, errors to application/problem+json, and a stream to text/event-stream. Declare a non-JSON body as z.string():
responses: {
200: {
body: z.string(),
contentType: 'text/csv',
},
},For binary responses, use BinarySchema for a body of raw bytes (Uint8Array/Buffer), such as a PDF download:
import { BinarySchema } from 'kizunajs/schemas';
responses: {
200: {
body: BinarySchema,
contentType: 'application/pdf',
},
},Going further
Naming schemas with models
By default the generators inline each schema wherever it appears. Use Kizuna.model to give a schema a name they can reuse:
import { Kizuna } from 'kizunajs';
export const UserSchema = Kizuna.model({
title: 'User',
description: 'A user in the system',
schema: z.object({
id: z.string(),
name: z.string(),
email: z.email(),
}),
});- OpenAPI: extracted into
components.schemas.User, and every usage becomes a$ref - Swift: emitted as a shared
public struct User - TypeScript: emitted as
API.Userin the generated client (see Named types)
Splitting routes across files
Large APIs split into groups. Declare the tags once with k.tags, name them on defineConfig, then declare each group with k.routes in its own file:
import { k } from './k';
export const tags = k.tags({
users: {
title: 'Users',
description: 'User management endpoints',
},
health: 'Health',
});import { defineConfig } from 'kizunajs';
import { tags } from './src/tags';
import { routes } from './src/routes';
export default defineConfig({
tags,
routes,
});import { k } from '../k';
export const users = k.routes('users', {
listUsers: k.route({ ... }).handler(...),
createUser: k.route({ ... }).handler(...),
});An index.ts beside them collects the groups, so the config has one import to make:
import { users } from './users';
import { health } from './health';
export const routes = {
users,
health,
};import { defineConfig } from 'kizunajs';
import { routes } from './src/routes';
export default defineConfig({
adapter: expressAdapter(),
tags,
routes,
});See Project Structure for where the rest of the pieces go.
The group tag's title becomes the OpenAPI tag for every route in the group, and the description is included in the tag definition. A route can also cross-tag by listing tag keys directly:
cancelAccount: k
.route({
method: 'POST',
path: '/account/cancel',
tags: ['users', 'health'],
// ...
})
.handler(/* ... */),Deprecating routes and fields
Set deprecated on a route, either true or a migration message:
deleteUser: k
.route({
method: 'DELETE',
path: '/users/:id',
deprecated: 'use `archiveUser` instead',
responses: {
200: z.object({
success: z.boolean(),
}),
},
})
.handler(/* ... */),Fields declare it in their Zod metadata, with the same true | string shape:
getUser: k
.route({
method: 'GET',
path: '/users/:id',
responses: {
200: z.object({
id: z.string(),
name: z.string(),
legacyUsername: z.string().optional().meta({
deprecated: 'use name instead',
}),
}),
},
})
.handler(/* ... */),Your editor, the OpenAPI spec, and the Swift and Kotlin clients pick both up. A route also takes a deprecation date and a sunset timestamp, both ISO 8601, and every response from it then announces them in the Deprecation and Sunset headers. See Deprecations.
Reference
Next steps
- Config to assemble your routes and serve them
- Authentication for the identities a route's
authnames