write-endpoints

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Writing OpenAPI Endpoints with Chanfana

用Chanfana编写OpenAPI接口

When to Use

适用场景

Use this skill when:
  • Building OpenAPI endpoints with chanfana for Cloudflare Workers
  • Defining request/response schemas with Zod v4
  • Creating CRUD auto endpoints (Create, Read, Update, Delete, List)
  • Integrating with Cloudflare D1 databases
  • Implementing error handling with exception classes
当你有以下需求时可使用本指南:
  • 为Cloudflare Workers使用chanfana构建OpenAPI接口
  • 使用Zod v4定义请求/响应模式
  • 生成CRUD自动接口(创建、查询、更新、删除、列表)
  • 集成Cloudflare D1数据库
  • 使用异常类实现错误处理

Part 1: Fundamentals

第一部分:基础

Quick Start with Hono

Hono快速上手

typescript
import { Hono, type Context } from 'hono';
import { fromHono, OpenAPIRoute, contentJson } from 'chanfana';
import { z } from 'zod';

export type Env = {
    DB: D1Database;
};
export type AppContext = Context<{ Bindings: Env }>;

class HelloEndpoint extends OpenAPIRoute {
    schema = {
        responses: {
            "200": {
                description: 'Successful response',
                ...contentJson(z.object({ message: z.string() })),
            },
        },
    };

    async handle(c: AppContext) {
        return { message: 'Hello, Chanfana!' };
    }
}

const app = new Hono<{ Bindings: Env }>();
const openapi = fromHono(app);
openapi.get('/hello', HelloEndpoint);

export default app;
typescript
import { Hono, type Context } from 'hono';
import { fromHono, OpenAPIRoute, contentJson } from 'chanfana';
import { z } from 'zod';

export type Env = {
    DB: D1Database;
};
export type AppContext = Context<{ Bindings: Env }>;

class HelloEndpoint extends OpenAPIRoute {
    schema = {
        responses: {
            "200": {
                description: 'Successful response',
                ...contentJson(z.object({ message: z.string() })),
            },
        },
    };

    async handle(c: AppContext) {
        return { message: 'Hello, Chanfana!' };
    }
}

const app = new Hono<{ Bindings: Env }>();
const openapi = fromHono(app);
openapi.get('/hello', HelloEndpoint);

export default app;

Quick Start with itty-router

itty-router快速上手

typescript
import { Router } from 'itty-router';
import { fromIttyRouter, OpenAPIRoute, contentJson } from 'chanfana';
import { z } from 'zod';

class HelloEndpoint extends OpenAPIRoute {
    schema = {
        responses: {
            "200": {
                description: 'Successful response',
                ...contentJson(z.object({ message: z.string() })),
            },
        },
    };

    async handle(request: Request, env, ctx) {
        return { message: 'Hello, Chanfana!' };
    }
}

const router = Router();
const openapi = fromIttyRouter(router);
openapi.get('/hello', HelloEndpoint);
router.all('*', () => new Response("Not Found.", { status: 404 }));

export const fetch = router.handle;
typescript
import { Router } from 'itty-router';
import { fromIttyRouter, OpenAPIRoute, contentJson } from 'chanfana';
import { z } from 'zod';

class HelloEndpoint extends OpenAPIRoute {
    schema = {
        responses: {
            "200": {
                description: 'Successful response',
                ...contentJson(z.object({ message: z.string() })),
            },
        },
    };

    async handle(request: Request, env, ctx) {
        return { message: 'Hello, Chanfana!' };
    }
}

const router = Router();
const openapi = fromIttyRouter(router);
openapi.get('/hello', HelloEndpoint);
router.all('*', () => new Response("Not Found.", { status: 404 }));

export const fetch = router.handle;

Schema Definition

模式定义

Define request validation for body, query, params, and headers:
typescript
import { OpenAPIRoute, contentJson } from 'chanfana';
import { z } from 'zod';

class CreateUserEndpoint extends OpenAPIRoute {
    schema = {
        request: {
            body: contentJson(z.object({
                username: z.string().min(3).max(20),
                password: z.string().min(8),
                email: z.email(),
                fullName: z.string().optional(),
            })),
            query: z.object({
                notify: z.boolean().optional().default(true),
            }),
            params: z.object({
                orgId: z.uuid(),
            }),
            headers: z.object({
                'X-API-Key': z.string(),
            }),
        },
        responses: {
            "200": {
                description: 'User created successfully',
                ...contentJson(z.object({
                    id: z.uuid(),
                    username: z.string(),
                    email: z.email(),
                })),
            },
            "400": {
                description: 'Validation error',
                ...contentJson(z.object({
                    success: z.literal(false),
                    errors: z.array(z.object({
                        code: z.number(),
                        message: z.string(),
                    })),
                })),
            },
        },
    };

    async handle(c) {
        const data = await this.getValidatedData<typeof this.schema>();
        // data.body, data.query, data.params, data.headers are all typed
        return { id: crypto.randomUUID(), username: data.body.username, email: data.body.email };
    }
}
为请求体、查询参数、路径参数和请求头定义请求校验规则:
typescript
import { OpenAPIRoute, contentJson } from 'chanfana';
import { z } from 'zod';

class CreateUserEndpoint extends OpenAPIRoute {
    schema = {
        request: {
            body: contentJson(z.object({
                username: z.string().min(3).max(20),
                password: z.string().min(8),
                email: z.email(),
                fullName: z.string().optional(),
            })),
            query: z.object({
                notify: z.boolean().optional().default(true),
            }),
            params: z.object({
                orgId: z.uuid(),
            }),
            headers: z.object({
                'X-API-Key': z.string(),
            }),
        },
        responses: {
            "200": {
                description: 'User created successfully',
                ...contentJson(z.object({
                    id: z.uuid(),
                    username: z.string(),
                    email: z.email(),
                })),
            },
            "400": {
                description: 'Validation error',
                ...contentJson(z.object({
                    success: z.literal(false),
                    errors: z.array(z.object({
                        code: z.number(),
                        message: z.string(),
                    })),
                })),
            },
        },
    };

