📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,744 @@
|
||||
# Error Handling
|
||||
|
||||
Centralized error handling with `HTTPException` and `onError`.
|
||||
|
||||
## HTTPException
|
||||
|
||||
Throw typed HTTP errors with status codes and optional metadata.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
app.get('/users/:id', (c) => {
|
||||
const user = findUser(c.req.param('id'));
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ user });
|
||||
});
|
||||
```
|
||||
|
||||
### With Cause
|
||||
|
||||
```typescript
|
||||
app.get('/users/:id', (c) => {
|
||||
const id = c.req.param('id');
|
||||
const user = findUser(id);
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, {
|
||||
message: 'User not found',
|
||||
cause: { userId: id, timestamp: Date.now() }
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ user });
|
||||
});
|
||||
```
|
||||
|
||||
### Status Codes
|
||||
|
||||
```typescript
|
||||
// 400 Bad Request
|
||||
throw new HTTPException(400, { message: 'Invalid request' });
|
||||
|
||||
// 401 Unauthorized
|
||||
throw new HTTPException(401, { message: 'Missing or invalid token' });
|
||||
|
||||
// 403 Forbidden
|
||||
throw new HTTPException(403, { message: 'Insufficient permissions' });
|
||||
|
||||
// 404 Not Found
|
||||
throw new HTTPException(404, { message: 'Resource not found' });
|
||||
|
||||
// 409 Conflict
|
||||
throw new HTTPException(409, { message: 'Email already registered' });
|
||||
|
||||
// 422 Unprocessable Entity
|
||||
throw new HTTPException(422, { message: 'Validation failed' });
|
||||
|
||||
// 429 Too Many Requests
|
||||
throw new HTTPException(429, { message: 'Rate limit exceeded' });
|
||||
|
||||
// 500 Internal Server Error
|
||||
throw new HTTPException(500, { message: 'Internal server error' });
|
||||
|
||||
// 503 Service Unavailable
|
||||
throw new HTTPException(503, { message: 'Service temporarily unavailable' });
|
||||
```
|
||||
|
||||
## Custom Error Classes
|
||||
|
||||
Extend `HTTPException` for domain-specific errors.
|
||||
|
||||
### Common Error Classes
|
||||
|
||||
```typescript
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export class ValidationError extends HTTPException {
|
||||
constructor(message: string, issues?: Record<string, string>) {
|
||||
super(400, {
|
||||
message,
|
||||
cause: issues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends HTTPException {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends HTTPException {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(403, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends HTTPException {
|
||||
constructor(resource: string, id?: string) {
|
||||
super(404, {
|
||||
message: `${resource} not found`,
|
||||
cause: id ? { [resource.toLowerCase() + 'Id']: id } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends HTTPException {
|
||||
constructor(message: string, details?: Record<string, any>) {
|
||||
super(409, {
|
||||
message,
|
||||
cause: details,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends HTTPException {
|
||||
constructor(retryAfter: number) {
|
||||
super(429, {
|
||||
message: 'Too many requests',
|
||||
cause: { retryAfter },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
// Not found
|
||||
app.get('/posts/:id', (c) => {
|
||||
const post = findPost(c.req.param('id'));
|
||||
|
||||
if (!post) {
|
||||
throw new NotFoundError('Post', c.req.param('id'));
|
||||
}
|
||||
|
||||
return c.json({ post });
|
||||
});
|
||||
|
||||
// Unauthorized
|
||||
app.use('/api/*', (c, next) => {
|
||||
const token = c.req.header('authorization');
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedError('Missing authorization header');
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
// Forbidden
|
||||
app.delete('/posts/:id', (c) => {
|
||||
const user = c.get('user');
|
||||
const post = findPost(c.req.param('id'));
|
||||
|
||||
if (post.authorId !== user.id && user.role !== 'admin') {
|
||||
throw new ForbiddenError('You can only delete your own posts');
|
||||
}
|
||||
|
||||
deletePost(post.id);
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// Conflict
|
||||
app.post('/users', async (c) => {
|
||||
const { email } = await c.req.json();
|
||||
|
||||
const existing = findUserByEmail(email);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictError('Email already registered', { email });
|
||||
}
|
||||
|
||||
const user = createUser({ email });
|
||||
return c.json({ user }, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Centralized Error Handler
|
||||
|
||||
Use `onError` to handle all errors in one place.
|
||||
|
||||
### Basic Handler
|
||||
|
||||
```typescript
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
// HTTPException (includes custom classes)
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json({
|
||||
error: err.message,
|
||||
...(err.cause && { details: err.cause })
|
||||
}, err.status);
|
||||
}
|
||||
|
||||
// Zod validation errors
|
||||
if (err instanceof ZodError) {
|
||||
return c.json({
|
||||
error: 'Validation failed',
|
||||
issues: err.issues.map(issue => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}))
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Generic errors
|
||||
return c.json({
|
||||
error: 'Internal server error'
|
||||
}, 500);
|
||||
});
|
||||
```
|
||||
|
||||
### Production-Safe Handler
|
||||
|
||||
```typescript
|
||||
app.onError((err, c) => {
|
||||
const isDev = Bun.env.NODE_ENV !== 'production';
|
||||
|
||||
// Log error
|
||||
console.error('Error:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
path: c.req.path,
|
||||
method: c.req.method,
|
||||
});
|
||||
|
||||
// HTTPException
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json({
|
||||
error: err.message,
|
||||
...(err.cause && { details: err.cause })
|
||||
}, err.status);
|
||||
}
|
||||
|
||||
// Zod validation
|
||||
if (err instanceof ZodError) {
|
||||
return c.json({
|
||||
error: 'Validation failed',
|
||||
issues: err.issues.map(issue => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}))
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Generic errors — sanitize in production
|
||||
return c.json({
|
||||
error: isDev ? err.message : 'Internal server error',
|
||||
...(isDev && { stack: err.stack })
|
||||
}, 500);
|
||||
});
|
||||
```
|
||||
|
||||
### Structured Error Logging
|
||||
|
||||
```typescript
|
||||
interface ErrorLog {
|
||||
timestamp: string;
|
||||
level: 'error' | 'warn';
|
||||
message: string;
|
||||
stack?: string;
|
||||
context: {
|
||||
path: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
user?: string;
|
||||
};
|
||||
}
|
||||
|
||||
app.onError((err, c) => {
|
||||
const log: ErrorLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: err instanceof HTTPException && err.status < 500 ? 'warn' : 'error',
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
context: {
|
||||
path: c.req.path,
|
||||
method: c.req.method,
|
||||
user: c.get('user')?.id,
|
||||
},
|
||||
};
|
||||
|
||||
// Log to external service (e.g., Sentry, LogRocket)
|
||||
if (log.level === 'error') {
|
||||
logToExternalService(log);
|
||||
} else {
|
||||
console.warn(JSON.stringify(log));
|
||||
}
|
||||
|
||||
// Return response
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json({ error: err.message }, err.status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
});
|
||||
```
|
||||
|
||||
## Validation Errors
|
||||
|
||||
Handle Zod validation errors with detailed messages.
|
||||
|
||||
### Basic Zod Error Handling
|
||||
|
||||
```typescript
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
const CreatePostSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
|
||||
content: z.string().min(1, 'Content is required'),
|
||||
tags: z.array(z.string()).max(5, 'Maximum 5 tags allowed'),
|
||||
});
|
||||
|
||||
app.post('/posts', zValidator('json', CreatePostSchema), (c) => {
|
||||
const data = c.req.valid('json');
|
||||
// Data is validated
|
||||
return c.json({ post: createPost(data) }, 201);
|
||||
});
|
||||
|
||||
// Handle validation errors in onError
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof ZodError) {
|
||||
return c.json({
|
||||
error: 'Validation failed',
|
||||
issues: err.issues.map(issue => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}))
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Other errors...
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Validation Messages
|
||||
|
||||
```typescript
|
||||
const EmailSchema = z.object({
|
||||
email: z.string()
|
||||
.email('Invalid email address')
|
||||
.refine(
|
||||
(email) => email.endsWith('@example.com'),
|
||||
'Email must be from example.com domain'
|
||||
),
|
||||
});
|
||||
|
||||
app.post('/validate-email', zValidator('json', EmailSchema), (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
return c.json({ valid: true, email });
|
||||
});
|
||||
```
|
||||
|
||||
### Field-Level Error Formatting
|
||||
|
||||
```typescript
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof ZodError) {
|
||||
// Group errors by field
|
||||
const fieldErrors: Record<string, string[]> = {};
|
||||
|
||||
for (const issue of err.issues) {
|
||||
const field = issue.path.join('.');
|
||||
if (!fieldErrors[field]) {
|
||||
fieldErrors[field] = [];
|
||||
}
|
||||
fieldErrors[field].push(issue.message);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
error: 'Validation failed',
|
||||
fields: fieldErrors,
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Other errors...
|
||||
});
|
||||
|
||||
// Example response:
|
||||
// {
|
||||
// "error": "Validation failed",
|
||||
// "fields": {
|
||||
// "email": ["Invalid email address"],
|
||||
// "password": ["Password must be at least 8 characters"],
|
||||
// "tags": ["Maximum 5 tags allowed"]
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
## Not Found Handler
|
||||
|
||||
Handle 404 errors for undefined routes.
|
||||
|
||||
```typescript
|
||||
app.notFound((c) => {
|
||||
return c.json({
|
||||
error: 'Not found',
|
||||
path: c.req.path,
|
||||
}, 404);
|
||||
});
|
||||
```
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
```typescript
|
||||
app.get('/data', async (c) => {
|
||||
try {
|
||||
// Try primary data source
|
||||
const data = await fetchFromPrimaryAPI();
|
||||
return c.json({ data, source: 'primary' });
|
||||
} catch (primaryErr) {
|
||||
console.warn('Primary API failed, trying backup:', primaryErr);
|
||||
|
||||
try {
|
||||
// Fall back to secondary source
|
||||
const data = await fetchFromBackupAPI();
|
||||
return c.json({ data, source: 'backup' });
|
||||
} catch (backupErr) {
|
||||
console.error('Both APIs failed:', backupErr);
|
||||
|
||||
// Return cached data if available
|
||||
const cached = getCachedData();
|
||||
if (cached) {
|
||||
return c.json({ data: cached, source: 'cache' });
|
||||
}
|
||||
|
||||
throw new HTTPException(503, {
|
||||
message: 'Service temporarily unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Retry Logic
|
||||
|
||||
```typescript
|
||||
async function retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 3,
|
||||
delay = 1000
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err) {
|
||||
if (attempt === maxRetries) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Retry logic failed');
|
||||
}
|
||||
|
||||
app.get('/external-data', async (c) => {
|
||||
try {
|
||||
const data = await retryOperation(() => fetchExternalAPI());
|
||||
return c.json({ data });
|
||||
} catch (err) {
|
||||
throw new HTTPException(503, {
|
||||
message: 'External service unavailable',
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Circuit Breaker
|
||||
|
||||
```typescript
|
||||
class CircuitBreaker {
|
||||
private failures = 0;
|
||||
private lastFailure = 0;
|
||||
private state: 'closed' | 'open' | 'half-open' = 'closed';
|
||||
|
||||
constructor(
|
||||
private threshold = 5,
|
||||
private timeout = 60000 // 1 minute
|
||||
) {}
|
||||
|
||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (this.state === 'open') {
|
||||
if (Date.now() - this.lastFailure > this.timeout) {
|
||||
this.state = 'half-open';
|
||||
} else {
|
||||
throw new Error('Circuit breaker is open');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
if (this.state === 'half-open') {
|
||||
this.state = 'closed';
|
||||
this.failures = 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.failures++;
|
||||
this.lastFailure = Date.now();
|
||||
|
||||
if (this.failures >= this.threshold) {
|
||||
this.state = 'open';
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const apiCircuitBreaker = new CircuitBreaker();
|
||||
|
||||
app.get('/api/data', async (c) => {
|
||||
try {
|
||||
const data = await apiCircuitBreaker.execute(() => fetchExternalAPI());
|
||||
return c.json({ data });
|
||||
} catch (err) {
|
||||
if (err.message === 'Circuit breaker is open') {
|
||||
throw new HTTPException(503, {
|
||||
message: 'Service temporarily unavailable',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Database Error Handling
|
||||
|
||||
### SQLite Errors
|
||||
|
||||
```typescript
|
||||
app.post('/users', async (c) => {
|
||||
const { email, name } = await c.req.json();
|
||||
const db = c.get('db');
|
||||
|
||||
try {
|
||||
const user = db.query(
|
||||
'INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *'
|
||||
).get(crypto.randomUUID(), email, name);
|
||||
|
||||
return c.json({ user }, 201);
|
||||
} catch (err: any) {
|
||||
// SQLite unique constraint violation
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new ConflictError('Email already registered', { email });
|
||||
}
|
||||
|
||||
// SQLite foreign key constraint
|
||||
if (err.message.includes('FOREIGN KEY constraint failed')) {
|
||||
throw new ValidationError('Invalid reference');
|
||||
}
|
||||
|
||||
// Generic database error
|
||||
console.error('Database error:', err);
|
||||
throw new HTTPException(500, { message: 'Database error' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Transaction Rollback
|
||||
|
||||
```typescript
|
||||
app.post('/transfer', async (c) => {
|
||||
const { fromId, toId, amount } = await c.req.json();
|
||||
const db = c.get('db');
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
// Deduct from sender
|
||||
const sender = db.query(
|
||||
'UPDATE accounts SET balance = balance - ? WHERE id = ? RETURNING balance'
|
||||
).get(amount, fromId);
|
||||
|
||||
if (!sender || sender.balance < 0) {
|
||||
throw new ValidationError('Insufficient funds');
|
||||
}
|
||||
|
||||
// Add to recipient
|
||||
db.query(
|
||||
'UPDATE accounts SET balance = balance + ? WHERE id = ?'
|
||||
).run(amount, toId);
|
||||
})();
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
console.error('Transfer failed:', err);
|
||||
throw new HTTPException(500, { message: 'Transfer failed' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Async Error Handling
|
||||
|
||||
### Promise Rejection
|
||||
|
||||
```typescript
|
||||
// ❌ Unhandled promise rejection
|
||||
app.get('/data', (c) => {
|
||||
fetchData().then(data => {
|
||||
// This won't work — response already sent
|
||||
return c.json({ data });
|
||||
});
|
||||
|
||||
return c.json({ loading: true }); // Wrong!
|
||||
});
|
||||
|
||||
// ✅ Await async operations
|
||||
app.get('/data', async (c) => {
|
||||
const data = await fetchData();
|
||||
return c.json({ data });
|
||||
});
|
||||
|
||||
// ✅ Explicit error handling
|
||||
app.get('/data', async (c) => {
|
||||
try {
|
||||
const data = await fetchData();
|
||||
return c.json({ data });
|
||||
} catch (err) {
|
||||
throw new HTTPException(500, { message: 'Failed to fetch data' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Parallel Operations
|
||||
|
||||
```typescript
|
||||
app.get('/dashboard', async (c) => {
|
||||
try {
|
||||
const [user, posts, stats] = await Promise.all([
|
||||
fetchUser(c.get('user').id),
|
||||
fetchUserPosts(c.get('user').id),
|
||||
fetchUserStats(c.get('user').id),
|
||||
]);
|
||||
|
||||
return c.json({ user, posts, stats });
|
||||
} catch (err) {
|
||||
console.error('Dashboard fetch failed:', err);
|
||||
throw new HTTPException(500, { message: 'Failed to load dashboard' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Timeout Handling
|
||||
|
||||
```typescript
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Operation timed out')), timeoutMs)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
app.get('/slow-api', async (c) => {
|
||||
try {
|
||||
const data = await withTimeout(fetchSlowAPI(), 5000); // 5s timeout
|
||||
return c.json({ data });
|
||||
} catch (err) {
|
||||
if (err.message === 'Operation timed out') {
|
||||
throw new HTTPException(504, { message: 'Gateway timeout' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Error Response Format
|
||||
|
||||
### Consistent Structure
|
||||
|
||||
```typescript
|
||||
interface ErrorResponse {
|
||||
error: string;
|
||||
details?: Record<string, any>;
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
app.onError((err, c) => {
|
||||
const response: ErrorResponse = {
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: c.get('requestId'),
|
||||
};
|
||||
|
||||
if (err instanceof HTTPException && err.cause) {
|
||||
response.details = err.cause;
|
||||
}
|
||||
|
||||
const status = err instanceof HTTPException ? err.status : 500;
|
||||
|
||||
return c.json(response, status);
|
||||
});
|
||||
```
|
||||
|
||||
### API-Specific Formats
|
||||
|
||||
```typescript
|
||||
// JSON:API format
|
||||
app.onError((err, c) => {
|
||||
return c.json({
|
||||
errors: [{
|
||||
status: err instanceof HTTPException ? err.status.toString() : '500',
|
||||
title: err.message,
|
||||
detail: err instanceof HTTPException ? err.cause : undefined,
|
||||
}]
|
||||
}, err instanceof HTTPException ? err.status : 500);
|
||||
});
|
||||
|
||||
// RFC 7807 Problem Details
|
||||
app.onError((err, c) => {
|
||||
return c.json({
|
||||
type: 'about:blank',
|
||||
title: err.message,
|
||||
status: err instanceof HTTPException ? err.status : 500,
|
||||
detail: err instanceof HTTPException ? JSON.stringify(err.cause) : undefined,
|
||||
instance: c.req.path,
|
||||
}, err instanceof HTTPException ? err.status : 500);
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,699 @@
|
||||
# Factory Pattern — Context Typing
|
||||
|
||||
`createFactory<Env>()` provides type-safe context variables across middleware and routes.
|
||||
|
||||
## Environment Definition
|
||||
|
||||
```typescript
|
||||
import { createFactory } from 'hono/factory';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user' | 'guest';
|
||||
};
|
||||
requestId: string;
|
||||
db: Database;
|
||||
session: {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
};
|
||||
Bindings: {
|
||||
// Cloudflare Workers bindings (if deploying to CF)
|
||||
DB: D1Database;
|
||||
BUCKET: R2Bucket;
|
||||
API_KEY: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const factory = createFactory<Env>();
|
||||
```
|
||||
|
||||
## Typed Middleware
|
||||
|
||||
### Basic Middleware
|
||||
|
||||
```typescript
|
||||
// Request ID middleware
|
||||
export const requestIdMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const requestId = c.req.header('x-request-id') || crypto.randomUUID();
|
||||
c.set('requestId', requestId);
|
||||
|
||||
await next();
|
||||
|
||||
// Add to response
|
||||
c.res.headers.set('x-request-id', requestId);
|
||||
});
|
||||
|
||||
// Database middleware
|
||||
export const dbMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const db = new Database('app.db');
|
||||
c.set('db', db);
|
||||
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
db.close(); // Cleanup
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Authentication Middleware
|
||||
|
||||
```typescript
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export const authMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const token = c.req.header('authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: 'Missing authorization token' });
|
||||
}
|
||||
|
||||
// Verify token (simplified)
|
||||
const payload = await verifyJWT(token);
|
||||
|
||||
if (!payload) {
|
||||
throw new HTTPException(401, { message: 'Invalid token' });
|
||||
}
|
||||
|
||||
const db = c.get('db');
|
||||
const user = db.query('SELECT * FROM users WHERE id = ?').get(payload.userId);
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'User not found' });
|
||||
}
|
||||
|
||||
c.set('user', {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// Optional auth — doesn't throw if no token
|
||||
export const optionalAuthMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const token = c.req.header('authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const payload = await verifyJWT(token);
|
||||
const db = c.get('db');
|
||||
const user = db.query('SELECT * FROM users WHERE id = ?').get(payload.userId);
|
||||
|
||||
if (user) {
|
||||
c.set('user', {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid tokens for optional auth
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
### Authorization Middleware
|
||||
|
||||
```typescript
|
||||
type Role = 'admin' | 'user' | 'guest';
|
||||
|
||||
export const requireRole = (requiredRole: Role) => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Admin has access to everything
|
||||
if (user.role === 'admin') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check role hierarchy
|
||||
const roleHierarchy: Record<Role, number> = {
|
||||
guest: 0,
|
||||
user: 1,
|
||||
admin: 2,
|
||||
};
|
||||
|
||||
if (roleHierarchy[user.role] < roleHierarchy[requiredRole]) {
|
||||
throw new HTTPException(403, {
|
||||
message: `${requiredRole} access required`,
|
||||
});
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
};
|
||||
|
||||
// Resource ownership check
|
||||
export const requireOwnership = (resourceKey: 'userId' | 'authorId' = 'userId') => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Admin bypasses ownership check
|
||||
if (user.role === 'admin') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get resource ID from path params
|
||||
const resourceUserId = c.req.param(resourceKey);
|
||||
|
||||
if (user.id !== resourceUserId) {
|
||||
throw new HTTPException(403, { message: 'Access denied' });
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Session Middleware
|
||||
|
||||
```typescript
|
||||
export const sessionMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const sessionId = c.req.header('x-session-id');
|
||||
|
||||
if (!sessionId) {
|
||||
throw new HTTPException(401, { message: 'Missing session' });
|
||||
}
|
||||
|
||||
const db = c.get('db');
|
||||
const session = db.query(
|
||||
'SELECT * FROM sessions WHERE id = ? AND expires_at > CURRENT_TIMESTAMP'
|
||||
).get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new HTTPException(401, { message: 'Invalid or expired session' });
|
||||
}
|
||||
|
||||
c.set('session', {
|
||||
id: session.id,
|
||||
expiresAt: new Date(session.expires_at),
|
||||
});
|
||||
|
||||
// Extend session on activity
|
||||
db.run(
|
||||
'UPDATE sessions SET expires_at = datetime(CURRENT_TIMESTAMP, "+1 hour") WHERE id = ?',
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
## Typed Handlers
|
||||
|
||||
### Basic Handlers
|
||||
|
||||
```typescript
|
||||
// Single handler
|
||||
const getProfile = factory.createHandlers((c) => {
|
||||
const user = c.get('user'); // Fully typed!
|
||||
const requestId = c.get('requestId');
|
||||
|
||||
return c.json({
|
||||
user,
|
||||
requestId,
|
||||
});
|
||||
});
|
||||
|
||||
// Multiple handlers (middleware + handler)
|
||||
const getUsers = factory.createHandlers(
|
||||
// Middleware
|
||||
async (c, next) => {
|
||||
console.log('Fetching users...');
|
||||
await next();
|
||||
},
|
||||
// Handler
|
||||
async (c) => {
|
||||
const db = c.get('db');
|
||||
const users = db.query('SELECT id, email, role FROM users').all();
|
||||
|
||||
return c.json({ users });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Handlers with Validation
|
||||
|
||||
```typescript
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
|
||||
const UpdateProfileSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
bio: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
const updateProfile = factory.createHandlers(
|
||||
zValidator('json', UpdateProfileSchema),
|
||||
async (c) => {
|
||||
const user = c.get('user');
|
||||
const data = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
|
||||
const updated = db.query(`
|
||||
UPDATE users
|
||||
SET name = COALESCE(?, name),
|
||||
bio = COALESCE(?, bio)
|
||||
WHERE id = ?
|
||||
RETURNING *
|
||||
`).get(data.name || null, data.bio || null, user.id);
|
||||
|
||||
return c.json({ user: updated });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## App Assembly
|
||||
|
||||
### Simple App
|
||||
|
||||
```typescript
|
||||
const app = factory.createApp()
|
||||
// Global middleware
|
||||
.use('*', requestIdMiddleware)
|
||||
.use('*', dbMiddleware)
|
||||
|
||||
// Public routes
|
||||
.get('/health', (c) => c.json({ status: 'ok' }))
|
||||
.post('/auth/login', loginHandler)
|
||||
|
||||
// Protected routes
|
||||
.use('/api/*', authMiddleware)
|
||||
.get('/api/profile', ...getProfile)
|
||||
.put('/api/profile', ...updateProfile)
|
||||
|
||||
// Admin routes
|
||||
.use('/api/admin/*', requireRole('admin'))
|
||||
.get('/api/admin/users', ...getUsers);
|
||||
|
||||
export type AppType = typeof app;
|
||||
export default app;
|
||||
```
|
||||
|
||||
### Multi-Module App
|
||||
|
||||
```typescript
|
||||
// routes/users.ts
|
||||
import { factory } from '../factory';
|
||||
import { requireRole } from '../middleware/auth';
|
||||
|
||||
export const usersRoute = factory.createApp()
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('db');
|
||||
const users = db.query('SELECT id, email, role FROM users').all();
|
||||
return c.json({ users });
|
||||
})
|
||||
.get('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const user = db.query('SELECT id, email, role FROM users WHERE id = ?')
|
||||
.get(c.req.param('id'));
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ user });
|
||||
})
|
||||
.use(requireRole('admin')) // Admin-only routes below
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const user = db.query('DELETE FROM users WHERE id = ? RETURNING *')
|
||||
.get(c.req.param('id'));
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ deleted: true, user });
|
||||
});
|
||||
|
||||
// routes/posts.ts
|
||||
import { factory } from '../factory';
|
||||
|
||||
const CreatePostSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
|
||||
export const postsRoute = factory.createApp()
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('db');
|
||||
const posts = db.query('SELECT * FROM posts ORDER BY created_at DESC').all();
|
||||
return c.json({ posts });
|
||||
})
|
||||
.post('/', zValidator('json', CreatePostSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
const data = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
|
||||
const post = db.query(`
|
||||
INSERT INTO posts (id, user_id, title, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *
|
||||
`).get(crypto.randomUUID(), user.id, data.title, data.content);
|
||||
|
||||
return c.json({ post }, 201);
|
||||
})
|
||||
.get('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const post = db.query('SELECT * FROM posts WHERE id = ?')
|
||||
.get(c.req.param('id'));
|
||||
|
||||
if (!post) {
|
||||
throw new HTTPException(404, { message: 'Post not found' });
|
||||
}
|
||||
|
||||
return c.json({ post });
|
||||
});
|
||||
|
||||
// index.ts
|
||||
import { factory } from './factory';
|
||||
import { usersRoute } from './routes/users';
|
||||
import { postsRoute } from './routes/posts';
|
||||
|
||||
const app = factory.createApp()
|
||||
.use('*', requestIdMiddleware)
|
||||
.use('*', dbMiddleware)
|
||||
|
||||
// Mount routes
|
||||
.route('/users', usersRoute)
|
||||
.route('/posts', postsRoute);
|
||||
|
||||
export type AppType = typeof app;
|
||||
export default app;
|
||||
```
|
||||
|
||||
## Type Propagation
|
||||
|
||||
### Extending Environment
|
||||
|
||||
```typescript
|
||||
// base-env.ts
|
||||
export type BaseEnv = {
|
||||
Variables: {
|
||||
requestId: string;
|
||||
db: Database;
|
||||
};
|
||||
};
|
||||
|
||||
// auth-env.ts
|
||||
import type { BaseEnv } from './base-env';
|
||||
|
||||
export type AuthEnv = BaseEnv & {
|
||||
Variables: BaseEnv['Variables'] & {
|
||||
user: {
|
||||
id: string;
|
||||
role: 'admin' | 'user';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Usage
|
||||
const authFactory = createFactory<AuthEnv>();
|
||||
|
||||
export const authRoute = authFactory.createApp()
|
||||
.get('/profile', (c) => {
|
||||
const user = c.get('user'); // Typed!
|
||||
const requestId = c.get('requestId'); // Also typed!
|
||||
const db = c.get('db'); // Also typed!
|
||||
|
||||
return c.json({ user, requestId });
|
||||
});
|
||||
```
|
||||
|
||||
### Merging Environments
|
||||
|
||||
```typescript
|
||||
type Env1 = {
|
||||
Variables: {
|
||||
foo: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Env2 = {
|
||||
Variables: {
|
||||
bar: number;
|
||||
};
|
||||
};
|
||||
|
||||
type MergedEnv = {
|
||||
Variables: Env1['Variables'] & Env2['Variables'];
|
||||
};
|
||||
|
||||
const factory = createFactory<MergedEnv>();
|
||||
|
||||
const app = factory.createApp()
|
||||
.get('/test', (c) => {
|
||||
const foo = c.get('foo'); // string
|
||||
const bar = c.get('bar'); // number
|
||||
|
||||
return c.json({ foo, bar });
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Conditional Middleware
|
||||
|
||||
```typescript
|
||||
export const conditionalAuth = (condition: (c: Context) => boolean) => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
if (condition(c)) {
|
||||
// Apply auth
|
||||
await authMiddleware(c, next);
|
||||
} else {
|
||||
// Skip auth
|
||||
await next();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
const app = factory.createApp()
|
||||
.use('/api/*', conditionalAuth((c) => {
|
||||
// Skip auth for health checks
|
||||
return c.req.path !== '/api/health';
|
||||
}))
|
||||
.get('/api/health', (c) => c.json({ status: 'ok' }))
|
||||
.get('/api/profile', (c) => {
|
||||
const user = c.get('user'); // May be undefined
|
||||
return c.json({ user });
|
||||
});
|
||||
```
|
||||
|
||||
### Middleware Composition
|
||||
|
||||
```typescript
|
||||
const composeMiddleware = (...middlewares: MiddlewareHandler[]) => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
const execute = async (index: number) => {
|
||||
if (index >= middlewares.length) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
await middlewares[index](c, async () => {
|
||||
await execute(index + 1);
|
||||
});
|
||||
};
|
||||
|
||||
await execute(0);
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
const app = factory.createApp()
|
||||
.use('/api/*', composeMiddleware(
|
||||
requestIdMiddleware,
|
||||
dbMiddleware,
|
||||
authMiddleware
|
||||
))
|
||||
.get('/api/profile', (c) => {
|
||||
// All middleware ran
|
||||
const requestId = c.get('requestId');
|
||||
const db = c.get('db');
|
||||
const user = c.get('user');
|
||||
|
||||
return c.json({ user, requestId });
|
||||
});
|
||||
```
|
||||
|
||||
### Scoped Factories
|
||||
|
||||
```typescript
|
||||
// Public routes — no auth
|
||||
const publicFactory = createFactory<{
|
||||
Variables: {
|
||||
requestId: string;
|
||||
db: Database;
|
||||
};
|
||||
}>();
|
||||
|
||||
export const publicRoute = publicFactory.createApp()
|
||||
.get('/status', (c) => {
|
||||
// No user available here
|
||||
return c.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// Protected routes — auth required
|
||||
const protectedFactory = createFactory<{
|
||||
Variables: {
|
||||
requestId: string;
|
||||
db: Database;
|
||||
user: { id: string; role: string };
|
||||
};
|
||||
}>();
|
||||
|
||||
export const protectedRoute = protectedFactory.createApp()
|
||||
.get('/profile', (c) => {
|
||||
const user = c.get('user'); // Always available!
|
||||
return c.json({ user });
|
||||
});
|
||||
|
||||
// Combine
|
||||
const app = factory.createApp()
|
||||
.use('*', requestIdMiddleware)
|
||||
.use('*', dbMiddleware)
|
||||
.route('/public', publicRoute)
|
||||
.use('/protected/*', authMiddleware)
|
||||
.route('/protected', protectedRoute);
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
```typescript
|
||||
interface IDatabase {
|
||||
query(sql: string): any;
|
||||
}
|
||||
|
||||
interface ICache {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
db: IDatabase;
|
||||
cache: ICache;
|
||||
user: { id: string };
|
||||
};
|
||||
};
|
||||
|
||||
const factory = createFactory<Env>();
|
||||
|
||||
// Inject dependencies
|
||||
const createApp = (db: IDatabase, cache: ICache) => {
|
||||
return factory.createApp()
|
||||
.use('*', async (c, next) => {
|
||||
c.set('db', db);
|
||||
c.set('cache', cache);
|
||||
await next();
|
||||
})
|
||||
.get('/users/:id', async (c) => {
|
||||
const cache = c.get('cache');
|
||||
const db = c.get('db');
|
||||
const id = c.req.param('id');
|
||||
|
||||
// Try cache first
|
||||
const cached = await cache.get(`user:${id}`);
|
||||
if (cached) {
|
||||
return c.json(JSON.parse(cached));
|
||||
}
|
||||
|
||||
// Fetch from DB
|
||||
const user = db.query('SELECT * FROM users WHERE id = ?').get(id);
|
||||
|
||||
// Cache result
|
||||
await cache.set(`user:${id}`, JSON.stringify(user));
|
||||
|
||||
return c.json({ user });
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
const db = new Database('app.db');
|
||||
const cache = new RedisClient();
|
||||
|
||||
const app = createApp(db, cache);
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong: Variables set but not in type
|
||||
type Env = {
|
||||
Variables: {
|
||||
user: { id: string };
|
||||
};
|
||||
};
|
||||
|
||||
const factory = createFactory<Env>();
|
||||
|
||||
const app = factory.createApp()
|
||||
.use('*', async (c, next) => {
|
||||
c.set('requestId', crypto.randomUUID()); // Type error!
|
||||
await next();
|
||||
});
|
||||
|
||||
|
||||
// ✅ Correct: Include all variables in type
|
||||
type Env = {
|
||||
Variables: {
|
||||
user: { id: string };
|
||||
requestId: string; // Added!
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// ❌ Wrong: Using base Hono with factory
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const app = new Hono() // Lost types!
|
||||
.use(authMiddleware) // Middleware expects typed context
|
||||
.get('/profile', (c) => {
|
||||
const user = c.get('user'); // Type error!
|
||||
});
|
||||
|
||||
|
||||
// ✅ Correct: Use factory.createApp()
|
||||
const app = factory.createApp()
|
||||
.use(authMiddleware)
|
||||
.get('/profile', (c) => {
|
||||
const user = c.get('user'); // Fully typed!
|
||||
});
|
||||
|
||||
|
||||
// ❌ Wrong: Middleware doesn't use factory
|
||||
const authMiddleware = async (c: Context, next: Next) => {
|
||||
c.set('user', { id: '123' }); // Lost types!
|
||||
await next();
|
||||
};
|
||||
|
||||
|
||||
// ✅ Correct: Use factory.createMiddleware
|
||||
const authMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
c.set('user', { id: '123' }); // Typed!
|
||||
await next();
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,457 @@
|
||||
# Middleware Patterns
|
||||
|
||||
Common middleware patterns for Hono APIs.
|
||||
|
||||
## Built-in Middleware
|
||||
|
||||
### Logger
|
||||
|
||||
```typescript
|
||||
import { logger } from 'hono/logger';
|
||||
|
||||
app.use('*', logger());
|
||||
|
||||
// Custom log function
|
||||
app.use('*', logger((message) => {
|
||||
console.log(`[${new Date().toISOString()}] ${message}`);
|
||||
}));
|
||||
```
|
||||
|
||||
### CORS
|
||||
|
||||
```typescript
|
||||
import { cors } from 'hono/cors';
|
||||
|
||||
// Basic CORS
|
||||
app.use('/api/*', cors());
|
||||
|
||||
// Configured CORS
|
||||
app.use('/api/*', cors({
|
||||
origin: ['http://localhost:3000', 'https://example.com'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
maxAge: 86400, // 24 hours
|
||||
}));
|
||||
|
||||
// Dynamic origin
|
||||
app.use('/api/*', cors({
|
||||
origin: (origin) => {
|
||||
if (origin.endsWith('.example.com')) {
|
||||
return origin;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Compress
|
||||
|
||||
```typescript
|
||||
import { compress } from 'hono/compress';
|
||||
|
||||
app.use('*', compress());
|
||||
```
|
||||
|
||||
### Secure Headers
|
||||
|
||||
```typescript
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
|
||||
app.use('*', secureHeaders());
|
||||
```
|
||||
|
||||
### Bearer Auth
|
||||
|
||||
```typescript
|
||||
import { bearerAuth } from 'hono/bearer-auth';
|
||||
|
||||
app.use('/api/*', bearerAuth({
|
||||
token: Bun.env.API_TOKEN!,
|
||||
}));
|
||||
|
||||
// Multiple tokens
|
||||
app.use('/api/*', bearerAuth({
|
||||
token: [Bun.env.API_TOKEN!, Bun.env.ADMIN_TOKEN!],
|
||||
}));
|
||||
|
||||
// Custom verification
|
||||
app.use('/api/*', bearerAuth({
|
||||
verifyToken: async (token, c) => {
|
||||
const user = await verifyJWT(token);
|
||||
if (user) {
|
||||
c.set('user', user);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Basic Auth
|
||||
|
||||
```typescript
|
||||
import { basicAuth } from 'hono/basic-auth';
|
||||
|
||||
app.use('/admin/*', basicAuth({
|
||||
username: 'admin',
|
||||
password: Bun.env.ADMIN_PASSWORD!,
|
||||
}));
|
||||
```
|
||||
|
||||
## Custom Middleware with Factory
|
||||
|
||||
### Authentication
|
||||
|
||||
```typescript
|
||||
import { createFactory } from 'hono/factory';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
user: { id: string; email: string; role: 'admin' | 'user' };
|
||||
};
|
||||
};
|
||||
|
||||
const factory = createFactory<Env>();
|
||||
|
||||
export const authMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const token = c.req.header('authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: 'Missing authorization token' });
|
||||
}
|
||||
|
||||
const payload = await verifyJWT(token);
|
||||
|
||||
if (!payload) {
|
||||
throw new HTTPException(401, { message: 'Invalid token' });
|
||||
}
|
||||
|
||||
c.set('user', {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
});
|
||||
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
### Optional Authentication
|
||||
|
||||
```typescript
|
||||
export const optionalAuth = factory.createMiddleware(async (c, next) => {
|
||||
const token = c.req.header('authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const payload = await verifyJWT(token);
|
||||
if (payload) {
|
||||
c.set('user', {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Invalid token, continue without user
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
### Role-Based Access Control
|
||||
|
||||
```typescript
|
||||
export const requireRole = (requiredRole: 'admin' | 'user') => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Admin has access to everything
|
||||
if (user.role === 'admin') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role !== requiredRole) {
|
||||
throw new HTTPException(403, { message: `${requiredRole} access required` });
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
app.use('/api/admin/*', requireRole('admin'));
|
||||
```
|
||||
|
||||
### Resource Ownership
|
||||
|
||||
```typescript
|
||||
export const requireOwnership = (paramName = 'userId') => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Admin bypasses ownership check
|
||||
if (user.role === 'admin') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceUserId = c.req.param(paramName);
|
||||
|
||||
if (user.id !== resourceUserId) {
|
||||
throw new HTTPException(403, { message: 'Access denied' });
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
app.delete('/users/:userId', requireOwnership('userId'), deleteUser);
|
||||
```
|
||||
|
||||
### Request ID
|
||||
|
||||
```typescript
|
||||
export const requestIdMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const requestId = c.req.header('x-request-id') || crypto.randomUUID();
|
||||
c.set('requestId', requestId);
|
||||
|
||||
await next();
|
||||
|
||||
c.res.headers.set('x-request-id', requestId);
|
||||
});
|
||||
```
|
||||
|
||||
### Request Timing
|
||||
|
||||
```typescript
|
||||
export const timingMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const start = Bun.nanoseconds();
|
||||
|
||||
await next();
|
||||
|
||||
const duration = (Bun.nanoseconds() - start) / 1_000_000;
|
||||
c.res.headers.set('x-response-time', `${duration.toFixed(2)}ms`);
|
||||
|
||||
console.log(`${c.req.method} ${c.req.path} - ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```typescript
|
||||
const rateLimits = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
export const rateLimiter = (limit: number, windowMs: number) => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
const ip = c.req.header('x-forwarded-for') || 'unknown';
|
||||
const now = Date.now();
|
||||
|
||||
const entry = rateLimits.get(ip);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimits.set(ip, { count: 1, resetAt: now + windowMs });
|
||||
} else {
|
||||
entry.count++;
|
||||
|
||||
if (entry.count > limit) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
throw new HTTPException(429, {
|
||||
message: 'Rate limit exceeded',
|
||||
cause: { retryAfter },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
};
|
||||
|
||||
// Usage: 100 requests per minute
|
||||
app.use('/api/*', rateLimiter(100, 60 * 1000));
|
||||
```
|
||||
|
||||
### Database Connection
|
||||
|
||||
```typescript
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
export const dbMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const db = new Database('app.db');
|
||||
c.set('db', db);
|
||||
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
// With connection pooling
|
||||
class DatabasePool {
|
||||
private pool: Database[] = [];
|
||||
|
||||
get(): Database {
|
||||
return this.pool.pop() || new Database('app.db');
|
||||
}
|
||||
|
||||
release(db: Database) {
|
||||
this.pool.push(db);
|
||||
}
|
||||
}
|
||||
|
||||
const pool = new DatabasePool();
|
||||
|
||||
export const pooledDbMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const db = pool.get();
|
||||
c.set('db', db);
|
||||
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
pool.release(db);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
```typescript
|
||||
const cache = new Map<string, { data: any; expiresAt: number }>();
|
||||
|
||||
export const cacheMiddleware = (ttlMs: number) => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
if (c.req.method !== 'GET') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = c.req.url;
|
||||
const cached = cache.get(key);
|
||||
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return c.json(cached.data);
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
// Cache response after handler
|
||||
const response = c.res.clone();
|
||||
const data = await response.json();
|
||||
|
||||
cache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Usage: 5 minute cache
|
||||
app.get('/api/public-data', cacheMiddleware(5 * 60 * 1000), handler);
|
||||
```
|
||||
|
||||
### Request Validation
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
export const validateRequest = <T extends z.ZodType>(schema: T) => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
schema.parse(body);
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
throw new HTTPException(400, {
|
||||
message: 'Validation failed',
|
||||
cause: err.issues,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Middleware Composition
|
||||
|
||||
```typescript
|
||||
// Compose multiple middleware
|
||||
const apiMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
// Request ID
|
||||
c.set('requestId', crypto.randomUUID());
|
||||
|
||||
// Timing start
|
||||
const start = Bun.nanoseconds();
|
||||
|
||||
await next();
|
||||
|
||||
// Timing end
|
||||
const duration = (Bun.nanoseconds() - start) / 1_000_000;
|
||||
c.res.headers.set('x-request-id', c.get('requestId'));
|
||||
c.res.headers.set('x-response-time', `${duration.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
// Apply composed middleware
|
||||
app.use('/api/*', apiMiddleware);
|
||||
```
|
||||
|
||||
## Conditional Middleware
|
||||
|
||||
```typescript
|
||||
export const conditionalAuth = (condition: (c: Context) => boolean) => {
|
||||
return factory.createMiddleware(async (c, next) => {
|
||||
if (condition(c)) {
|
||||
await authMiddleware(c, next);
|
||||
} else {
|
||||
await next();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Skip auth for health checks
|
||||
app.use('/api/*', conditionalAuth((c) => c.req.path !== '/api/health'));
|
||||
```
|
||||
|
||||
## Middleware Order
|
||||
|
||||
```typescript
|
||||
const app = factory.createApp()
|
||||
// Global middleware (runs for all routes)
|
||||
.use('*', logger())
|
||||
.use('*', requestIdMiddleware)
|
||||
.use('*', timingMiddleware)
|
||||
|
||||
// API middleware
|
||||
.use('/api/*', cors())
|
||||
.use('/api/*', dbMiddleware)
|
||||
|
||||
// Public routes (before auth middleware)
|
||||
.get('/api/health', (c) => c.json({ status: 'ok' }))
|
||||
.post('/api/auth/login', loginHandler)
|
||||
|
||||
// Protected routes
|
||||
.use('/api/*', authMiddleware)
|
||||
.get('/api/profile', profileHandler)
|
||||
.get('/api/users', usersHandler)
|
||||
|
||||
// Admin routes
|
||||
.use('/api/admin/*', requireRole('admin'))
|
||||
.get('/api/admin/stats', statsHandler);
|
||||
```
|
||||
@@ -0,0 +1,839 @@
|
||||
# Zod OpenAPI Integration
|
||||
|
||||
Schema-first API development with automatic OpenAPI specification generation.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @hono/zod-openapi
|
||||
bun add @hono/swagger-ui
|
||||
```
|
||||
|
||||
## Basic Setup
|
||||
|
||||
```typescript
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import { swaggerUI } from '@hono/swagger-ui';
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
|
||||
// Define routes (see below)
|
||||
|
||||
// Generate OpenAPI spec
|
||||
app.doc('/openapi.json', {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'My API',
|
||||
version: '1.0.0',
|
||||
description: 'API documentation',
|
||||
},
|
||||
servers: [
|
||||
{ url: 'http://localhost:3000', description: 'Development' },
|
||||
{ url: 'https://api.example.com', description: 'Production' },
|
||||
],
|
||||
});
|
||||
|
||||
// Swagger UI
|
||||
app.get('/docs', swaggerUI({ url: '/openapi.json' }));
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
## Schema Definition
|
||||
|
||||
### Basic Schemas
|
||||
|
||||
```typescript
|
||||
// Register schemas for reuse
|
||||
const UserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(100),
|
||||
role: z.enum(['admin', 'user', 'guest']),
|
||||
createdAt: z.string().datetime(),
|
||||
}).openapi('User'); // Register with name
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(100),
|
||||
password: z.string().min(8),
|
||||
}).openapi('CreateUser');
|
||||
|
||||
const UpdateUserSchema = CreateUserSchema.partial().openapi('UpdateUser');
|
||||
|
||||
const ErrorSchema = z.object({
|
||||
error: z.string(),
|
||||
details: z.record(z.any()).optional(),
|
||||
}).openapi('Error');
|
||||
```
|
||||
|
||||
### Schema with Examples
|
||||
|
||||
```typescript
|
||||
const ProductSchema = z.object({
|
||||
id: z.string().uuid().openapi({
|
||||
example: '123e4567-e89b-12d3-a456-426614174000',
|
||||
}),
|
||||
name: z.string().min(1).max(200).openapi({
|
||||
example: 'Laptop',
|
||||
}),
|
||||
price: z.number().positive().openapi({
|
||||
example: 999.99,
|
||||
}),
|
||||
category: z.enum(['electronics', 'clothing', 'books']).openapi({
|
||||
example: 'electronics',
|
||||
}),
|
||||
tags: z.array(z.string()).optional().openapi({
|
||||
example: ['gaming', 'portable'],
|
||||
}),
|
||||
}).openapi('Product');
|
||||
```
|
||||
|
||||
### Schema with Descriptions
|
||||
|
||||
```typescript
|
||||
const PostSchema = z.object({
|
||||
id: z.string().uuid().describe('Unique post identifier'),
|
||||
title: z.string().min(1).max(200).describe('Post title'),
|
||||
content: z.string().min(1).describe('Post content (markdown supported)'),
|
||||
published: z.boolean().default(false).describe('Publication status'),
|
||||
author: UserSchema.describe('Post author'),
|
||||
tags: z.array(z.string()).optional().describe('Post tags for categorization'),
|
||||
createdAt: z.string().datetime().describe('Creation timestamp'),
|
||||
updatedAt: z.string().datetime().describe('Last update timestamp'),
|
||||
}).openapi('Post');
|
||||
```
|
||||
|
||||
## Route Definition
|
||||
|
||||
### GET Route
|
||||
|
||||
```typescript
|
||||
const getUserRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/users/{id}',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().uuid().openapi({
|
||||
param: { name: 'id', in: 'path' },
|
||||
example: '123e4567-e89b-12d3-a456-426614174000',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': { schema: UserSchema },
|
||||
},
|
||||
description: 'User found',
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
'application/json': { schema: ErrorSchema },
|
||||
},
|
||||
description: 'User not found',
|
||||
},
|
||||
},
|
||||
tags: ['Users'],
|
||||
summary: 'Get user by ID',
|
||||
description: 'Retrieves a single user by their UUID',
|
||||
});
|
||||
|
||||
app.openapi(getUserRoute, (c) => {
|
||||
const { id } = c.req.valid('param'); // Typed!
|
||||
|
||||
const user = db.query('SELECT * FROM users WHERE id = ?').get(id);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json(user, 200);
|
||||
});
|
||||
```
|
||||
|
||||
### POST Route
|
||||
|
||||
```typescript
|
||||
const createUserRoute = createRoute({
|
||||
method: 'post',
|
||||
path: '/users',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': { schema: CreateUserSchema },
|
||||
},
|
||||
description: 'User data',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
'application/json': { schema: UserSchema },
|
||||
},
|
||||
description: 'User created',
|
||||
},
|
||||
400: {
|
||||
content: {
|
||||
'application/json': { schema: ErrorSchema },
|
||||
},
|
||||
description: 'Validation error',
|
||||
},
|
||||
},
|
||||
tags: ['Users'],
|
||||
summary: 'Create new user',
|
||||
});
|
||||
|
||||
app.openapi(createUserRoute, async (c) => {
|
||||
const data = c.req.valid('json'); // Typed as CreateUserSchema!
|
||||
|
||||
const hashedPassword = await Bun.password.hash(data.password);
|
||||
|
||||
const user = db.query(`
|
||||
INSERT INTO users (id, email, name, password)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, email, name, role, created_at as createdAt
|
||||
`).get(crypto.randomUUID(), data.email, data.name, hashedPassword);
|
||||
|
||||
return c.json(user, 201);
|
||||
});
|
||||
```
|
||||
|
||||
### PUT/PATCH Routes
|
||||
|
||||
```typescript
|
||||
const updateUserRoute = createRoute({
|
||||
method: 'put',
|
||||
path: '/users/{id}',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': { schema: UpdateUserSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': { schema: UserSchema },
|
||||
},
|
||||
description: 'User updated',
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
'application/json': { schema: ErrorSchema },
|
||||
},
|
||||
description: 'User not found',
|
||||
},
|
||||
},
|
||||
tags: ['Users'],
|
||||
});
|
||||
|
||||
app.openapi(updateUserRoute, async (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const user = db.query(`
|
||||
UPDATE users
|
||||
SET email = COALESCE(?, email),
|
||||
name = COALESCE(?, name)
|
||||
WHERE id = ?
|
||||
RETURNING id, email, name, role, created_at as createdAt
|
||||
`).get(data.email || null, data.name || null, id);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json(user, 200);
|
||||
});
|
||||
```
|
||||
|
||||
### DELETE Route
|
||||
|
||||
```typescript
|
||||
const deleteUserRoute = createRoute({
|
||||
method: 'delete',
|
||||
path: '/users/{id}',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
deleted: z.boolean(),
|
||||
user: UserSchema,
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: 'User deleted',
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
'application/json': { schema: ErrorSchema },
|
||||
},
|
||||
description: 'User not found',
|
||||
},
|
||||
},
|
||||
tags: ['Users'],
|
||||
});
|
||||
|
||||
app.openapi(deleteUserRoute, (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
const user = db.query('DELETE FROM users WHERE id = ? RETURNING *').get(id);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json({ deleted: true, user }, 200);
|
||||
});
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
```typescript
|
||||
const PaginationSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
}).openapi('Pagination');
|
||||
|
||||
const listUsersRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/users',
|
||||
request: {
|
||||
query: PaginationSchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
users: z.array(UserSchema),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
totalPages: z.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: 'Users list',
|
||||
},
|
||||
},
|
||||
tags: ['Users'],
|
||||
});
|
||||
|
||||
app.openapi(listUsersRoute, (c) => {
|
||||
const { page, limit } = c.req.valid('query'); // Typed with defaults!
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const users = db.query(
|
||||
'SELECT * FROM users LIMIT ? OFFSET ?'
|
||||
).all(limit, offset);
|
||||
|
||||
const total = db.query('SELECT COUNT(*) as count FROM users')
|
||||
.get() as { count: number };
|
||||
|
||||
return c.json({
|
||||
users,
|
||||
total: total.count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total.count / limit),
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Headers
|
||||
|
||||
```typescript
|
||||
const protectedRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/protected',
|
||||
request: {
|
||||
headers: z.object({
|
||||
authorization: z.string().openapi({
|
||||
example: 'Bearer token123',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ protected: z.boolean() }),
|
||||
},
|
||||
},
|
||||
description: 'Success',
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
'application/json': { schema: ErrorSchema },
|
||||
},
|
||||
description: 'Unauthorized',
|
||||
},
|
||||
},
|
||||
tags: ['Auth'],
|
||||
security: [{ bearerAuth: [] }],
|
||||
});
|
||||
|
||||
app.openapi(protectedRoute, (c) => {
|
||||
const { authorization } = c.req.valid('header');
|
||||
|
||||
// Verify token...
|
||||
|
||||
return c.json({ protected: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Security Schemes
|
||||
|
||||
```typescript
|
||||
app.doc('/openapi.json', {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'My API',
|
||||
version: '1.0.0',
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'X-API-Key',
|
||||
},
|
||||
oauth2: {
|
||||
type: 'oauth2',
|
||||
flows: {
|
||||
authorizationCode: {
|
||||
authorizationUrl: 'https://example.com/oauth/authorize',
|
||||
tokenUrl: 'https://example.com/oauth/token',
|
||||
scopes: {
|
||||
'read:users': 'Read user data',
|
||||
'write:users': 'Create and update users',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Use in routes
|
||||
const secureRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/secure',
|
||||
security: [
|
||||
{ bearerAuth: [] },
|
||||
{ apiKey: [] },
|
||||
],
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Response Types
|
||||
|
||||
### Multiple Content Types
|
||||
|
||||
```typescript
|
||||
const getFileRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/files/{id}',
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
},
|
||||
'application/octet-stream': {
|
||||
schema: z.instanceof(Blob),
|
||||
},
|
||||
},
|
||||
description: 'File metadata or content',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(getFileRoute, (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
const accept = c.req.header('accept');
|
||||
|
||||
const file = findFile(id);
|
||||
|
||||
if (accept?.includes('application/octet-stream')) {
|
||||
return c.body(file.stream());
|
||||
}
|
||||
|
||||
return c.json({ id: file.id, name: file.name, url: file.url });
|
||||
});
|
||||
```
|
||||
|
||||
### Status Code Unions
|
||||
|
||||
```typescript
|
||||
const route = createRoute({
|
||||
method: 'post',
|
||||
path: '/action',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
},
|
||||
description: 'Success',
|
||||
},
|
||||
202: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ accepted: z.literal(true) }),
|
||||
},
|
||||
},
|
||||
description: 'Accepted for processing',
|
||||
},
|
||||
400: {
|
||||
content: {
|
||||
'application/json': { schema: ErrorSchema },
|
||||
},
|
||||
description: 'Bad request',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, async (c) => {
|
||||
const result = await processAction();
|
||||
|
||||
if (result.immediate) {
|
||||
return c.json({ success: true }, 200);
|
||||
}
|
||||
|
||||
return c.json({ accepted: true }, 202);
|
||||
});
|
||||
```
|
||||
|
||||
## Nested Resources
|
||||
|
||||
```typescript
|
||||
const getPostCommentsRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/posts/{postId}/comments',
|
||||
request: {
|
||||
params: z.object({
|
||||
postId: z.string().uuid(),
|
||||
}),
|
||||
query: PaginationSchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
comments: z.array(CommentSchema),
|
||||
total: z.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: 'Comments list',
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
'application/json': { schema: ErrorSchema },
|
||||
},
|
||||
description: 'Post not found',
|
||||
},
|
||||
},
|
||||
tags: ['Comments'],
|
||||
});
|
||||
|
||||
const createCommentRoute = createRoute({
|
||||
method: 'post',
|
||||
path: '/posts/{postId}/comments',
|
||||
request: {
|
||||
params: z.object({
|
||||
postId: z.string().uuid(),
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
'application/json': { schema: CommentSchema },
|
||||
},
|
||||
description: 'Comment created',
|
||||
},
|
||||
},
|
||||
tags: ['Comments'],
|
||||
});
|
||||
```
|
||||
|
||||
## Grouping Routes
|
||||
|
||||
```typescript
|
||||
// Create separate apps for different resources
|
||||
const usersApp = new OpenAPIHono();
|
||||
|
||||
usersApp.openapi(getUserRoute, getUserHandler);
|
||||
usersApp.openapi(createUserRoute, createUserHandler);
|
||||
usersApp.openapi(updateUserRoute, updateUserHandler);
|
||||
usersApp.openapi(deleteUserRoute, deleteUserHandler);
|
||||
|
||||
const postsApp = new OpenAPIHono();
|
||||
|
||||
postsApp.openapi(getPostRoute, getPostHandler);
|
||||
postsApp.openapi(createPostRoute, createPostHandler);
|
||||
|
||||
// Combine
|
||||
const app = new OpenAPIHono()
|
||||
.route('/users', usersApp)
|
||||
.route('/posts', postsApp);
|
||||
|
||||
// Generate combined OpenAPI spec
|
||||
app.doc('/openapi.json', {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Combined API',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Tags and Organization
|
||||
|
||||
```typescript
|
||||
app.doc('/openapi.json', {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'My API',
|
||||
version: '1.0.0',
|
||||
description: 'API with organized endpoints',
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Users',
|
||||
description: 'User management endpoints',
|
||||
},
|
||||
{
|
||||
name: 'Posts',
|
||||
description: 'Blog post endpoints',
|
||||
},
|
||||
{
|
||||
name: 'Comments',
|
||||
description: 'Comment management',
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
description: 'Administrative endpoints',
|
||||
externalDocs: {
|
||||
description: 'Admin guide',
|
||||
url: 'https://docs.example.com/admin',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Validation
|
||||
|
||||
```typescript
|
||||
const EmailSchema = z.string().email().refine(
|
||||
(email) => email.endsWith('@example.com'),
|
||||
{ message: 'Email must be from example.com domain' }
|
||||
).openapi('CompanyEmail');
|
||||
|
||||
const PasswordSchema = z.string().min(8).refine(
|
||||
(password) => {
|
||||
// Complex password requirements
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*]/.test(password);
|
||||
|
||||
return hasUpper && hasLower && hasNumber && hasSpecial;
|
||||
},
|
||||
{ message: 'Password must contain uppercase, lowercase, number, and special character' }
|
||||
).openapi('StrongPassword');
|
||||
```
|
||||
|
||||
## With Factory Pattern
|
||||
|
||||
```typescript
|
||||
import { createFactory } from 'hono/factory';
|
||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
user: { id: string; role: string };
|
||||
db: Database;
|
||||
};
|
||||
};
|
||||
|
||||
// Use OpenAPIHono directly (doesn't support factory.createApp)
|
||||
const app = new OpenAPIHono<Env>();
|
||||
|
||||
// Create middleware with factory
|
||||
const factory = createFactory<Env>();
|
||||
|
||||
const authMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
// Auth logic...
|
||||
c.set('user', { id: '123', role: 'admin' });
|
||||
await next();
|
||||
});
|
||||
|
||||
// Apply middleware
|
||||
app.use('*', authMiddleware);
|
||||
|
||||
// Define routes
|
||||
app.openapi(getUserRoute, (c) => {
|
||||
const user = c.get('user'); // Typed from Env!
|
||||
const db = c.get('db'); // Typed from Env!
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Type Extraction
|
||||
|
||||
```typescript
|
||||
import type { z } from 'zod';
|
||||
|
||||
// Extract inferred type from schema
|
||||
type User = z.infer<typeof UserSchema>;
|
||||
|
||||
type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
||||
|
||||
type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||
|
||||
// Use in application code
|
||||
function saveUser(user: User) {
|
||||
// ...
|
||||
}
|
||||
|
||||
function validateUser(input: CreateUserInput): User {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Testing OpenAPI Routes
|
||||
|
||||
```typescript
|
||||
import { testClient } from 'hono/testing';
|
||||
|
||||
describe('OpenAPI Routes', () => {
|
||||
const client = testClient(app);
|
||||
|
||||
test('POST /users validates schema', async () => {
|
||||
const res = await client.users.$post({
|
||||
json: {
|
||||
email: 'invalid-email', // Invalid!
|
||||
name: 'John',
|
||||
password: 'pass',
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const error = await res.json();
|
||||
expect(error.error).toBeTruthy();
|
||||
});
|
||||
|
||||
test('GET /openapi.json returns valid spec', async () => {
|
||||
const res = await client['openapi.json'].$get();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const spec = await res.json();
|
||||
|
||||
expect(spec.openapi).toBe('3.1.0');
|
||||
expect(spec.info).toBeTruthy();
|
||||
expect(spec.paths).toBeTruthy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Reusable Error Responses
|
||||
|
||||
```typescript
|
||||
const errorResponses = {
|
||||
400: {
|
||||
content: { 'application/json': { schema: ErrorSchema } },
|
||||
description: 'Bad request',
|
||||
},
|
||||
401: {
|
||||
content: { 'application/json': { schema: ErrorSchema } },
|
||||
description: 'Unauthorized',
|
||||
},
|
||||
403: {
|
||||
content: { 'application/json': { schema: ErrorSchema } },
|
||||
description: 'Forbidden',
|
||||
},
|
||||
404: {
|
||||
content: { 'application/json': { schema: ErrorSchema } },
|
||||
description: 'Not found',
|
||||
},
|
||||
500: {
|
||||
content: { 'application/json': { schema: ErrorSchema } },
|
||||
description: 'Internal server error',
|
||||
},
|
||||
};
|
||||
|
||||
// Use in routes
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/resource',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: ResourceSchema } },
|
||||
description: 'Success',
|
||||
},
|
||||
...errorResponses, // Spread common errors
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Reusable Request Schemas
|
||||
|
||||
```typescript
|
||||
const authHeaders = z.object({
|
||||
authorization: z.string(),
|
||||
});
|
||||
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/protected',
|
||||
request: {
|
||||
headers: authHeaders, // Reuse
|
||||
},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user