write-endpoints
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWriting 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 with :
awaitgetValidatedData()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()awaittypescript
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 always have values in validated data. Use to detect what was actually sent:
.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 };
}
}在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 property:
_metatypescript
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
};所有自动接口都需要定义属性:
_metatypescript
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=desctypescript
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=descUpdateEndpoint
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
异常类
| Exception | Status | Code | Default Message | Special Properties |
|---|---|---|---|---|
| 500 | 7000 | "Internal Error" | Base class |
| 400 | 7001 | "Input Validation Error" | |
| 404 | 7002 | "Not Found" | - |
| 401 | 7003 | "Unauthorized" | - |
| 403 | 7004 | "Forbidden" | - |
| 405 | 7005 | "Method Not Allowed" | - |
| 409 | 7006 | "Conflict" | - |
| 422 | 7007 | "Unprocessable Entity" | |
| 429 | 7008 | "Too Many Requests" | |
| 500 | 7009 | "Internal Server Error" | |
| 502 | 7010 | "Bad Gateway" | - |
| 503 | 7011 | "Service Unavailable" | |
| 504 | 7012 | "Gateway Timeout" | - |
| 异常类 | 状态码 | 错误码 | 默认消息 | 特殊属性 |
|---|---|---|---|---|
| 500 | 7000 | "内部错误" | 基础类 |
| 400 | 7001 | "输入校验错误" | |
| 404 | 7002 | "资源不存在" | - |
| 401 | 7003 | "未授权" | - |
| 403 | 7004 | "禁止访问" | - |
| 405 | 7005 | "请求方法不允许" | - |
| 409 | 7006 | "资源冲突" | - |
| 422 | 7007 | "请求无法处理" | |
| 429 | 7008 | "请求次数过多" | |
| 500 | 7009 | "内部服务错误" | |
| 502 | 7010 | "网关错误" | - |
| 503 | 7011 | "服务不可用" | |
| 504 | 7012 | "网关超时" | - |
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 (required, even if just 200)
responses - Using wrapper for JSON request/response bodies
contentJson() - Using for type-safe access
await this.getValidatedData<typeof this.schema>() - Using Zod v4 syntax (not
z.email())z.string().email() - Path parameters in schema match route definition (->
:userId)params: z.object({ userId: ... }) - Exception responses documented using spread
...ExceptionClass.schema()
CRUD Auto Endpoints:
- property is defined on the endpoint class
_meta - is a valid Zod object schema
_meta.model.schema - is an array of primary key field names
_meta.model.primaryKeys - is set (required for D1 endpoints)
_meta.model.tableName - Nested routes use in meta for composite primary keys
pathParameters - is set to group related endpoints under OpenAPI tags
_meta.tags - ListEndpoint has ,
filterFields,searchFieldsconfigured as neededorderByFields
D1 Endpoints:
- matches the binding name in wrangler.toml
dbName - defined for UNIQUE constraint handling
constraintsMessages - Hono app typed with
{ Bindings: { DB: D1Database } }
基础接口:
- Schema中定义了(必填,哪怕只有200状态码)
responses - 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 - 是合法的Zod对象模式
_meta.model.schema - 是主键字段名组成的数组
_meta.model.primaryKeys - 已设置(D1接口必填)
_meta.model.tableName - 嵌套路由在meta中使用定义复合主键
pathParameters - 已设置用于在OpenAPI文档中分组相关接口
_meta.tags - ListEndpoint按需配置了、
filterFields、searchFieldsorderByFields
D1接口:
- 与wrangler.toml中的绑定名一致
dbName - 已定义处理UNIQUE约束冲突
constraintsMessages - 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'])