    async handle(c) {
        const data = await this.getValidatedData<typeof this.schema>();
        // data.body, data.query, data.params, data.headers are all typed
        return { id: crypto.randomUUID(), username: data.body.username, email: data.body.email };
    }
}

Zod v4 Syntax (CRITICAL)

Zod v4语法(重要)

Chanfana v3 uses Zod v4. Use the correct syntax:
typescript
// WRONG - Zod v3 syntax (deprecated)
z.string().email()
z.string().uuid()
z.string().datetime()
z.string().date()
z.string().url()
z.string().ip({ version: "v4" })
z.object({}).strict()
z.nativeEnum(MyEnum)

// CORRECT - Zod v4 syntax
z.email()
z.uuid()
z.iso.datetime()
z.iso.date()
z.url()
z.ipv4()
z.strictObject({})
z.enum(['option1', 'option2'])
Chanfana v3使用Zod v4,请使用正确语法:
typescript
// WRONG - Zod v3 syntax (deprecated)
z.string().email()
z.string().uuid()
z.string().datetime()
z.string().date()
z.string().url()
z.string().ip({ version: "v4" })
z.object({}).strict()
z.nativeEnum(MyEnum)

// CORRECT - Zod v4 syntax
z.email()
z.uuid()
z.iso.datetime()
z.iso.date()
z.url()
z.ipv4()
z.strictObject({})
z.enum(['option1', 'option2'])

Common Zod Types for APIs

API常用的Zod类型

Use native Zod schemas for all parameter types:
typescript
import { z } from 'zod';

// String with constraints
const nameSchema = z.string()
    .min(3)
    .max(50)
    .describe("User's name")
    .openapi({ example: 'John Doe' });

// Number with range
const priceSchema = z.number()
    .min(0)
    .describe('Product price')
    .openapi({ example: 99.99 });

// Integer
const ageSchema = z.number()
    .int()
    .min(0)
    .max(120)
    .describe("User's age");

// Boolean with default
const isActiveSchema = z.boolean()
    .default(true)
    .describe('User active status');

// Date/time (ISO 8601)
const createdAtSchema = z.iso.datetime()
    .describe('Creation timestamp')
    .openapi({ example: '2024-01-20T10:30:00Z' });

// Date only (YYYY-MM-DD)
const birthDateSchema = z.iso.date()
    .describe('Birth date')
    .openapi({ example: '1990-05-15' });

// Email, UUID
const emailSchema = z.email().describe('Email address');
const userIdSchema = z.uuid().describe('User ID');

// Enumeration
const statusSchema = z.enum(['pending', 'processing', 'shipped', 'delivered'])
    .default('pending')
    .describe('Order status');

// Array
const tagsSchema = z.array(z.string()).openapi({
    description: 'Tags',
});

// Object
const addressSchema = z.object({
    street: z.string().describe('Street address'),
    city: z.string().describe('City'),
    zipCode: z.string().describe('Zip code'),
});

// Regex pattern
const phoneSchema = z.string()
    .regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number format')
    .describe('Phone number');

// IP addresses
const ipv4Schema = z.ipv4();
const ipv6Schema = z.ipv6();
const ipSchema = z.union([z.ipv4(), z.ipv6()]);

// Hostname (regex pattern)
const hostnameSchema = z.string().regex(
    /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
);
所有参数类型都使用原生Zod模式定义:
typescript
import { z } from 'zod';

// String with constraints
const nameSchema = z.string()
    .min(3)
    .max(50)
    .describe("User's name")
    .openapi({ example: 'John Doe' });

// Number with range
const priceSchema = z.number()
    .min(0)
    .describe('Product price')
    .openapi({ example: 99.99 });

// Integer
const ageSchema = z.number()
    .int()
    .min(0)
    .max(120)
    .describe("User's age");

// Boolean with default
const isActiveSchema = z.boolean()
    .default(true)
    .describe('User active status');

// Date/time (ISO 8601)
const createdAtSchema = z.iso.datetime()
    .describe('Creation timestamp')
    .openapi({ example: '2024-01-20T10:30:00Z' });

// Date only (YYYY-MM-DD)
const birthDateSchema = z.iso.date()
    .describe('Birth date')
    .openapi({ example: '1990-05-15' });

// Email, UUID
const emailSchema = z.email().describe('Email address');
const userIdSchema = z.uuid().describe('User ID');

// Enumeration
const statusSchema = z.enum(['pending', 'processing', 'shipped', 'delivered'])
    .default('pending')
    .describe('Order status');

// Array
const tagsSchema = z.array(z.string()).openapi({
    description: 'Tags',
});

// Object
const addressSchema = z.object({
    street: z.string().describe('Street address'),
    city: z.string().describe('City'),
    zipCode: z.string().describe('Zip code'),
});

// Regex pattern
const phoneSchema = z.string()
    .regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number format')
    .describe('Phone number');

// IP addresses
const ipv4Schema = z.ipv4();
const ipv6Schema = z.ipv6();
const ipSchema = z.union([z.ipv4(), z.ipv6()]);

// Hostname (regex pattern)
const hostnameSchema = z.string().regex(
    /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
);

Validated Data Access

校验后的数据访问

