Skill v1.0.0
currentTrusted Publisher100/100version: "1.0.0" name: write-endpoints description: Comprehensive guide for building OpenAPI endpoints with chanfana - schema definition, request validation, CRUD operations, D1 database integration, and exception handling
Writing OpenAPI Endpoints with Chanfana
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
Part 1: Fundamentals
Quick Start with 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;
Quick Start with 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;
Schema Definition
Define request validation for body, query, params, and 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 typedreturn { id: crypto.randomUUID(), username: data.body.username, email: data.body.email };}}
Zod v4 Syntax (CRITICAL)
Chanfana v3 uses Zod v4. Use the correct syntax:
// 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 syntaxz.email()z.uuid()z.iso.datetime()z.iso.date()z.url()z.ipv4()z.strictObject({})z.enum(['option1', 'option2'])
Common Zod Types for APIs
Use native Zod schemas for all parameter types:
import { z } from 'zod';// String with constraintsconst nameSchema = z.string().min(3).max(50).describe("User's name").openapi({ example: 'John Doe' });// Number with rangeconst priceSchema = z.number().min(0).describe('Product price').openapi({ example: 99.99 });// Integerconst ageSchema = z.number().int().min(0).max(120).describe("User's age");// Boolean with defaultconst 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, UUIDconst emailSchema = z.email().describe('Email address');const userIdSchema = z.uuid().describe('User ID');// Enumerationconst statusSchema = z.enum(['pending', 'processing', 'shipped', 'delivered']).default('pending').describe('Order status');// Arrayconst tagsSchema = z.array(z.string()).openapi({description: 'Tags',});// Objectconst addressSchema = z.object({street: z.string().describe('Street address'),city: z.string().describe('City'),zipCode: z.string().describe('Zip code'),});// Regex patternconst phoneSchema = z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number format').describe('Phone number');// IP addressesconst ipv4Schema = z.ipv4();const ipv6Schema = z.ipv6();const ipSchema = z.union([z.ipv4(), z.ipv6()]);// Hostname (regex pattern)const hostnameSchema = z.string().regex(/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/);
Validated Data Access
Always use await with getValidatedData():
class MyEndpoint extends OpenAPIRoute {async handle(c) {// CORRECT - with await and type annotationconst data = await this.getValidatedData<typeof this.schema>();// Type-safe accessconst 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
In Zod v4, optional fields with .default() always have values in validated data. Use getUnvalidatedData() to detect what was actually sent:
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 sentconst raw = await this.getUnvalidatedData();// raw.body = {} if nothing was sent// Check what was actually sentconst 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
Meta Object Definition
All auto endpoints require a _meta property:
import { z } from 'zod';// Define the model schemaconst 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 objectconst userMeta = {model: {schema: UserSchema, // Required: Zod schema for the modelprimaryKeys: ['id'], // Required: Array of primary key fieldstableName: 'users', // Required for D1 endpointsserializer: (user: any) => { // Optional: Transform outputconst { passwordHash, ...safe } = user;return safe;},serializerSchema: UserSchema.omit({ passwordHash: true }), // Optional: Schema for serialized output},pathParameters: ['id'], // Optional: Explicit path params for nested routestags: ['Users'], // Optional: OpenAPI tags for grouping operations};
CreateEndpoint
import { CreateEndpoint, type O } from 'chanfana';class CreateUser extends CreateEndpoint {_meta = userMeta;// Optional: Pre-processing hookasync before(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {return {...data,id: crypto.randomUUID(),createdAt: new Date().toISOString(),};}// Required: Create logicasync create(data: O<typeof this._meta>) {await db.users.insert(data);return data;}// Optional: Post-processing hookasync after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {await sendWelcomeEmail(data.email);return data;}}// Register routeopenapi.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 validationreturn 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 processingreturn data;}}// Register route with path parameteropenapi.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 sortingfilterFields = ['role', 'status']; // Exact match filteringsearchFields = ['username', 'email']; // Full-text search (LIKE)orderByFields = ['createdAt', 'username']; // Available sort fieldsdefaultOrderBy = 'createdAt'; // Default sort fieldasync 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 routeopenapi.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 routeopenapi.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 routeopenapi.delete('/users/:id', DeleteUser);
Nested Routes with pathParameters
For composite primary keys in nested routes:
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 keytableName: '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/:idconst postsRouter = new Hono();const postsOpenapi = fromHono(postsRouter);postsOpenapi.get('/:id', GetPost);// Mount nested routeropenapi.route('/:userId/posts', postsOpenapi);
Part 3: D1 Database Integration
D1 Endpoint Classes
D1 endpoints extend CRUD endpoints with built-in database operations:
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 violationsconstraintsMessages = {'users_email_unique': new InputValidationException('Email already registered',['body', 'email']),'users_username_unique': new InputValidationException('Username already taken',['body', 'username']),};// Optional: Enable logginglogger = 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 routesconst 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
D1 endpoints include built-in security utilities:
import {validateSqlIdentifier,validateTableName,validateColumnName,buildSafeFilters,} from 'chanfana/endpoints/d1/base';// Validate identifiersconst table = validateTableName('users'); // OKconst column = validateColumnName('email'); // OKvalidateTableName('DROP TABLE--'); // Throws ApiException// Build safe WHERE clausesconst 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 | |
|---|---|---|---|---|---|
ApiException | 500 | 7000 | "Internal Error" | Base class | |
InputValidationException | 400 | 7001 | "Input Validation Error" | path | |
NotFoundException | 404 | 7002 | "Not Found" | - | |
UnauthorizedException | 401 | 7003 | "Unauthorized" | - | |
ForbiddenException | 403 | 7004 | "Forbidden" | - | |
MethodNotAllowedException | 405 | 7005 | "Method Not Allowed" | - | |
ConflictException | 409 | 7006 | "Conflict" | - | |
UnprocessableEntityException | 422 | 7007 | "Unprocessable Entity" | path | |
TooManyRequestsException | 429 | 7008 | "Too Many Requests" | retryAfter | |
InternalServerErrorException | 500 | 7009 | "Internal Server Error" | isVisible: false | |
BadGatewayException | 502 | 7010 | "Bad Gateway" | - | |
ServiceUnavailableException | 503 | 7011 | "Service Unavailable" | retryAfter | |
GatewayTimeoutException | 504 | 7012 | "Gateway Timeout" | - |
Throwing Exceptions
import {InputValidationException,NotFoundException,UnauthorizedException,ForbiddenException,ConflictException,TooManyRequestsException,MultiException,} from 'chanfana';class MyEndpoint extends OpenAPIRoute {async handle(c) {// Validation error with pathif (!isValidEmail(email)) {throw new InputValidationException('Invalid email format', ['body', 'email']);}// Not foundconst user = await db.users.findById(id);if (!user) {throw new NotFoundException(`User ${id} not found`);}// Authentication requiredif (!c.req.header('Authorization')) {throw new UnauthorizedException('Authentication required');}// Permission deniedif (!user.hasPermission('admin')) {throw new ForbiddenException('Admin access required');}// Resource conflictif (await db.users.existsByEmail(email)) {throw new ConflictException('Email already registered');}// Rate limitingif (rateLimitExceeded) {throw new TooManyRequestsException('Rate limit exceeded', 60); // retry after 60s}// Multiple errorsconst 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
import {OpenAPIRoute,contentJson,InputValidationException,NotFoundException,UnauthorizedException,} from 'chanfana';class GetUser extends OpenAPIRoute {schema = {request: {params: z.object({ id: z.uuid() }),},responses: {"200": {description: 'User found',...contentJson(UserSchema),},...InputValidationException.schema(), // Documents 400 response...UnauthorizedException.schema(), // Documents 401 response...NotFoundException.schema(), // Documents 404 response},};}
Part 5: Verification
Checklist
Basic Endpoints:
- [ ] Schema defines
responses(required, even if just 200) - [ ] Using
contentJson()wrapper for JSON request/response bodies - [ ] Using
await this.getValidatedData<typeof this.schema>()for type-safe access - [ ] Using Zod v4 syntax (
z.email()notz.string().email()) - [ ] Path parameters in schema match route definition (
:userId->params: z.object({ userId: ... })) - [ ] Exception responses documented using
...ExceptionClass.schema()spread
CRUD Auto Endpoints:
- [ ]
_metaproperty is defined on the endpoint class - [ ]
_meta.model.schemais a valid Zod object schema - [ ]
_meta.model.primaryKeysis an array of primary key field names - [ ]
_meta.model.tableNameis set (required for D1 endpoints) - [ ] Nested routes use
pathParametersin meta for composite primary keys - [ ]
_meta.tagsis set to group related endpoints under OpenAPI tags - [ ] ListEndpoint has
filterFields,searchFields,orderByFieldsconfigured as needed
D1 Endpoints:
- [ ]
dbNamematches the binding name in wrangler.toml - [ ]
constraintsMessagesdefined for UNIQUE constraint handling - [ ] Hono app typed with
{ Bindings: { DB: D1Database } }
Common Mistakes
1. Missing contentJson wrapper
// WRONG - response body not properly documentedresponses: {"200": {description: 'Success',content: { 'application/json': { schema: z.object({...}) } }}}// CORRECT - use contentJson helperresponses: {"200": {description: 'Success',...contentJson(z.object({...}))}}
2. Not awaiting getValidatedData
// WRONG - missing awaitconst data = this.getValidatedData<typeof this.schema>();// CORRECTconst data = await this.getValidatedData<typeof this.schema>();
3. Using Zod v3 syntax
// WRONG - Zod v3 syntaxz.string().email()z.string().datetime()z.object({}).strict()// CORRECT - Zod v4 syntaxz.email()z.iso.datetime()z.strictObject({})
4. Forgetting response schema
// WRONG - no responses definedschema = { request: { ... } }// CORRECT - always define responsesschema = {request: { ... },responses: { "200": { description: 'Success', ...contentJson(...) } }}
5. Primary key mismatch in nested routes
// WRONG - composite key not reflected in pathParametersconst postMeta = {model: {primaryKeys: ['userId', 'postId'],}};// Route: /users/:userId/posts/:postId but no pathParameters// CORRECT - explicitly define pathParametersconst postMeta = {model: {primaryKeys: ['userId', 'postId'],},pathParameters: ['userId', 'postId'],};
6. Optional fields with defaults in Zod v4
// GOTCHA - Zod v4 always provides default valuesconst data = await this.getValidatedData();// data.body.status is 'active' even if not sent in request// SOLUTION - use getUnvalidatedData() to check what was actually sentconst raw = await this.getUnvalidatedData();if ('status' in raw.body) {// status was actually sent}
7. D1 binding name mismatch
// WRONG - binding name doesn't match wrangler.tomlclass MyEndpoint extends D1CreateEndpoint {dbName = 'DATABASE'; // wrangler.toml has binding = "DB"}// CORRECTclass MyEndpoint extends D1CreateEndpoint {dbName = 'DB'; // matches wrangler.toml [[d1_databases]] binding}
8. Missing _meta in auto endpoints
// WRONG - no _meta definedclass CreateUser extends CreateEndpoint {async create(data) { ... }}// CORRECT - _meta is requiredclass CreateUser extends CreateEndpoint {_meta = {model: {schema: UserSchema,primaryKeys: ['id'],tableName: 'users',},};async create(data) { ... }}
9. Using nativeEnum in Zod v4
// WRONG - Zod v3 syntaxenum Status { Active = 'active', Inactive = 'inactive' }z.nativeEnum(Status)// CORRECT - Zod v4 syntaxz.enum(['active', 'inactive'])