npx skills add ...
npx skills add cloudflare/chanfana --skill write-endpoints
npx skills add cloudflare/chanfana --skill write-endpoints
Comprehensive guide for building OpenAPI endpoints with chanfana - schema definition, request validation, CRUD operations, D1 database integration, and exception handling
Use this skill when:
Define request validation for body, query, params, and headers:
Chanfana v3 uses Zod v4. Use the correct syntax:
Use native Zod schemas for all parameter types:
Always use await with getValidatedData():
In Zod v4, optional fields with .default() always have values in validated data. Use getUnvalidatedData() to detect what was actually sent:
All auto endpoints require a _meta property:
For composite primary keys in nested routes:
D1 endpoints extend CRUD endpoints with built-in database operations:
D1 endpoints include built-in security utilities:
| 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" | - |
Basic Endpoints:
responses (required, even if just 200)contentJson() wrapper for JSON request/response bodiesawait this.getValidatedData<typeof this.schema>() for type-safe accessz.email() not z.string().email()):userId -> params: z.object({ userId: ... }))...ExceptionClass.schema() spreadCRUD Auto Endpoints:
_meta property is defined on the endpoint class_meta.model.schema is a valid Zod object schema_meta.model.primaryKeys is an array of primary key field names_meta.model.tableName is set (required for D1 endpoints)pathParameters in meta for composite primary keys_meta.tags is set to group related endpoints under OpenAPI tagsfilterFields, searchFields, orderByFields configured as neededD1 Endpoints:
dbName matches the binding name in wrangler.tomlconstraintsMessages defined for UNIQUE constraint handling{ Bindings: { DB: D1Database } }1. Missing contentJson wrapper
2. Not awaiting getValidatedData
3. Using Zod v3 syntax
4. Forgetting response schema
5. Primary key mismatch in nested routes
6. Optional fields with defaults in Zod v4
7. D1 binding name mismatch
8. Missing _meta in auto endpoints
9. Using nativeEnum in Zod v4
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 };
}
}// 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'])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])$/
);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 };
}
}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 };
}
}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
};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);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);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=descimport { 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);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);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);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);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']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
},
};
}// 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({...}))
}
}// WRONG - missing await
const data = this.getValidatedData<typeof this.schema>();
// CORRECT
const data = await this.getValidatedData<typeof this.schema>();// WRONG - Zod v3 syntax
z.string().email()
z.string().datetime()
z.object({}).strict()
// CORRECT - Zod v4 syntax
z.email()
z.iso.datetime()
z.strictObject({})// WRONG - no responses defined
schema = { request: { ... } }
// CORRECT - always define responses
schema = {
request: { ... },
responses: { "200": { description: 'Success', ...contentJson(...) } }
}// 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'],
};// 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
}// 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
}// 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) { ... }
}// WRONG - Zod v3 syntax
enum Status { Active = 'active', Inactive = 'inactive' }
z.nativeEnum(Status)
// CORRECT - Zod v4 syntax
z.enum(['active', 'inactive'])