mcpField
W
write-endpoints

write-endpoints

github.com/cloudflare开发773111 次安装1 天前更新
代码仓库

关于 write-endpoints

write-endpoints 是用于在 Cloudflare Workers 上借助 Chanfana 构建 OpenAPI 端点的编码技能,面向 API 开发者。内容涵盖 Zod v4 模式、CRUD 端点、D1 集成和基于异常类的错误处理。

主要功能

  1. 01定义并验证 OpenAPI 请求模式
  2. 02构建 CRUD 自动端点
  3. 03将端点接入 Cloudflare D1
  4. 04采用 Zod v4 语法与验证
  5. 05使用异常类处理错误

适用场景

  1. 01使用 Hono 创建类型安全的 API 端点
  2. 02为 D1 模型生成 CRUD 路由
  3. 03编写请求和响应模式文档
  4. 04依据常见问题检查端点代码

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/cloudflare/chanfana --skill write-endpoints

安装后在 Claude Code 中输入/write-endpoints确认已加载。

详细介绍

使用 Chanfana 编写 OpenAPI 端点

适用场景

在以下情况下使用此技能:

  • 使用 chanfana 为 Cloudflare Workers 构建 OpenAPI 端点
  • 使用 Zod v4 定义请求/响应模式
  • 创建 CRUD 自动端点(创建、读取、更新、删除、列表)
  • 与 Cloudflare D1 数据库集成
  • 使用异常类实现错误处理

第 1 部分:基础知识

使用 Hono 快速入门
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;
使用 itty-router 快速入门
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;
模式定义

为请求正文、查询、路径参数和标头定义请求验证:

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 语法(重要)

Chanfana v3 使用 Zod v4。请使用正确的语法:

// 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'])
API 常用 Zod 类型

对所有参数类型使用原生 Zod 模式:

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])$/
);
访问已验证数据

始终在 getValidatedData() 前使用 await:

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 };
    }
}
对部分更新使用 getUnvalidatedData()

在 Zod v4 中,带有 .default() 的可选字段在已验证数据中始终有值。使用 getUnvalidatedData() 检测实际发送了哪些内容:

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 };
    }
}

第 2 部分:CRUD 自动端点

Meta 对象定义

所有自动端点都需要 _meta 属性:

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
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
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
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
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
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);
使用 pathParameters 的嵌套路由

用于嵌套路由中的复合主键:

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);

第 3 部分:D1 数据库集成

D1 端点类

D1 端点扩展了 CRUD 端点,并内置数据库操作:

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 注入

D1 端点包含内置安全实用工具:

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']

第 4 部分:错误处理

异常类
异常状态代码默认消息特殊属性
ApiException5007000“内部错误”基类
InputValidationException4007001“输入验证错误”path
NotFoundException4047002“未找到”-
UnauthorizedException4017003“未授权”-
ForbiddenException4037004“禁止访问”-
MethodNotAllowedException4057005“不允许使用该方法”-
ConflictException4097006“冲突”-
UnprocessableEntityException4227007“无法处理的实体”path
TooManyRequestsException4297008“请求过多”retryAfter
InternalServerErrorException5007009“内部服务器错误”isVisible: false
BadGatewayException5027010“错误网关”-
ServiceUnavailableException5037011“服务不可用”retryAfter
GatewayTimeoutException5047012“网关超时”-
抛出异常
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 };
    }
}
在模式中记录异常
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
        },
    };
}

第 5 部分:验证

检查清单

基本端点:

  • 架构定义了 responses(必需,即使只有 200)
  • 对 JSON 请求/响应正文使用 contentJson() 包装器
  • 使用 await this.getValidatedData<typeof this.schema>() 进行类型安全的访问
  • 使用 Zod v4 语法(z.email(),而不是 z.string().email())
  • 架构中的路径参数与路由定义匹配(: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 } } 进行类型标注
常见错误

1. 缺少 contentJson 包装器

// 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

// WRONG - missing await
const data = this.getValidatedData<typeof this.schema>();

// CORRECT
const data = await this.getValidatedData<typeof this.schema>();

3. 使用 Zod v3 语法

// 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. 忘记响应架构

// WRONG - no responses defined
schema = { request: { ... } }

// CORRECT - always define responses
schema = {
    request: { ... },
    responses: { "200": { description: 'Success', ...contentJson(...) } }
}

5. 嵌套路由中的主键不匹配

// 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 中带默认值的可选字段

// 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 绑定名称不匹配

// 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

// 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

// WRONG - Zod v3 syntax
enum Status { Active = 'active', Inactive = 'inactive' }
z.nativeEnum(Status)

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

评论

支持 Markdown