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. 01OpenAPIリクエストスキーマの定義と検証
  2. 02CRUD自動エンドポイントの構築
  3. 03エンドポイントとCloudflare D1の連携
  4. 04Zod v4構文と検証の適用
  5. 05例外クラスによるエラー処理

活用シーン

  1. 01Honoで型安全なAPIエンドポイントを作成
  2. 02D1モデルのCRUDルートを生成
  3. 03リクエストとレスポンスのスキーマを文書化
  4. 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:エラー処理

例外クラス
例外ステータスコードデフォルトメッセージ特別なプロパティ
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.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'])

コメント