Examples and Recipes
This section provides practical examples and recipes for common API development tasks using Chanfana. These examples are designed to be hands-on and demonstrate how to apply Chanfana's features in real-world scenarios.
Complete API Example: A Simple Task Management API (Hono and D1)
Let's build a complete example of a simple Task Management API using Hono, Chanfana, and Cloudflare D1 for data persistence. This example will demonstrate CRUD operations for tasks, including listing, creating, reading, updating, and deleting tasks.
Project Setup:
Create a new Cloudflare Workers project:
bashnpm create cloudflare@latest my-task-api -- --no-deploy cd my-task-api
Install dependencies:
bashnpm install hono chanfana zod
Set up D1 binding in
wrangler.toml
:tomlname = "my-task-api" main = "src/index.ts" compatibility_date = "2024-01-01" [[d1_databases]] binding = "DB" database_name = "task-database" database_id = "your-database-id" # Replace with your D1 database ID
Create
src/index.ts
with the following code:typescriptimport { Hono } from 'hono'; import { fromHono, D1CreateEndpoint, D1ReadEndpoint, D1UpdateEndpoint, D1DeleteEndpoint, D1ListEndpoint, contentJson } from 'chanfana'; import { z } from 'zod'; // Task Model const TaskModel = z.object({ id: z.string().uuid(), title: z.string().min(3).max(100), description: z.string().optional(), completed: z.boolean().default(false), createdAt: z.string().datetime(), updatedAt: z.string().datetime().optional(), }); // Task Meta const taskMeta = { model: { schema: TaskModel, primaryKeys: ['id'], tableName: 'tasks', }, }; // Endpoints class CreateTask extends D1CreateEndpoint { _meta = taskMeta; dbName = 'DB'; } class GetTask extends D1ReadEndpoint { _meta = taskMeta; dbName = 'DB'; } class UpdateTask extends D1UpdateEndpoint { _meta = taskMeta; dbName = 'DB'; } class DeleteTask extends D1DeleteEndpoint { _meta = taskMeta; dbName = 'DB'; } class ListTasks extends D1ListEndpoint { _meta = taskMeta; dbName = 'DB'; } const app = new Hono<{ Bindings: { DB: D1Database } }>(); const openapi = fromHono(app); openapi.post('/tasks', CreateTask); openapi.get('/tasks/:id', GetTask); openapi.put('/tasks/:id', UpdateTask); openapi.delete('/tasks/:id', DeleteTask); openapi.get('/tasks', ListTasks); export default app;
Create a
migrations
directory and a migration file (e.g.,migrations/0001-create-tasks-table.sql
) with the following SQL to create thetasks
table in your D1 database:sqlCREATE TABLE tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, completed INTEGER NOT NULL DEFAULT 0, createdAt TEXT NOT NULL, updatedAt TEXT );
Apply the migration using
wrangler d1 migrations apply task-database --local
(or--remote
for your deployed database).Run the API using
wrangler dev
and access the documentation athttp://localhost:8787/api/docs
.
This example sets up a fully functional Task Management API with CRUD operations, input validation, OpenAPI documentation, and D1 database persistence, all with minimal code thanks to Chanfana's predefined D1 endpoints.
OpenAPI schema customizations
Besides adding a schema to your endpoints, its also recommended you customize your schema. This can be done by passing the schema argument when creating your router.
All OpenAPI Object Properties except paths
, components
and webhooks
are available.
paths
can only be added by registering routes like:
const router = Router()
const openAPI = fromIttyRouter(router)
openAPI.post('/scan/metadata/', ScanMetadataCreate)
components
can only be added by registering them in the main router like:
const router = Router()
const openAPI = fromIttyRouter(router)
const bearerAuth = openAPI.registry.registerComponent(
'securitySchemes',
'bearerAuth',
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
)
Every other property must be defined in your main/root router as such:
const router = Router()
const openAPI = fromIttyRouter(router, {
schema: {
info: {
title: 'Radar Worker API',
version: '1.0',
},
servers: [
{
"url": "https://development.gigantic-server.com/v1",
"description": "Development server"
},
{
"url": "https://staging.gigantic-server.com/v1",
"description": "Staging server"
},
{
"url": "https://api.gigantic-server.com/v1",
"description": "Production server"
}
]
},
})
For more information on the structure of every available property you can read the specification for OpenAPI 3 here and OpenAPI 3.1 here.
Pagination for List Endpoints
Pagination is essential for list endpoints to handle large datasets efficiently. Chanfana's ListEndpoint
and D1ListEndpoint
automatically support pagination using query parameters page
and per_page
.
Example: Pagination with ListEndpoint
// ... (ListEndpoint and Meta definition) ...
class MyListEndpoint extends ListEndpoint {
_meta = myMeta; // Your Meta object
async list(filters: any) {
const page = filters.options.page;
const perPage = filters.options.per_page;
// ... logic to fetch paginated data from your data source ...
const items = getPaginatedItems(page, perPage); // Assume this function fetches paginated data
const totalCount = getTotalItemCount(); // Assume this function gets total count
return {
result: items,
result_info: {
page: page,
per_page: perPage,
total_count: totalCount,
},
};
}
}
Query Parameters:
page
: (integer, default: 1) Page number to retrieve.per_page
: (integer, default: 20, max: 100) Number of items per page.
Chanfana automatically documents these query parameters in your OpenAPI specification and makes them available in the filters.options
object within your list
method.
Filtering for List Endpoints
Filtering allows clients to narrow down the results of list endpoints based on specific criteria. ListEndpoint
and D1ListEndpoint
provide built-in support for filtering based on the filterFields
property.
Example: Filtering with D1ListEndpoint
// ... (D1ListEndpoint and Meta definition) ...
class MyD1ListEndpoint extends D1ListEndpoint {
_meta = myMeta; // Your Meta object
filterFields = ['status', 'category']; // Enable filtering by 'status' and 'category' fields
}
Query Parameters (for filtering, automatically generated based on filterFields
):
status
: (string, optional) Filter bystatus
field (exact match).category
: (string, optional) Filter bycategory
field (exact match).
Chanfana automatically generates query parameters in your OpenAPI spec based on filterFields
. In your list
method (or the default D1 list
method), the filters.filters
array will contain filter conditions based on the provided query parameters.
File Uploads (if supported, or planned)
(Currently, Chanfana core library does not have built-in direct support for file uploads in terms of specialized parameter types or request body handling for multipart/form-data
. However, you can handle file uploads using the underlying router's capabilities and standard Fetch API methods within your OpenAPIRoute
endpoints.)
Example: Basic File Upload Handling in Hono (Conceptual)
import { Hono, type Context } from 'hono';
import { fromHono, OpenAPIRoute } from 'chanfana';
import { z } from 'zod';
export type Env = {
// Example bindings, use your own
DB: D1Database
BUCKET: R2Bucket
}
export type AppContext = Context<{ Bindings: Env }>
class UploadFileEndpoint extends OpenAPIRoute {
schema = {
request: {
// OpenAPI schema for multipart/form-data request (example - adjust as needed)
body: {
description: "File upload",
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
file: {
type: "string",
format: "binary",
description: "File to upload"
},
description: {
type: "string",
description: "Optional file description"
}
},
required: ["file"]
}
}
}
}
},
responses: {
"200": { description: 'File uploaded successfully' },
},
};
async handle(c: AppContext) { // Hono Context
const formData = await c.req.formData();
const file = formData.get('file') as File;
const description = formData.get('description') as string | null;
if (!file || !(file instanceof File)) {
return c.json({ error: 'No file uploaded' }, 400);
}
// ... logic to process file upload (e.g., save to storage) ...
return { message: 'File uploaded successfully', filename: file.name, size: file.size, description };
}
}
const app = new Hono<{ Bindings: Env }>();
const openapi = fromHono(app);
openapi.post('/upload', UploadFileEndpoint);
export default app;
Explanation:
- OpenAPI Schema for
multipart/form-data
: We manually define an OpenAPI schema formultipart/form-data
request body within theschema.request.body.content
. This example shows a basic schema with afile
(binary format) and an optionaldescription
field. Note: Zod itself doesn't directly validatemultipart/form-data
. handle
Method:- We use
c.req.formData()
(Hono-specific method to parse form data) to get theFormData
object from the request. - We extract the
file
anddescription
fields from theFormData
. - We perform basic checks to ensure a file was uploaded.
- File Processing Logic: You would replace the
// ... logic to process file upload ...
comment with your actual file handling logic (e.g., saving to R2, cloud storage, etc.).
- We use
Important Notes for File Uploads:
- Zod Validation: Zod is primarily designed for validating JSON-like data structures. Direct validation of
multipart/form-data
with Zod is not straightforward. You might need to perform manual validation of file types, sizes, etc., within yourhandle
method. - Streaming: For large file uploads, consider using streaming techniques to avoid loading the entire file into memory at once. Refer to your router's documentation and Fetch API streams for handling large request bodies efficiently.
- Security: Implement proper security measures for file uploads, including file type validation, size limits, and protection against malicious files.
(Future versions of Chanfana might introduce more specialized parameter types or utilities for handling file uploads more seamlessly.)
API Authentication and Authorization (Basic Example)
API authentication and authorization are critical for securing your APIs. Here's a basic example of implementing API key-based authentication using middleware in Hono and Chanfana.
Example: API Key Authentication Middleware
import { Hono, type Context } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { fromHono, OpenAPIRoute } from 'chanfana';
import { z } from 'zod';
export type Env = {
// Example bindings, use your own
DB: D1Database
BUCKET: R2Bucket
}
export type AppContext = Context<{ Bindings: Env }>
// API Key Authentication Middleware
const apiKeyAuthMiddleware = async (c, next) => {
const apiKey = c.req.header('X-API-Key');
if (!apiKey || apiKey !== process.env.API_KEY) { // Validate against API_KEY environment variable
throw new HTTPException(401, { message: 'Invalid API Key.' })
}
await next();
};
class ProtectedDataEndpoint extends OpenAPIRoute {
schema = {
responses: {
"200": { description: 'Protected data' },
"401": { description: 'Invalid API Key' },
},
};
async handle(c: AppContext) {
return { message: 'Access granted to protected data' };
}
}
const app = new Hono<{ Bindings: Env }>();
const openapi = fromHono(app);
// Apply API Key authentication middleware to /protected route
openapi.get('/protected', apiKeyAuthMiddleware, ProtectedDataEndpoint);
export default app;
Explanation:
apiKeyAuthMiddleware
: This middleware function:- Retrieves the
X-API-Key
header from the request. - Validates the API key against an expected value (e.g., from environment variables).
- If the API key is invalid or missing, it throws an
HTTPException
. - If the API key is valid, it calls
next()
to proceed to the next handler in the chain (the endpoint).
- Retrieves the
ProtectedDataEndpoint
: This endpoint is protected by theapiKeyAuthMiddleware
. It also documents a401 Unauthorized
.- Applying Middleware: We use
openapi.get('/protected', apiKeyAuthMiddleware, ProtectedDataEndpoint)
to apply theapiKeyAuthMiddleware
to the/protected
route.
Security Considerations for Authentication:
- Securely Store API Keys: Never hardcode API keys directly in your code. Use environment variables or secure configuration management to store sensitive credentials.
- HTTPS: Always use HTTPS to encrypt communication between clients and your API, protecting API keys and other sensitive data in transit.
- Rate Limiting and Abuse Prevention: Implement rate limiting and other security measures to prevent abuse of your API and protect against brute-force attacks on authentication.
- More Robust Authentication Methods: For production APIs, consider more robust authentication methods like OAuth 2.0, JWT (JSON Web Tokens), or session-based authentication, depending on your security requirements.
Custom Error Handling
In order to customize the zod error formats, just overwrite the handleValidationError
function in your endpoint class
import { OpenAPIRoute } from 'chanfana'
import { Context } from 'hono'
export class ToDoList extends OpenAPIRoute {
schema = {
// ...
}
handleValidationError(errors: z.ZodIssue[]): Response {
return Response.json({
errors: errors,
success: false,
result: {},
}, {
status: 400,
})
}
async handle(c: Context) {
// ...
}
}
Reusing errors handlers across the project
First define a generic class that extends OpenAPIRoute
, in this function define you cross endpoint functions
import { OpenAPIRoute } from "chanfana";
class MyProjectRoute extends OpenAPIRoute {
handleValidationError(errors: z.ZodIssue[]): Response {
return Response.json({
errors: errors,
success: false,
result: {},
}, {
status: 400,
})
}
}
Then, in your endpoint extend from the new class
import { MyProjectRoute } from './route'
import { Context } from 'hono'
export class ToDoList extends MyProjectRoute {
schema = {
// ...
}
async handle(c: Context) {
// ...
}
}
Custom Response Formats
Describing a binary file:
import { OpenAPIRoute, Str } from 'chanfana'
import { Context } from 'hono'
export class ToDoList extends OpenAPIRoute {
schema = {
summary: 'My summary of a custom pdf file endpoint.',
responses: {
'200': {
description: 'PDF response',
content: {
'application/pdf': {
schema: Str({ format: 'binary' }),
},
},
},
},
}
async handle(c: Context) {
// ...
}
}
Describing multiple content types:
import { OpenAPIRoute, Str } from 'chanfana'
import { Context } from 'hono'
export class ToDoList extends OpenAPIRoute {
schema = {
summary: 'My summary of a custom pdf file endpoint.',
responses: {
'200': {
description: 'Successful response',
content: {
'application/json': {
schema: z.object({
title: z.string()
}),
},
'audio/mpeg': {
schema: Str({ format: 'binary' }),
},
},
},
},
}
async handle(c: Context) {
// ...
}
}
Custom Response Headers
Describing response headers:
import { OpenAPIRoute, Str } from 'chanfana'
import { Context } from 'hono'
export class ToDoList extends OpenAPIRoute {
schema = {
responses: {
200: {
description: 'Object with user data.',
content: {
'application/json': {
schema: z.object({
series: z.object({
timestamps: z.string().date().array(),
values: z.number().array(),
})
}),
},
},
headers: {
'x-bar': 'header-example',
'x-foo': new Str({required: false}),
},
},
},
}
async handle(c: Context) {
// ...
}
}
Describing response headers with zod:
import { OpenAPIRoute, Str } from 'chanfana'
import { Context } from 'hono'
export class ToDoList extends OpenAPIRoute {
schema = {
responses: {
200: {
description: 'Object with user data.',
content: {
'application/json': {
schema: z.object({
series: z.object({
timestamps: z.string().date().array(),
values: z.number().array(),
})
}),
},
},
headers: z.object({
'x-bar': z.string()
}),
},
},
}
async handle(c: Context) {
// ...
}
}
Hiding Routes in OpenAPI Schema
If you don't want a route to be displayed in the openapi schema, just register it in the base router
import { fromIttyRouter } from 'chanfana'
import { Router } from 'itty-router'
const router = Router()
const openAPI = fromIttyRouter(router)
router.get(
'/todos/:id',
({ params }) => new Response(`Todo #${params.id}`)
)
This endpoint will still be accessible, but will not be shown in the schema.
Reusable Schemas
Before continuing, please learn more about Reusing Descriptions by OpenAPI.
To start reusing your schemas, all you need to do is call the .openapi("schema name here")
after any schema you have defined. This includes parameters
, requestBody
, responses
even Enum
.
!!! note
This is only available when using [chanfana types](../types.md#chanfana-types) or
[zod types](../types.md#zod-types)
export class PutMetadata extends OpenAPIRoute {
schema = {
operationId: 'post-bucket-put-object-metadata',
tags: ['Buckets'],
summary: 'Update object metadata',
parameters: {
bucket: Path(String),
key: Path(z.string().describe('base64 encoded file key')),
},
requestBody: z.object({
customMetadata: z.record(z.string(), z.any())
}).openapi("Object metadata")
}
// ...
}
Then when running the server, it would get rendered like this:
The OpenAPI spec will also reflect this, by moving the schemas out of the endpoint and into the components
:
{
"components": {
"schemas": {
"Object metadata": {
"type": "object",
"properties": {
"customMetadata": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"customMetadata"
]
}
}
}
}
Inside the endpoint schema, the reusable parameter is referenced by the name:
{
"paths": {
"post": {
"operationId": "post-bucket-put-object-metadata",
"tags": [
"Buckets"
],
"summary": "Update object metadata",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Object metadata"
}
}
}
},
"responses": {}
}
}
}
}
Accessing URL parameters from the class schema
You can now get a list of url parameters inside the getSchema function. This can be very helpful when auto generating schemas
import { OpenAPIRoute } from './route'
// Define route
router.get("/v1/:account_id/gateways/:gateway_id", GetGateway);
export class GetAccountStats extends OpenAPIRoute {
getSchema() {
console.log(this.params.urlParams)
// The line above will print this: ["account_id", "gateway_id"]
// You can use this to manipulate the schema, adding or removing fields
return this.schema
}
};
CI/CD Pipelines
For CI/CD pipelines, you can read the complete openapi.json
schemas by calling the schema
property from the router instance.
Here is an example of a nodejs script that would pick the schema, make some changes and write it to a file, to be able to be picked from a CI/CD pipeline.
import fs from 'fs'
import { openAPI } from '../src/router'
// Get the Schema from chanfana
const schema = openAPI.schema
// Optionaly: update the schema with some costumizations for publishing
// Write the final schema
fs.writeFileSync('./public-api.json', JSON.stringify(schema, null, 2))
These examples and recipes provide a starting point for building various types of APIs with Chanfana. You can adapt and extend these patterns to create more complex and feature-rich APIs tailored to your specific needs.