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.

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

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

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

FieldRequiredDescription
methodYesGET POST PUT PATCH DELETE HEAD OPTIONS
pathYesURL path. Use :name for path parameters
responsesYesResponse schemas keyed by status code
pathParamsNoPath parameter schema, keyed by placeholder name
bodyNoRequest body schema
queryNoQuery string schema
headersNoRequest headers schema
contentTypeNo'application/json' (default), 'multipart/form-data', 'application/x-www-form-urlencoded'
summaryNoShort description shown in OpenAPI, and what a model reads when the route publishes as a tool
descriptionNoThe longer account, for OpenAPI and for a model
tagsNoOpenAPI tags
authNoThe 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
toolNoPublish this route as a tool a model can call
deprecatedNotrue, or the migration message. See Deprecations
cacheNoThe 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:

PropertyAvailable when
paramsRoute path has :param placeholders
queryRoute has a query schema
bodyRoute has a body schema
headersRoute has a headers schema
throwErrorAlways
authRoute's auth names an identity
requestContextConfig declares request context
pluginsConfig declares plugins
jobsConfig declares jobs
req, resExpress adapter
cHono adapter
request, replyFastify adapter
requestNext.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.

src/routes/members.ts
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:

src/routes/workspace.ts
.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:

src/routes/workspace.ts
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:

src/routes/users.ts
.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:

CodeWhen it fires
invalid_typeWrong type or missing required field
too_smallBelow min() / minLength()
too_bigAbove max() / maxLength()
invalid_string_formatFailed email(), url(), uuid(), etc.
unrecognized_keysExtra keys when using strict()
not_multiple_ofFailed 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:

DeclareAccepts on the wireYou get
z.number() / z.int()"42"42
z.boolean()"true" / "false"true / false
z.bigint()"9007199254740993"9007199254740993n
z.date()an ISO 8601 stringDate

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:

src/routes/users/create-user.ts
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),
    }));
src/routes/users/index.ts
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:

src/routes/reports.ts
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:

src/routes/schemas.ts
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.User in 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:

src/tags.ts
import { k } from './k';

export const tags = k.tags({
    users: {
        title: 'Users',
        description: 'User management endpoints',
    },
    health: 'Health',
});
kizuna.config.ts
import { defineConfig } from 'kizunajs';
import { tags } from './src/tags';
import { routes } from './src/routes';

export default defineConfig({
    tags,
    routes,
});
src/routes/users.ts
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:

src/routes/index.ts
import { users } from './users';
import { health } from './health';

export const routes = {
    users,
    health,
};
kizuna.config.ts
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 auth names

On this page