Always use
await
with
getValidatedData()
:
typescript
class MyEndpoint extends OpenAPIRoute {
    async handle(c) {
        // CORRECT - with await and type annotation
        const data = await this.getValidatedData<typeof this.schema>();

        // Type-safe access
        const username = data.body.username;
        const page = data.query.page;
        const userId = data.params.userId;
        const apiKey = data.headers['X-API-Key'];

        return { success: true };
    }
}
调用
getValidatedData()
时必须搭配
await
使用:
typescript
class MyEndpoint extends OpenAPIRoute {
    async handle(c) {
        // CORRECT - with await and type annotation
        const data = await this.getValidatedData<typeof this.schema>();

        // Type-safe access
        const username = data.body.username;
        const page = data.query.page;
        const userId = data.params.userId;
        const apiKey = data.headers['X-API-Key'];

        return { success: true };
    }
}

Using getUnvalidatedData() for Partial Updates

部分更新场景使用getUnvalidatedData()

In Zod v4, optional fields with
.default()
always have values in validated data. Use
getUnvalidatedData()
to detect what was actually sent:
typescript
class UpdateUser extends OpenAPIRoute {
    schema = {
        request: {
            body: contentJson(z.object({
                name: z.string().optional(),
                status: z.enum(['active', 'inactive']).default('active'),
            })),
        },
    };

    async handle() {
        const validated = await this.getValidatedData<typeof this.schema>();
        // validated.body.status is 'active' even if not sent

        const raw = await this.getUnvalidatedData();
        // raw.body = {} if nothing was sent

        // Check what was actually sent
        const updates: Record<string, any> = {};
        if ('name' in raw.body) updates.name = validated.body.name;
        if ('status' in raw.body) updates.status = validated.body.status;

        return { updated: updates };
    }
}
在Zod v4中,带
.default()
的可选字段在校验后的数据中总会返回默认值,可使用
getUnvalidatedData()
来检测请求实际发送的字段:
typescript
class UpdateUser extends OpenAPIRoute {
    schema = {
        request: {
            body: contentJson(z.object({
                name: z.string().optional(),
                status: z.enum(['active', 'inactive']).default('active'),
            })),
        },
    };

    async handle() {
        const validated = await this.getValidatedData<typeof this.schema>();
        // validated.body.status is 'active' even if not sent

        const raw = await this.getUnvalidatedData();
        // raw.body = {} if nothing was sent

        // Check what was actually sent
        const updates: Record<string, any> = {};
        if ('name' in raw.body) updates.name = validated.body.name;
        if ('status' in raw.body) updates.status = validated.body.status;

        return { updated: updates };
    }
}

Part 2: CRUD Auto Endpoints

第二部分:CRUD自动接口

Meta Object Definition

Meta对象定义

All auto endpoints require a
_meta
property:
typescript
import { z } from 'zod';

// Define the model schema
const UserSchema = z.object({
    id: z.uuid(),
    username: z.string().min(3).max(20),
    email: z.email(),
    role: z.enum(['user', 'admin']),
    createdAt: z.iso.datetime(),
});

// Define the meta object
const userMeta = {
    model: {
        schema: UserSchema,              // Required: Zod schema for the model
        primaryKeys: ['id'],             // Required: Array of primary key fields
        tableName: 'users',              // Required for D1 endpoints
        serializer: (user: any) => {     // Optional: Transform output
            const { passwordHash, ...safe } = user;
            return safe;
        },
        serializerSchema: UserSchema.omit({ passwordHash: true }), // Optional: Schema for serialized output
    },
    pathParameters: ['id'],              // Optional: Explicit path params for nested routes
    tags: ['Users'],                     // Optional: OpenAPI tags for grouping operations
};
所有自动接口都需要定义
_meta
属性:
typescript
import { z } from 'zod';

// Define the model schema
const UserSchema = z.object({
    id: z.uuid(),
    username: z.string().min(3).max(20),
    email: z.email(),
    role: z.enum(['user', 'admin']),
    createdAt: z.iso.datetime(),
});

// Define the meta object
const userMeta = {
    model: {
        schema: UserSchema,              // Required: Zod schema for the model
        primaryKeys: ['id'],             // Required: Array of primary key fields
        tableName: 'users',              // Required for D1 endpoints
        serializer: (user: any) => {     // Optional: Transform output
            const { passwordHash, ...safe } = user;
            return safe;
        },
        serializerSchema: UserSchema.omit({ passwordHash: true }), // Optional: Schema for serialized output
    },
    pathParameters: ['id'],              // Optional: Explicit path params for nested routes
    tags: ['Users'],                     // Optional: OpenAPI tags for grouping operations
};

CreateEndpoint

CreateEndpoint(创建接口)

typescript
import { CreateEndpoint, type O } from 'chanfana';

class CreateUser extends CreateEndpoint {
    _meta = userMeta;

    // Optional: Pre-processing hook
    async before(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        return {
            ...data,
            id: crypto.randomUUID(),
            createdAt: new Date().toISOString(),
        };
    }

    // Required: Create logic
    async create(data: O<typeof this._meta>) {
        await db.users.insert(data);
        return data;
    }

    // Optional: Post-processing hook
    async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        await sendWelcomeEmail(data.email);
        return data;
    }
}

// Register route
openapi.post('/users', CreateUser);
typescript
import { CreateEndpoint, type O } from 'chanfana';

class CreateUser extends CreateEndpoint {
    _meta = userMeta;

    // Optional: Pre-processing hook
    async before(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        return {
            ...data,
            id: crypto.randomUUID(),
            createdAt: new Date().toISOString(),
        };
    }

    // Required: Create logic
    async create(data: O<typeof this._meta>) {
        await db.users.insert(data);
        return data;
    }

    // Optional: Post-processing hook
    async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        await sendWelcomeEmail(data.email);
        return data;
    }
}

// Register route
openapi.post('/users', CreateUser);

ReadEndpoint

ReadEndpoint(详情查询接口)

typescript
import { ReadEndpoint, type Filters, type O } from 'chanfana';

class GetUser extends ReadEndpoint {
    _meta = userMeta;

