API Development Guide
This guide explains how to add new API endpoints to the D'n'A Cruises system.
Architecture
The API is built on Cloudflare Workers using a route-based architecture:
- Location:
workers/api/src/ - Entry Point:
workers/api/src/index.ts - Routes:
workers/api/src/routes/ - Middleware:
workers/api/src/middleware/ - Utils:
workers/api/src/utils/
Adding a New Endpoint
1. Create Route File (if needed)
If creating a new resource, create a new route file:
// workers/api/src/routes/my-resource.ts
/**
* My Resource routes
*
* Routes for managing my resource.
*/
import type { Env } from '../types';
import type { Route } from './index';
import { routes } from './index';
import { requireRole, type AuthContext } from '../middleware/auth';
import { createSuccessResponse, createErrorResponse, parsePaginationParams } from '@dna-cruises/api';
// GET /my-resource
routes.push({
method: 'GET',
path: '/my-resource',
handler: async (request: Request, env: Env) => {
try {
// Your handler logic
return new Response(
JSON.stringify(createSuccessResponse({ data: 'example' })),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
return new Response(
JSON.stringify(createErrorResponse('INTERNAL_ERROR', 'Failed to fetch')),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
},
middleware: [requireRole('Admin')], // Optional: role-based access
});
2. Import Route File
Add the import to workers/api/src/router.ts:
import './routes/my-resource';
3. Route Structure
Each route follows this pattern:
routes.push({
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
path: '/resource/:id?', // :id is optional
handler: async (request: Request, env: Env) => {
// Handler logic
},
middleware: [/* optional middleware */],
});
Common Patterns
GET with Pagination
routes.push({
method: 'GET',
path: '/items',
handler: async (request: Request, env: Env) => {
try {
const url = new URL(request.url);
const { page, pageSize } = parsePaginationParams(url.searchParams);
const offset = (page - 1) * pageSize;
const items = await env.DB.prepare(
'SELECT * FROM items ORDER BY id DESC LIMIT ? OFFSET ?'
)
.bind(pageSize, offset)
.all();
const totalResult = await env.DB.prepare('SELECT COUNT(*) as total FROM items')
.first<{ total: number }>();
const total = totalResult?.total || 0;
return new Response(
JSON.stringify(
createSuccessResponse({
items: items.results,
pagination: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
})
),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
return new Response(
JSON.stringify(createErrorResponse('INTERNAL_ERROR', 'Failed to fetch items')),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
},
});
POST with Validation
routes.push({
method: 'POST',
path: '/items',
handler: async (request: Request, env: Env) => {
try {
const auth = (request as any).auth as AuthContext;
const body = await request.json() as { name?: string; category_id?: number };
const { name, category_id } = body;
// Validation
if (!name) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', 'Name is required')),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
const now = Math.floor(Date.now() / 1000);
const result = await env.DB.prepare(
'INSERT INTO items (name, category_id, created_at) VALUES (?, ?, ?) RETURNING *'
)
.bind(name, category_id || null, now)
.first();
return new Response(
JSON.stringify(createSuccessResponse(result)),
{
status: 201,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
return new Response(
JSON.stringify(createErrorResponse('INTERNAL_ERROR', 'Failed to create item')),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
},
middleware: [requireRole('Admin')],
});
PUT with ID Parameter
routes.push({
method: 'PUT',
path: '/items/:id',
handler: async (request: Request, env: Env) => {
try {
const params = (request as any).params;
const id = parseInt(params.id, 10);
const body = await request.json() as { name?: string };
if (isNaN(id)) {
return new Response(
JSON.stringify(createErrorResponse('INVALID_ID', 'Invalid item ID')),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
// Check if exists
const item = await env.DB.prepare('SELECT * FROM items WHERE id = ?')
.bind(id)
.first();
if (!item) {
return new Response(
JSON.stringify(createErrorResponse('NOT_FOUND', 'Item not found')),
{
status: 404,
headers: { 'Content-Type': 'application/json' },
}
);
}
// Update
const result = await env.DB.prepare(
'UPDATE items SET name = ? WHERE id = ? RETURNING *'
)
.bind(body.name || item.name, id)
.first();
return new Response(
JSON.stringify(createSuccessResponse(result)),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
return new Response(
JSON.stringify(createErrorResponse('INTERNAL_ERROR', 'Failed to update item')),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
},
middleware: [requireRole('Admin')],
});
Authentication & Authorization
Accessing Auth Context
const auth = (request as any).auth as AuthContext;
// auth.userId - User ID
// auth.email - User email
// auth.role - User role (Loader, Producer, Admin)
Role-Based Access
middleware: [requireRole('Admin')], // Only Admin
middleware: [requireRole('Producer', 'Admin')], // Producer or Admin
middleware: [requireRole('Loader', 'Producer', 'Admin')], // All roles
Error Handling
Always use createErrorResponse:
createErrorResponse('ERROR_CODE', 'Human-readable message')
Common error codes:
VALIDATION_ERROR- Invalid inputNOT_FOUND- Resource not foundUNAUTHORIZED- Authentication requiredFORBIDDEN- Insufficient permissionsINTERNAL_ERROR- Server error
Database Queries
Prepared Statements
Always use prepared statements:
const result = await env.DB.prepare('SELECT * FROM items WHERE id = ?')
.bind(id)
.first();
Transactions
For multiple related operations:
// Note: D1 doesn't support explicit transactions
// Use careful ordering and error handling instead
Audit Logging
Log important actions:
const now = Math.floor(Date.now() / 1000);
await env.DB.prepare(
'INSERT INTO audit_log (entity_type, entity_id, action, user_id, timestamp) VALUES (?, ?, ?, ?, ?)'
)
.bind('item', id, 'UPDATE', auth.userId, now)
.run();
Testing
- Test locally with
wrangler dev - Use
curlor Postman for manual testing - Check Cloudflare Dashboard logs for errors
Best Practices
- Always validate input - Check required fields and types
- Use prepared statements - Prevent SQL injection
- Handle errors gracefully - Return proper error responses
- Log important actions - Use audit log
- Document your endpoints - Add JSDoc comments
- Follow existing patterns - Maintain consistency
- Test thoroughly - Test all code paths
Related Topics
- Database Schema - Understanding the database
- Authentication - Auth implementation
- Frontend Development - Frontend integration