mirror of https://github.com/buster-so/buster.git
9.5 KiB
9.5 KiB
Hono Server Development Guidelines
This document provides specific guidelines for developing the Hono-based backend server in this monorepo.
API Development Standards
Directory Structure
apps/server/src/api/
├── v2/ # Current API version
│ ├── chats/ # Feature folder
│ │ ├── index.ts # Route definitions
│ │ ├── handler.ts # Main handler (if single)
│ │ ├── create-chat.ts # Individual handlers
│ │ └── services/ # Business logic
│ └── security/ # Another feature
│ ├── index.ts
│ ├── get-workspace-settings.ts
│ └── update-workspace-settings.ts
└── healthcheck.ts # Non-versioned endpoints
Route Definition Pattern
Always follow this pattern in index.ts
:
import { RequestSchema } from '@buster/server-shared/feature';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { requireAuth } from '../../../middleware/auth';
import '../../../types/hono.types';
import { HTTPException } from 'hono/http-exception';
import { handlerFunction } from './handler-file';
const app = new Hono()
// Apply authentication middleware
.use('*', requireAuth)
// GET endpoint (no body validation)
.get('/endpoint', async (c) => {
const user = c.get('busterUser');
const response = await handlerFunction(user);
return c.json(response);
})
// POST/PATCH/PUT endpoint (with body validation)
.post('/endpoint', zValidator('json', RequestSchema), async (c) => {
const request = c.req.valid('json');
const user = c.get('busterUser');
const response = await handlerFunction(request, user);
return c.json(response);
})
// Error handling
.onError((e, c) => {
if (e instanceof HTTPException) {
return e.getResponse();
}
throw new HTTPException(500, {
message: 'Internal server error',
});
});
export default app;
Handler Function Pattern
Each handler must:
- Be in its own file
- Be a pure async function
- Accept typed parameters
- Return typed responses
- Handle business logic or delegate to services
import type {
FeatureRequest,
FeatureResponse
} from '@buster/server-shared/feature';
import type { User } from '@buster/database';
export async function featureHandler(
request: FeatureRequest,
user: User
): Promise<FeatureResponse> {
// TODO: Implement business logic
// For complex logic, delegate to service functions
// const result = await featureService.process(request, user);
return {
// Response data matching FeatureResponse type
};
}
Type Safety Requirements
-
Request/Response Types: Define all types in
@buster/server-shared
// In @buster/server-shared/feature/requests.ts export const FeatureRequestSchema = z.object({ field: z.string(), }); export type FeatureRequest = z.infer<typeof FeatureRequestSchema>;
-
Validation: Always use
zValidator
for request body validation.post('/endpoint', zValidator('json', RequestSchema), async (c) => { const request = c.req.valid('json'); // Fully typed })
-
User Type: Always use
User
from@buster/database
import type { User } from '@buster/database';
Authentication Pattern
- Apply
requireAuth
middleware to all protected routes - Extract user with
c.get('busterUser')
- Pass user to handler functions
const app = new Hono()
.use('*', requireAuth) // Protects all routes in this app
.get('/protected', async (c) => {
const user = c.get('busterUser'); // Type: User from @buster/database
// Use user in handler
});
Error Handling
-
Custom Errors: Create domain-specific error classes
export class FeatureError extends Error { constructor( public code: string, message: string, public statusCode: number ) { super(message); } }
-
Error Responses: Handle in route error handler
.onError((e, c) => { if (e instanceof FeatureError) { return c.json({ error: e.message, code: e.code }, e.statusCode); } // Default error handling })
Testing Guidelines
-
Unit Tests: Test handlers in isolation
// handler.test.ts describe('featureHandler', () => { it('should process valid request', async () => { const mockUser = { id: '123' } as User; const request = { field: 'value' }; const result = await featureHandler(request, mockUser); expect(result).toMatchObject({ // Expected response }); }); });
-
Integration Tests: Test full API routes
// index.test.ts import { testClient } from 'hono/testing';
Best Practices
-
Separation of Concerns
- Routes: Handle HTTP concerns only
- Handlers: Coordinate business logic
- Services: Implement business logic
- Repositories: Handle data access
-
Consistent Naming
- Handler files:
verb-resource.ts
(e.g.,get-user.ts
,update-settings.ts
) - Handler functions:
verbResourceHandler
(e.g.,getUserHandler
) - Service files:
resource-service.ts
- Handler files:
-
Response Validation
- Consider validating responses in development
- Use
.safeParse()
for critical endpoints
-
Logging
- Log errors with context
- Use appropriate log levels
- Include user context where relevant
-
Performance
- Keep handlers lightweight
- Delegate heavy computation to background jobs
- Monitor response times
Common Patterns
Pagination
// In request schema
export const ListRequestSchema = z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
});
// In handler
const offset = (request.page - 1) * request.limit;
Query Parameters
// Define query schema in server-shared
export const ListItemsQuerySchema = z.object({
search: z.string().optional(),
status: z.enum(['active', 'inactive', 'all']).default('all'),
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
sortBy: z.enum(['created_at', 'name', 'updated_at']).default('created_at'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export type ListItemsQuery = z.infer<typeof ListItemsQuerySchema>;
// Route definition with query validation
.get('/items', zValidator('query', ListItemsQuerySchema), async (c) => {
const query = c.req.valid('query'); // Fully typed as ListItemsQuery
const user = c.get('busterUser');
const response = await listItemsHandler(query, user);
return c.json(response);
})
// Handler receives typed query
export async function listItemsHandler(
query: ListItemsQuery,
user: User
): Promise<ListItemsResponse> {
const offset = (query.page - 1) * query.limit;
// Use query.search, query.status, etc. with full type safety
}
Path Parameters
// Define param schema
export const ItemParamsSchema = z.object({
item_id: z.string().uuid(),
});
export type ItemParams = z.infer<typeof ItemParamsSchema>;
// Route with path param validation
.get('/items/:item_id', zValidator('param', ItemParamsSchema), async (c) => {
const params = c.req.valid('param'); // Typed as ItemParams
const user = c.get('busterUser');
const response = await getItemHandler(params.item_id, user);
return c.json(response);
})
// Handler receives typed param
export async function getItemHandler(
itemId: string,
user: User
): Promise<ItemResponse> {
// itemId is guaranteed to be a valid UUID
}
Combined Parameters (Path + Query + Body)
// Define schemas
export const UpdateItemParamsSchema = z.object({
item_id: z.string().uuid(),
});
export const UpdateItemQuerySchema = z.object({
validate: z.coerce.boolean().default(true),
});
export const UpdateItemBodySchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
status: z.enum(['active', 'inactive']).optional(),
});
// Route with multiple validations
.patch(
'/items/:item_id',
zValidator('param', UpdateItemParamsSchema),
zValidator('query', UpdateItemQuerySchema),
zValidator('json', UpdateItemBodySchema),
async (c) => {
const params = c.req.valid('param');
const query = c.req.valid('query');
const body = c.req.valid('json');
const user = c.get('busterUser');
const response = await updateItemHandler(
params.item_id,
body,
{ validate: query.validate },
user
);
return c.json(response);
}
)
// Handler with all parameters typed
export async function updateItemHandler(
itemId: string,
data: UpdateItemBody,
options: { validate: boolean },
user: User
): Promise<ItemResponse> {
if (options.validate) {
// Perform validation
}
// Update logic
}
Important Notes on Type Coercion
- Use
z.coerce.number()
for numeric query params (they come as strings) - Use
z.coerce.boolean()
for boolean query params - Path params are always strings, validate format with
.uuid()
,.regex()
, etc. - Query arrays need special handling:
ids: z.array(z.string()).or(z.string()).transform(v => Array.isArray(v) ? v : [v])
Background Jobs
// Queue job without waiting
import { tasks } from '@trigger.dev/sdk/v3';
await tasks.trigger('job-name', { data });
// Return immediately, don't await job completion