    async before(filters: Filters): Promise<Filters> {
        // Pre-fetch validation
        return filters;
    }

    async fetch(filters: Filters): Promise<O<typeof this._meta> | null> {
        const userId = filters.filters[0].value;
        return await db.users.findById(userId);
    }

    async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        // Post-fetch processing
        return data;
    }
}

// Register route with path parameter
openapi.get('/users/:id', GetUser);
typescript
import { ReadEndpoint, type Filters, type O } from 'chanfana';

class GetUser extends ReadEndpoint {
    _meta = userMeta;

    async before(filters: Filters): Promise<Filters> {
        // Pre-fetch validation
        return filters;
    }

    async fetch(filters: Filters): Promise<O<typeof this._meta> | null> {
        const userId = filters.filters[0].value;
        return await db.users.findById(userId);
    }

    async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        // Post-fetch processing
        return data;
    }
}

// Register route with path parameter
openapi.get('/users/:id', GetUser);

ListEndpoint

ListEndpoint(列表查询接口)

typescript
import { ListEndpoint, type ListFilters, type ListResult, type O } from 'chanfana';

class ListUsers extends ListEndpoint {
    _meta = userMeta;

    // Configure filtering, search, and sorting
    filterFields = ['role', 'status'];           // Exact match filtering
    searchFields = ['username', 'email'];         // Full-text search (LIKE)
    orderByFields = ['createdAt', 'username'];    // Available sort fields
    defaultOrderBy = 'createdAt';                 // Default sort field

    async before(filters: ListFilters): Promise<ListFilters> {
        // Add tenant filter, etc.
        return filters;
    }

    async list(filters: ListFilters): Promise<ListResult<O<typeof this._meta>>> {
        const users = await db.users.findMany(filters);
        return { result: users };
    }

    async after(data: ListResult<O<typeof this._meta>>): Promise<ListResult<O<typeof this._meta>>> {
        return data;
    }
}

// Register route
openapi.get('/users', ListUsers);

// API calls:
// GET /users?page=2&per_page=10
// GET /users?role=admin
// GET /users?search=john
// GET /users?order_by=createdAt&order_by_direction=desc
typescript
import { ListEndpoint, type ListFilters, type ListResult, type O } from 'chanfana';

class ListUsers extends ListEndpoint {
    _meta = userMeta;

    // Configure filtering, search, and sorting
    filterFields = ['role', 'status'];           // Exact match filtering
    searchFields = ['username', 'email'];         // Full-text search (LIKE)
    orderByFields = ['createdAt', 'username'];    // Available sort fields
    defaultOrderBy = 'createdAt';                 // Default sort field

    async before(filters: ListFilters): Promise<ListFilters> {
        // Add tenant filter, etc.
        return filters;
    }

    async list(filters: ListFilters): Promise<ListResult<O<typeof this._meta>>> {
        const users = await db.users.findMany(filters);
        return { result: users };
    }

    async after(data: ListResult<O<typeof this._meta>>): Promise<ListResult<O<typeof this._meta>>> {
        return data;
    }
}

// Register route
openapi.get('/users', ListUsers);

// API calls:
// GET /users?page=2&per_page=10
// GET /users?role=admin
// GET /users?search=john
// GET /users?order_by=createdAt&order_by_direction=desc

UpdateEndpoint

UpdateEndpoint(更新接口)

typescript
import { UpdateEndpoint, type UpdateFilters, type O } from 'chanfana';

class UpdateUser extends UpdateEndpoint {
    _meta = userMeta;

    async before(oldObj: O<typeof this._meta>, filters: UpdateFilters): Promise<UpdateFilters> {
        filters.updatedData = {
            ...filters.updatedData,
            updatedAt: new Date().toISOString(),
        };
        return filters;
    }

    async getObject(filters: UpdateFilters): Promise<O<typeof this._meta> | null> {
        const userId = filters.filters[0].value;
        return await db.users.findById(userId);
    }

    async update(oldObj: O<typeof this._meta>, filters: UpdateFilters): Promise<O<typeof this._meta>> {
        const userId = filters.filters[0].value;
        return await db.users.update(userId, { ...oldObj, ...filters.updatedData });
    }

    async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        await cache.invalidate(`user:${data.id}`);
        return data;
    }
}

// Register route
openapi.put('/users/:id', UpdateUser);
typescript
import { UpdateEndpoint, type UpdateFilters, type O } from 'chanfana';

class UpdateUser extends UpdateEndpoint {
    _meta = userMeta;

    async before(oldObj: O<typeof this._meta>, filters: UpdateFilters): Promise<UpdateFilters> {
        filters.updatedData = {
            ...filters.updatedData,
            updatedAt: new Date().toISOString(),
        };
        return filters;
    }

    async getObject(filters: UpdateFilters): Promise<O<typeof this._meta> | null> {
        const userId = filters.filters[0].value;
        return await db.users.findById(userId);
    }

    async update(oldObj: O<typeof this._meta>, filters: UpdateFilters): Promise<O<typeof this._meta>> {
        const userId = filters.filters[0].value;
        return await db.users.update(userId, { ...oldObj, ...filters.updatedData });
    }

    async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        await cache.invalidate(`user:${data.id}`);
        return data;
    }
}

// Register route
openapi.put('/users/:id', UpdateUser);

DeleteEndpoint

DeleteEndpoint(删除接口)

typescript
import { DeleteEndpoint, type Filters, type O } from 'chanfana';

class DeleteUser extends DeleteEndpoint {
    _meta = userMeta;

    async before(oldObj: O<typeof this._meta>, filters: Filters): Promise<Filters> {
        await checkDeletionPermissions(oldObj.id);
        return filters;
    }

    async getObject(filters: Filters): Promise<O<typeof this._meta> | null> {
        const userId = filters.filters[0].value;
        return await db.users.findById(userId);
    }

