840 lines
17 KiB
Markdown
840 lines
17 KiB
Markdown
# 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
|
|
},
|
|
// ...
|
|
});
|
|
```
|