write-endpoints について
write-endpointsは、Cloudflare Workers上でChanfanaを使ってOpenAPIエンドポイントを構築するためのコーディングスキルです。API開発者向けに、Zod v4のスキーマ、CRUDエンドポイント、D1連携、例外クラスによるエラー処理を案内します。
主な機能
- 01OpenAPIリクエストスキーマの定義と検証
- 02CRUD自動エンドポイントの構築
- 03エンドポイントとCloudflare D1の連携
- 04Zod v4構文と検証の適用
- 05例外クラスによるエラー処理
活用シーン
- 01Honoで型安全なAPIエンドポイントを作成
- 02D1モデルのCRUDルートを生成
- 03リクエストとレスポンスのスキーマを文書化
- 04よくある問題に沿ったエンドポイントコードの確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ cloudflare/ chanfana --skill write-endpointsインストール後、Claude Code で/write-endpointsを入力して読み込まれたか確認します。
詳細
Chanfana で OpenAPI エンドポイントを作成する
使用する場面
次のような場合にこのスキルを使用します。
- Cloudflare Workers 向けに chanfana で 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;
スキーマの定義
body、query、params、headers のリクエスト検証を定義します。
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:エラー処理
例外クラス
| 例外 | ステータス | コード | デフォルトメッセージ | 特別なプロパティ |
|---|---|---|---|---|
ApiException | 500 | 7000 | "内部エラー" | 基底クラス |
InputValidationException | 400 | 7001 | "入力検証エラー" | path |
NotFoundException | 404 | 7002 | "見つかりません" | - |
UnauthorizedException | 401 | 7003 | "認証されていません" | - |
ForbiddenException | 403 | 7004 | "アクセスが禁止されています" | - |
MethodNotAllowedException | 405 | 7005 | "許可されていないメソッド" | - |
ConflictException | 409 | 7006 | "競合" | - |
UnprocessableEntityException | 422 | 7007 | "処理できないエンティティ" | path |
TooManyRequestsException | 429 | 7008 | "リクエストが多すぎます" | retryAfter |
InternalServerErrorException | 500 | 7009 | "内部サーバーエラー" | isVisible: false |
BadGatewayException | 502 | 7010 | "不正なゲートウェイ" | - |
ServiceUnavailableException | 503 | 7011 | "サービスを利用できません" | retryAfter |
GatewayTimeoutException | 504 | 7012 | "ゲートウェイタイムアウト" | - |
例外をスローする
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.string().email()ではなくz.email()) - パスパラメーターがスキーマとルート定義で一致している(
:userId→params: z.object({ userId: ... })) -
...ExceptionClass.schema()の展開を使用して例外レスポンスが文書化されている
CRUD 自動エンドポイント:
-
_metaプロパティがエンドポイントクラスで定義されている -
_meta.model.schemaが有効な Zod オブジェクトスキーマである -
_meta.model.primaryKeysが主キーのフィールド名の配列である -
_meta.model.tableNameが設定されている(D1 エンドポイントでは必須) - 複合主キーを使うネストされたルートで、メタに
pathParametersを使用している - 関連するエンドポイントを OpenAPI タグでグループ化するために
_meta.tagsが設定されている - 必要に応じて
ListEndpointのfilterFields、searchFields、orderByFieldsが設定されている
D1 エンドポイント:
-
dbNameが wrangler.toml のバインディング名と一致している - UNIQUE 制約の処理用に
constraintsMessagesが定義されている - 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 の await 忘れ
// 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'])