    async delete(oldObj: O<typeof this._meta>, filters: Filters): Promise<O<typeof this._meta> | null> {
        const userId = filters.filters[0].value;
        await db.users.delete(userId);
        return oldObj;
    }

    async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        await auditLog.record('user_deleted', data.id);
        return data;
    }
}

// Register route
openapi.delete('/users/:id', DeleteUser);
typescript
import { DeleteEndpoint, type Filters, type O } from 'chanfana';

class DeleteUser extends DeleteEndpoint {
    _meta = userMeta;

    async before(oldObj: O<typeof this._meta>, filters: Filters): Promise<Filters> {
        await checkDeletionPermissions(oldObj.id);
        return filters;
    }

    async getObject(filters: Filters): Promise<O<typeof this._meta> | null> {
        const userId = filters.filters[0].value;
        return await db.users.findById(userId);
    }

    async delete(oldObj: O<typeof this._meta>, filters: Filters): Promise<O<typeof this._meta> | null> {
        const userId = filters.filters[0].value;
        await db.users.delete(userId);
        return oldObj;
    }

    async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
        await auditLog.record('user_deleted', data.id);
        return data;
    }
}

// Register route
openapi.delete('/users/:id', DeleteUser);

Nested Routes with pathParameters

带pathParameters的嵌套路由

For composite primary keys in nested routes:
typescript
const PostSchema = z.object({
    userId: z.uuid(),
    id: z.uuid(),
    title: z.string(),
    content: z.string(),
});

const postMeta = {
    model: {
        schema: PostSchema,
        primaryKeys: ['userId', 'id'],  // Composite primary key
        tableName: 'posts',
    },
    pathParameters: ['userId', 'id'],   // Explicit path params
};

class GetPost extends ReadEndpoint {
    _meta = postMeta;

    async fetch(filters: Filters) {
        const userId = filters.filters.find(f => f.field === 'userId')?.value;
        const postId = filters.filters.find(f => f.field === 'id')?.value;
        return await db.posts.findOne({ userId, id: postId });
    }
}

// Nested route: /users/:userId/posts/:id
const postsRouter = new Hono();
const postsOpenapi = fromHono(postsRouter);
postsOpenapi.get('/:id', GetPost);

// Mount nested router
openapi.route('/:userId/posts', postsOpenapi);
适用于嵌套路由中的复合主键场景:
typescript
const PostSchema = z.object({
    userId: z.uuid(),
    id: z.uuid(),
    title: z.string(),
    content: z.string(),
});

const postMeta = {
    model: {
        schema: PostSchema,
        primaryKeys: ['userId', 'id'],  // Composite primary key
        tableName: 'posts',
    },
    pathParameters: ['userId', 'id'],   // Explicit path params
};

class GetPost extends ReadEndpoint {
    _meta = postMeta;

    async fetch(filters: Filters) {
        const userId = filters.filters.find(f => f.field === 'userId')?.value;
        const postId = filters.filters.find(f => f.field === 'id')?.value;
        return await db.posts.findOne({ userId, id: postId });
    }
}

// Nested route: /users/:userId/posts/:id
const postsRouter = new Hono();
const postsOpenapi = fromHono(postsRouter);
postsOpenapi.get('/:id', GetPost);

// Mount nested router
openapi.route('/:userId/posts', postsOpenapi);

Part 3: D1 Database Integration

第三部分:D1数据库集成

D1 Endpoint Classes

D1接口类

D1 endpoints extend CRUD endpoints with built-in database operations:
typescript
import {
    D1CreateEndpoint,
    D1ReadEndpoint,
    D1UpdateEndpoint,
    D1DeleteEndpoint,
    D1ListEndpoint,
    InputValidationException,
} from 'chanfana';

// wrangler.toml:
// [[d1_databases]]
// binding = "DB"
// database_name = "my-database"
// database_id = "your-database-id"

class CreateUser extends D1CreateEndpoint {
    _meta = userMeta;
    dbName = 'DB';  // Must match wrangler.toml binding name

    // Optional: Handle UNIQUE constraint violations
    constraintsMessages = {
        'users_email_unique': new InputValidationException(
            'Email already registered',
            ['body', 'email']
        ),
        'users_username_unique': new InputValidationException(
            'Username already taken',
            ['body', 'username']
        ),
    };

    // Optional: Enable logging
    logger = console;
}

class GetUser extends D1ReadEndpoint {
    _meta = userMeta;
    dbName = 'DB';
}

class UpdateUser extends D1UpdateEndpoint {
    _meta = userMeta;
    dbName = 'DB';
}

class DeleteUser extends D1DeleteEndpoint {
    _meta = userMeta;
    dbName = 'DB';
}

class ListUsers extends D1ListEndpoint {
    _meta = userMeta;
    dbName = 'DB';
    filterFields = ['role', 'status'];
    searchFields = ['username', 'email'];
    orderByFields = ['createdAt', 'username'];
    defaultOrderBy = 'createdAt';
}

// Register routes
const app = new Hono<{ Bindings: { DB: D1Database } }>();
const openapi = fromHono(app);

openapi.post('/users', CreateUser);
openapi.get('/users', ListUsers);
openapi.get('/users/:id', GetUser);
openapi.put('/users/:id', UpdateUser);
openapi.delete('/users/:id', DeleteUser);
D1接口在CRUD接口基础上扩展,内置了数据库操作能力:
typescript
import {
    D1CreateEndpoint,
    D1ReadEndpoint,
    D1UpdateEndpoint,
    D1DeleteEndpoint,
    D1ListEndpoint,
    InputValidationException,
} from 'chanfana';

// wrangler.toml:
// [[d1_databases]]
// binding = "DB"
// database_name = "my-database"
// database_id = "your-database-id"

