| 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: ,
...(z.({
: z.(),
: z.(),
: z.(),
})),
},
: {
: ,
...(z.({
: z.(),
: z.(z.({
: z.(),
: z.(),
})),
})),
},
},
};
() {
data = .< .>();
{ : crypto.(), : data.., : data.. };
}
}
Zod v4 Syntax (CRITICAL)
Chanfana v3 uses Zod v4. Use the correct syntax:
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)
z.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';
const nameSchema = z.string()
.min(3)
.max(50)
.describe("User's name")
.openapi({ example: 'John Doe' });
const priceSchema = z.number()
.min(0)
.describe('Product price')
.openapi({ example: 99.99 });
const ageSchema = z.number()
.int()
.min(0)
.max(120)
.describe("User's age");
const isActiveSchema = z.boolean()
.default(true)
.describe('User active status');
const createdAtSchema = z.iso.datetime()
.describe('Creation timestamp')
.openapi({ example: });
birthDateSchema = z..()
.()
.({ : });
emailSchema = z.().();
userIdSchema = z.().();
statusSchema = z.([, , , ])
.()
.();
tagsSchema = z.(z.()).({
: ,
});
addressSchema = z.({
: z.().(),
: z.().(),
: z.().(),
});
phoneSchema = z.()
.(, )
.();
ipv4Schema = z.();
ipv6Schema = z.();
ipSchema = z.([z.(), z.()]);
hostnameSchema = z.().(
);
Validated Data Access
Always use await with getValidatedData():
class MyEndpoint extends OpenAPIRoute {
async handle(c) {
const data = await this.getValidatedData<typeof this.schema>();
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 };
}
}
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>();
const raw = await this.getUnvalidatedData();
const updates: Record<string, any> = {};
if ('name' in raw.body) updates.name = validated.body.name;
if ('status' in raw.) updates. = validated..;
{ : updates };
}
}
Part 2: CRUD Auto Endpoints
Meta Object Definition
All auto endpoints require a _meta property:
import { z } from 'zod';
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(),
});
const userMeta = {
model: {
schema: UserSchema,
primaryKeys: ['id'],
tableName: 'users',
serializer: (user: any) => {
const { passwordHash, ...safe } = user;
return safe;
},
serializerSchema: UserSchema.omit({ passwordHash: true }),
},
: [],
: [],
};
CreateEndpoint
import { CreateEndpoint, type O } from 'chanfana';
class CreateUser extends CreateEndpoint {
_meta = userMeta;
async before(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
return {
...data,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
};
}
async create(data: O<typeof this._meta>) {
await db.users.insert(data);
return data;
}
async after(data: O<typeof this._meta>): Promise<O<typeof this._meta>> {
await (data.);
data;
}
}
openapi.(, );
ReadEndpoint
import { ReadEndpoint, type Filters, type O } from 'chanfana';
class GetUser extends ReadEndpoint {
_meta = userMeta;
async before(filters: Filters): Promise<Filters> {
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>> {
return data;
}
}
openapi.get('/users/:id', );
ListEndpoint
import { ListEndpoint, type ListFilters, type ListResult, type O } from 'chanfana';
class ListUsers extends ListEndpoint {
_meta = userMeta;
filterFields = ['role', 'status'];
searchFields = ['username', 'email'];
orderByFields = ['createdAt', 'username'];
defaultOrderBy = 'createdAt';
async before(filters: ListFilters): Promise<ListFilters> {
return filters;
}
async list(filters: ListFilters): Promise<ListResult<O<typeof this._meta>>> {
const users = await db.users.findMany(filters);
return { result: users };
}
(: <O< .>>): <<O< .>>> {
data;
}
}
openapi.(, );
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.>, : ): <O< .>> {
userId = filters.[].;
db..(userId, { ...oldObj, ...filters. });
}
(: O< .>): <O< .>> {
cache.();
data;
}
}
openapi.(, );
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 .> | > {
userId = filters.[].;
db..(userId);
oldObj;
}
(: O< .>): <O< .>> {
auditLog.(, data.);
data;
}
}
openapi.(, );
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'],
tableName: 'posts',
},
pathParameters: ['userId', 'id'],
};
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..({ userId, : postId });
}
}
postsRouter = ();
postsOpenapi = (postsRouter);
postsOpenapi.(, );
openapi.(, 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';
class CreateUser extends D1CreateEndpoint {
_meta = userMeta;
dbName = 'DB';
constraintsMessages = {
'users_email_unique': new InputValidationException(
'Email already registered',
['body', 'email']
),
'users_username_unique': new InputValidationException(
'Username already taken',
['body', 'username']
),
};
logger = console;
}
class GetUser extends D1ReadEndpoint {
_meta = userMeta;
dbName = 'DB';
}
class UpdateUser extends D1UpdateEndpoint {
_meta = userMeta;
dbName = 'DB';
}
class DeleteUser extends {
_meta = userMeta;
dbName = ;
}
{
_meta = userMeta;
dbName = ;
filterFields = [, ];
searchFields = [, ];
orderByFields = [, ];
defaultOrderBy = ;
}
app = <{ : { : D1Database } }>();
openapi = (app);
openapi.(, );
openapi.(, );
openapi.(, );
openapi.(, );
openapi.(, );
SQL Injection Prevention
D1 endpoints include built-in security utilities:
import {
validateSqlIdentifier,
validateTableName,
validateColumnName,
buildSafeFilters,
} from 'chanfana/endpoints/d1/base';
const table = validateTableName('users');
const column = validateColumnName('email');
validateTableName('DROP TABLE--');
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);
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) {
if (!isValidEmail(email)) {
throw new InputValidationException('Invalid email format', ['body', 'email']);
}
const user = await db.users.findById(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
if (!c.req.header('Authorization')) {
throw new UnauthorizedException('Authentication required');
}
(!user.()) {
();
}
( db..(email)) {
();
}
(rateLimitExceeded) {
(, );
}
errors = [];
(field1Invalid) errors.( (, [, ]));
(field2Invalid) errors.( (, [, ]));
(errors. > ) {
(errors);
}
{ : };
}
}
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(),
...UnauthorizedException.schema(),
...NotFoundException.schema(),
},
};
}
Part 5: Verification
Checklist
Basic Endpoints:
CRUD Auto Endpoints:
D1 Endpoints:
Common Mistakes
1. Missing contentJson wrapper
responses: {
"200": {
description: 'Success',
content: { 'application/json': { schema: z.object({...}) } }
}
}
responses: {
"200": {
description: 'Success',
...contentJson(z.object({...}))
}
}
2. Not awaiting getValidatedData
const data = this.getValidatedData<typeof this.schema>();
const data = await this.getValidatedData<typeof this.schema>();
3. Using Zod v3 syntax
z.string().email()
z.string().datetime()
z.object({}).strict()
z.email()
z.iso.datetime()
z.strictObject({})
4. Forgetting response schema
schema = { request: { ... } }
schema = {
request: { ... },
responses: { "200": { description: 'Success', ...contentJson(...) } }
}
5. Primary key mismatch in nested routes
const postMeta = {
model: {
primaryKeys: ['userId', 'postId'],
}
};
const postMeta = {
model: {
primaryKeys: ['userId', 'postId'],
},
pathParameters: ['userId', 'postId'],
};
6. Optional fields with defaults in Zod v4
const data = await this.getValidatedData();
const raw = await this.getUnvalidatedData();
if ('status' in raw.body) {
}
7. D1 binding name mismatch
class MyEndpoint extends D1CreateEndpoint {
dbName = 'DATABASE';
}
class MyEndpoint extends D1CreateEndpoint {
dbName = 'DB';
}
8. Missing _meta in auto endpoints
class CreateUser extends CreateEndpoint {
async create(data) { ... }
}
class CreateUser extends CreateEndpoint {
_meta = {
model: {
schema: UserSchema,
primaryKeys: ['id'],
tableName: 'users',
},
};
async create(data) { ... }
}
9. Using nativeEnum in Zod v4
enum Status { Active = 'active', Inactive = 'inactive' }
z.nativeEnum(Status)
z.enum(['active', 'inactive'])