class CreateUser extends D1CreateEndpoint {
    _meta = userMeta;
    dbName = 'DB';  // Must match wrangler.toml binding name

    // Optional: Handle UNIQUE constraint violations
    constraintsMessages = {
        'users_email_unique': new InputValidationException(
            'Email already registered',
            ['body', 'email']
        ),
        'users_username_unique': new InputValidationException(
            'Username already taken',
            ['body', 'username']
        ),
    };

    // Optional: Enable logging
    logger = console;
}

class GetUser extends D1ReadEndpoint {
    _meta = userMeta;
    dbName = 'DB';
}

class UpdateUser extends D1UpdateEndpoint {
    _meta = userMeta;
    dbName = 'DB';
}

class DeleteUser extends D1DeleteEndpoint {
    _meta = userMeta;
    dbName = 'DB';
}

class ListUsers extends D1ListEndpoint {
    _meta = userMeta;
    dbName = 'DB';
    filterFields = ['role', 'status'];
    searchFields = ['username', 'email'];
    orderByFields = ['createdAt', 'username'];
    defaultOrderBy = 'createdAt';
}

// Register routes
const app = new Hono<{ Bindings: { DB: D1Database } }>();
const openapi = fromHono(app);

openapi.post('/users', CreateUser);
openapi.get('/users', ListUsers);
openapi.get('/users/:id', GetUser);
openapi.put('/users/:id', UpdateUser);
openapi.delete('/users/:id', DeleteUser);

SQL Injection Prevention

SQL注入防护

D1 endpoints include built-in security utilities:
typescript
import {
    validateSqlIdentifier,
    validateTableName,
    validateColumnName,
    buildSafeFilters,
} from 'chanfana/endpoints/d1/base';

// Validate identifiers
const table = validateTableName('users');        // OK
const column = validateColumnName('email');      // OK
validateTableName('DROP TABLE--');               // Throws ApiException

// Build safe WHERE clauses
const filters = [
    { field: 'status', operator: 'EQ', value: 'active' },
    { field: 'role', operator: 'EQ', value: 'admin' },
];
const validColumns = ['id', 'status', 'role', 'name'];
const { conditions, conditionsParams } = buildSafeFilters(filters, validColumns);
// conditions: ['status = ?1', 'role = ?2']
// conditionsParams: ['active', 'admin']
D1接口内置了安全工具:
typescript
import {
    validateSqlIdentifier,
    validateTableName,
    validateColumnName,
    buildSafeFilters,
} from 'chanfana/endpoints/d1/base';

// Validate identifiers
const table = validateTableName('users');        // OK
const column = validateColumnName('email');      // OK
validateTableName('DROP TABLE--');               // Throws ApiException

// Build safe WHERE clauses
const filters = [
    { field: 'status', operator: 'EQ', value: 'active' },
    { field: 'role', operator: 'EQ', value: 'admin' },
];
const validColumns = ['id', 'status', 'role', 'name'];
const { conditions, conditionsParams } = buildSafeFilters(filters, validColumns);
// conditions: ['status = ?1', 'role = ?2']
// conditionsParams: ['active', 'admin']

Part 4: Error Handling

第四部分:错误处理

Exception Classes

异常类

ExceptionStatusCodeDefault MessageSpecial Properties
ApiException
5007000"Internal Error"Base class
InputValidationException
4007001"Input Validation Error"
path
NotFoundException
4047002"Not Found"-
UnauthorizedException
4017003"Unauthorized"-
ForbiddenException
4037004"Forbidden"-
MethodNotAllowedException
4057005"Method Not Allowed"-
ConflictException
4097006"Conflict"-
UnprocessableEntityException
4227007"Unprocessable Entity"
path
TooManyRequestsException
4297008"Too Many Requests"
retryAfter
InternalServerErrorException
5007009"Internal Server Error"
isVisible: false
BadGatewayException
5027010"Bad Gateway"-
ServiceUnavailableException
5037011"Service Unavailable"
retryAfter
GatewayTimeoutException
5047012"Gateway Timeout"-
异常类状态码错误码默认消息特殊属性
ApiException
5007000"内部错误"基础类
InputValidationException
4007001"输入校验错误"
path
NotFoundException
4047002"资源不存在"-
UnauthorizedException
4017003"未授权"-
ForbiddenException
4037004"禁止访问"-
MethodNotAllowedException
4057005"请求方法不允许"-
ConflictException
4097006"资源冲突"-
UnprocessableEntityException
4227007"请求无法处理"
path
TooManyRequestsException
4297008"请求次数过多"
retryAfter
InternalServerErrorException
5007009"内部服务错误"
isVisible: false
BadGatewayException
5027010"网关错误"-
ServiceUnavailableException
5037011"服务不可用"
retryAfter
GatewayTimeoutException
5047012"网关超时"-

Throwing Exceptions

抛出异常

typescript
import {
    InputValidationException,
    NotFoundException,
    UnauthorizedException,
    ForbiddenException,
    ConflictException,
    TooManyRequestsException,
    MultiException,
} from 'chanfana';

class MyEndpoint extends OpenAPIRoute {
    async handle(c) {
        // Validation error with path
        if (!isValidEmail(email)) {
            throw new InputValidationException('Invalid email format', ['body', 'email']);
        }

        // Not found
        const user = await db.users.findById(id);
        if (!user) {
            throw new NotFoundException(`User ${id} not found`);
        }

        // Authentication required
        if (!c.req.header('Authorization')) {
            throw new UnauthorizedException('Authentication required');
        }

        // Permission denied
        if (!user.hasPermission('admin')) {
            throw new ForbiddenException('Admin access required');
        }

        // Resource conflict
        if (await db.users.existsByEmail(email)) {
            throw new ConflictException('Email already registered');
        }

        // Rate limiting
        if (rateLimitExceeded) {
            throw new TooManyRequestsException('Rate limit exceeded', 60); // retry after 60s
        }

        // Multiple errors
        const errors = [];
        if (field1Invalid) errors.push(new InputValidationException('Field 1 invalid', ['body', 'field1']));
        if (field2Invalid) errors.push(new InputValidationException('Field 2 invalid', ['body', 'field2']));
        if (errors.length > 0) {
            throw new MultiException(errors);
        }

        return { success: true };
    }
}
typescript
import {
    InputValidationException,
    NotFoundException,
    UnauthorizedException,
    ForbiddenException,
    ConflictException,
    TooManyRequestsException,
    MultiException,
} from 'chanfana';

class MyEndpoint extends OpenAPIRoute {
    async handle(c) {
        // Validation error with path
        if (!isValidEmail(email)) {
            throw new InputValidationException('Invalid email format', ['body', 'email']);
        }

        // Not found
        const user = await db.users.findById(id);
        if (!user) {
            throw new NotFoundException(`User ${id} not found`);
        }

        // Authentication required
        if (!c.req.header('Authorization')) {
            throw new UnauthorizedException('Authentication required');
        }

        // Permission denied
        if (!user.hasPermission('admin')) {
            throw new ForbiddenException('Admin access required');
        }

        // Resource conflict
        if (await db.users.existsByEmail(email)) {
            throw new ConflictException('Email already registered');
        }

        // Rate limiting
        if (rateLimitExceeded) {
            throw new TooManyRequestsException('Rate limit exceeded', 60); // retry after 60s
        }

        // Multiple errors
        const errors = [];
        if (field1Invalid) errors.push(new InputValidationException('Field 1 invalid', ['body', 'field1']));
        if (field2Invalid) errors.push(new InputValidationException('Field 2 invalid', ['body', 'field2']));
        if (errors.length > 0) {
            throw new MultiException(errors);
        }

        return { success: true };
    }
}

Documenting Exceptions in Schema

在Schema中定义异常文档

typescript
import {
    OpenAPIRoute,
    contentJson,
    InputValidationException,
    NotFoundException,
    UnauthorizedException,
} from 'chanfana';

class GetUser extends OpenAPIRoute {
    schema = {
        request: {
            params: z.object({ id: z.uuid() }),
        },
        responses: {
            "200": {
                description: 'User found',
                ...contentJson(UserSchema),
            },
            ...InputValidationException.schema(),  // Documents 400 response
            ...UnauthorizedException.schema(),      // Documents 401 response
            ...NotFoundException.schema(),          // Documents 404 response
        },
    };
}
typescript
import {
    OpenAPIRoute,
    contentJson,
    InputValidationException,
    NotFoundException,
    UnauthorizedException,
} from 'chanfana';

class GetUser extends OpenAPIRoute {
    schema = {
        request: {
            params: z.object({ id: z.uuid() }),
        },
        responses: {
            "200": {
                description: 'User found',
                ...contentJson(UserSchema),
            },
            ...InputValidationException.schema(),  // Documents 400 response
            ...UnauthorizedException.schema(),      // Documents 401 response
            ...NotFoundException.schema(),          // Documents 404 response
        },
    };
}

Part 5: Verification

第五部分:校验

Checklist

检查清单

Basic Endpoints:
  • Schema defines
    responses
    (required, even if just 200)
  • Using
    contentJson()
    wrapper for JSON request/response bodies
  • Using
    await this.getValidatedData<typeof this.schema>()
    for type-safe access
  • Using Zod v4 syntax (
    z.email()
    not
    z.string().email()
    )
  • Path parameters in schema match route definition (
    :userId
    ->
    params: z.object({ userId: ... })
    )
  • Exception responses documented using
    ...ExceptionClass.schema()
    spread
CRUD Auto Endpoints:
  • _meta
    property is defined on the endpoint class
  • _meta.model.schema
    is a valid Zod object schema
  • _meta.model.primaryKeys
    is an array of primary key field names
  • _meta.model.tableName
    is set (required for D1 endpoints)
  • Nested routes use
    pathParameters
    in meta for composite primary keys
  • _meta.tags
    is set to group related endpoints under OpenAPI tags
  • ListEndpoint has
    filterFields
    ,
    searchFields
    ,
    orderByFields
    configured as needed
D1 Endpoints:
  • dbName
    matches the binding name in wrangler.toml
  • constraintsMessages
    defined for UNIQUE constraint handling
  • Hono app typed with
    { Bindings: { DB: D1Database } }
基础接口:
  • Schema中定义了
    responses
    (必填,哪怕只有200状态码)
  • JSON请求/响应体使用
    contentJson()
    包裹
  • 使用
    await this.getValidatedData<typeof this.schema>()
    实现类型安全访问
  • 使用Zod v4语法(用
    z.email()
    而非
    z.string().email()
  • Schema中的路径参数与路由定义匹配(
    :userId
    对应
    params: z.object({ userId: ... })
  • 异常响应使用
    ...ExceptionClass.schema()
    展开语法来定义文档
CRUD自动接口:
  • 接口类上定义了
    _meta
    属性
  • _meta.model.schema
    是合法的Zod对象模式
  • _meta.model.primaryKeys
    是主键字段名组成的数组
  • 已设置
    _meta.model.tableName
    (D1接口必填)
  • 嵌套路由在meta中使用
    pathParameters
    定义复合主键
  • 已设置
    _meta.tags
    用于在OpenAPI文档中分组相关接口
  • ListEndpoint按需配置了
    filterFields
    searchFields
    orderByFields
D1接口:
  • dbName
    与wrangler.toml中的绑定名一致
  • 已定义
    constraintsMessages
    处理UNIQUE约束冲突
  • Hono应用已通过
    { Bindings: { DB: D1Database } }
    完成类型定义

Common Mistakes

常见错误

1. Missing contentJson wrapper
typescript
// WRONG - response body not properly documented
responses: {
    "200": {
        description: 'Success',
        content: { 'application/json': { schema: z.object({...}) } }
    }
}

// CORRECT - use contentJson helper
responses: {
    "200": {
        description: 'Success',
        ...contentJson(z.object({...}))
    }
}
2. Not awaiting getValidatedData
typescript
// WRONG - missing await
const data = this.getValidatedData<typeof this.schema>();

// CORRECT
const data = await this.getValidatedData<typeof this.schema>();
3. Using Zod v3 syntax
typescript
// WRONG - Zod v3 syntax
z.string().email()
z.string().datetime()
z.object({}).strict()

// CORRECT - Zod v4 syntax
z.email()
z.iso.datetime()
z.strictObject({})
4. Forgetting response schema
typescript
// WRONG - no responses defined
schema = { request: { ... } }

// CORRECT - always define responses
schema = {
    request: { ... },
    responses: { "200": { description: 'Success', ...contentJson(...) } }
}
5. Primary key mismatch in nested routes
typescript
// WRONG - composite key not reflected in pathParameters
const postMeta = {
    model: {
        primaryKeys: ['userId', 'postId'],
    }
};
// Route: /users/:userId/posts/:postId but no pathParameters

// CORRECT - explicitly define pathParameters
const postMeta = {
    model: {
        primaryKeys: ['userId', 'postId'],
    },
    pathParameters: ['userId', 'postId'],
};
6. Optional fields with defaults in Zod v4
typescript
// GOTCHA - Zod v4 always provides default values
const data = await this.getValidatedData();
// data.body.status is 'active' even if not sent in request

// SOLUTION - use getUnvalidatedData() to check what was actually sent
const raw = await this.getUnvalidatedData();
if ('status' in raw.body) {
    // status was actually sent
}
7. D1 binding name mismatch
typescript
// WRONG - binding name doesn't match wrangler.toml
class MyEndpoint extends D1CreateEndpoint {
    dbName = 'DATABASE'; // wrangler.toml has binding = "DB"
}

// CORRECT
class MyEndpoint extends D1CreateEndpoint {
    dbName = 'DB'; // matches wrangler.toml [[d1_databases]] binding
}
8. Missing _meta in auto endpoints
typescript
// WRONG - no _meta defined
class CreateUser extends CreateEndpoint {
    async create(data) { ... }
}

// CORRECT - _meta is required
class CreateUser extends CreateEndpoint {
    _meta = {
        model: {
            schema: UserSchema,
            primaryKeys: ['id'],
            tableName: 'users',
        },
    };
    async create(data) { ... }
}
9. Using nativeEnum in Zod v4
typescript
// WRONG - Zod v3 syntax
enum Status { Active = 'active', Inactive = 'inactive' }
z.nativeEnum(Status)

// CORRECT - Zod v4 syntax
z.enum(['active', 'inactive'])
1. 缺失contentJson包裹
typescript
// WRONG - response body not properly documented
responses: {
    "200": {
        description: 'Success',
        content: { 'application/json': { schema: z.object({...}) } }
    }
}

// CORRECT - use contentJson helper
responses: {
    "200": {
        description: 'Success',
        ...contentJson(z.object({...}))
    }
}
2. 调用getValidatedData没有加await
typescript
// WRONG - missing await
const data = this.getValidatedData<typeof this.schema>();

// CORRECT
const data = await this.getValidatedData<typeof this.schema>();
3. 使用Zod v3语法
typescript
// WRONG - Zod v3 syntax
z.string().email()
z.string().datetime()
z.object({}).strict()

// CORRECT - Zod v4 syntax
z.email()
z.iso.datetime()
z.strictObject({})
4. 忘记定义响应Schema
typescript
// WRONG - no responses defined
schema = { request: { ... } }

// CORRECT - always define responses
schema = {
    request: { ... },
    responses: { "200": { description: 'Success', ...contentJson(...) } }
}
5. 嵌套路由中主键不匹配
typescript
// WRONG - composite key not reflected in pathParameters
const postMeta = {
    model: {
        primaryKeys: ['userId', 'postId'],
    }
};
// Route: /users/:userId/posts/:postId but no pathParameters

// CORRECT - explicitly define pathParameters
const postMeta = {
    model: {
        primaryKeys: ['userId', 'postId'],
    },
    pathParameters: ['userId', 'postId'],
};
6. Zod v4中带默认值的可选字段问题
typescript
// GOTCHA - Zod v4 always provides default values
const data = await this.getValidatedData();
// data.body.status is 'active' even if not sent in request

// SOLUTION - use getUnvalidatedData() to check what was actually sent
const raw = await this.getUnvalidatedData();
if ('status' in raw.body) {
    // status was actually sent
}
7. D1绑定名不匹配
typescript
// WRONG - binding name doesn't match wrangler.toml
class MyEndpoint extends D1CreateEndpoint {
    dbName = 'DATABASE'; // wrangler.toml has binding = "DB"
}

// CORRECT
class MyEndpoint extends D1CreateEndpoint {
    dbName = 'DB'; // matches wrangler.toml [[d1_databases]] binding
}
8. 自动接口缺失_meta定义
typescript
// WRONG - no _meta defined
class CreateUser extends CreateEndpoint {
    async create(data) { ... }
}

// CORRECT - _meta is required
class CreateUser extends CreateEndpoint {
    _meta = {
        model: {
            schema: UserSchema,
            primaryKeys: ['id'],
            tableName: 'users',
        },
    };
    async create(data) { ... }
}
9. Zod v4中使用nativeEnum
typescript
// WRONG - Zod v3 syntax
enum Status { Active = 'active', Inactive = 'inactive' }
z.nativeEnum(Status)

// CORRECT - Zod v4 syntax
z.enum(['active', 'inactive'])