Type-safe REST API server with automatic validation and documentation.
- Write less code - Define endpoints with schemas, get validation + docs automatically
- Type safety - Full TypeScript inference from Zod schemas (request β handler β response)
- Catch bugs early - Request AND response validation at runtime
- Flexible auth - Simple tokens or custom validation with typed context (JWT, DB, etc.)
- Zero config - Swagger UI, rate limiting, CORS, metrics all included
Built on Fastify (fast) and Zod (type-safe validation).
npm install @bitfocusas/api
import { APIServer, z } from '@bitfocusas/api'; const app = new APIServer({ port: 3000 }); app.createEndpoint({ method: 'GET', url: '/hello', query: z.object({ name: z.string().optional().describe('Name to greet'), }), response: z.object({ message: z.string().describe('Greeting message'), }), handler: async (request) => { return { message: `Hello, ${request.query.name || 'World'}!` }; }, }); await app.start(); // Visit http://localhost:3000/docs for Swagger UI
Use satisfies with z.infer for better TypeScript error messages and autocomplete:
const ResponseSchema = z.object({ message: z.string(), userId: z.number() }); app.createEndpoint({ method: 'POST', url: '/users', body: z.object({ name: z.string() }), response: ResponseSchema, handler: async (request) => { // β Good: TypeScript will show exactly which field is wrong return { message: 'User created', userId: 123 } satisfies z.infer<typeof ResponseSchema>; // β Without satisfies: Less helpful error messages (a lot of ugly zod-bonanza) // return { message: 'User created', userId: '123' }; }, });
This catches type mismatches at compile time and provides better IDE support.
Use Zod's .describe() method to add field descriptions to your schemas. These descriptions automatically appear in your Swagger UI, making your API self-documenting:
const UserSchema = z.object({ id: z.string().describe('Unique user identifier (UUID)'), email: z.string().email().describe('User email address (must be unique)'), role: z.enum(['admin', 'user']).describe('User role for access control'), createdAt: z.string().describe('ISO 8601 timestamp of account creation'), metadata: z.record(z.any()).optional().describe('Additional user metadata (key-value pairs)'), }); app.createEndpoint({ method: 'POST', url: '/users', body: UserSchema.omit({ id: true, createdAt: true }), response: UserSchema, config: { description: 'Create a new user account', tags: ['Users'], }, handler: async (request) => { // Your logic here return newUser satisfies z.infer<typeof UserSchema>; }, });
Benefits:
- Field descriptions show up in Swagger UI
- Better API documentation without writing separate docs
- Helps frontend developers understand your API
- Types and documentation stay in sync
For production environments, always set NODE_ENV=production or explicitly configure the env option:
// Option 1: Environment variable (recommended) // NODE_ENV=production node index.js // Option 2: Explicit config const app = new APIServer({ env: 'production', logLevel: 'warn', // Reduce log verbosity in production apiToken: process.env.API_TOKEN, // Use environment variable for secrets });
In production mode, the library:
- Uses JSON-formatted logs (better for log aggregation)
- Disables pretty-printing for better performance
- Defaults to more conservative settings
Check out the examples/ directory for complete working examples:
- simple.ts - Minimal setup, perfect for getting started
- basic.ts - User CRUD API with validation and error handling
- custom-fastify.ts - Use your own Fastify instance
- custom-auth.ts - Custom authentication with typed context
Run any example:
npm run example:simple npm run example:basic npm run example:fastify npm run example:auth
See the examples README for detailed information about each example.
Create a server instance. You can either let the library create its own Fastify instance (default) or provide your own.
const app = new APIServer({ port: 3000, // Server port (default: 3000) host: '127.0.0.1', // Host (default: 127.0.0.1) env: 'production', // Environment: 'development' | 'production' (default: development) logLevel: 'info', // Log level: 'debug' | 'info' | 'warn' | 'error' (default: info) apiTitle: 'My API', // Swagger title apiToken: 'secret-token', // Bearer token for auth (string or function) rateLimitMax: 100, // Max requests per window (default: 100) rateLimitWindow: '15m', // Rate limit window (default: 15m) metricsEnabled: true, // Enable /metrics endpoint (default: true) corsOrigin: '*', // CORS origin (default: *) }); await app.start(); // Library manages server lifecycle
import fastify from 'fastify'; // Create your own Fastify instance const customFastify = fastify({ logger: { level: 'info' }, // Add your own configuration }); // Register your own plugins/routes customFastify.get('/health', async () => ({ status: 'ok' })); // Attach API server to your instance const app = new APIServer({ fastify: customFastify, // π Pass your Fastify instance apiTitle: 'My API', apiToken: 'secret-token', // port/host/logLevel are ignored when fastify is provided }); await app.start(); // Attaches endpoints, but doesn't start server // You're responsible for starting your Fastify instance await customFastify.listen({ port: 3000, host: '127.0.0.1' });
When to use your own Fastify instance:
- Integrating with an existing Fastify application
- Sharing a Fastify instance across multiple modules
- Need custom Fastify configuration or plugins
- Want more control over server lifecycle
See the custom-fastify example for a complete example.
Config via environment variables:
NODE_ENV=production # Set to 'production' in production (default: development) PORT=3000 HOST=127.0.0.1 API_TOKEN=your-token RATE_LIMIT_MAX=100 RATE_LIMIT_WINDOW=15m METRICS_ENABLED=true CORS_ORIGIN=*
Define an endpoint with automatic validation and documentation.
app.createEndpoint({ method: 'POST', // GET, POST, PUT, DELETE, PATCH url: '/users', // URL path (can include :params) query: QuerySchema, // Zod schema for query params body: BodySchema, // Zod schema for request body response: ResponseSchema, // Zod schema for response authenticated: true, // Optional: Require Bearer token (adds 401/403 responses) config: { // Optional Swagger metadata description: 'Create user', tags: ['Users'], summary: 'Create a new user', }, handler: async (request, reply) => { // Fully typed request.query and request.body // Return value is validated against ResponseSchema // If authenticated: true, request.auth is available return { /* response data */ }; }, });
The library includes rate limiting out of the box. You can configure it globally or per-endpoint.
const app = new APIServer({ rateLimitMax: 100, // Max requests per window (default: 100) rateLimitWindow: '15m', // Time window (default: 15m) rateLimitAllow: ['10.0.0.1'], // IPs to bypass rate limiting });
You can customize rate limiting for individual endpoints or disable it entirely:
// Stricter rate limit for sensitive endpoint app.createEndpoint({ method: 'POST', url: '/login', body: z.object({ email: z.string(), password: z.string() }), response: z.object({ token: z.string() }), rateLimit: { max: 5, timeWindow: '1m' }, // Only 5 attempts per minute handler: async (request) => { return { token: 'jwt-token' }; }, }); // Disable rate limiting for health check app.createEndpoint({ method: 'GET', url: '/health', response: z.object({ status: z.string() }), rateLimit: false, // No rate limiting handler: async () => { return { status: 'ok' }; }, }); // Use global rate limit (default behavior) app.createEndpoint({ method: 'GET', url: '/users', response: z.object({ users: z.array(z.any()) }), // rateLimit not specified = uses global settings handler: async () => { return { users: [] }; }, });
Rate limit options per endpoint:
false- Disable rate limiting for this endpoint{ max: number, timeWindow?: string }- Custom rate limit (timeWindow defaults to global setting)undefined(not specified) - Use global rate limit settings
Start the server. Returns a Promise.
Behavior:
- If you provided your own Fastify instance: Attaches endpoints but doesn't start the server (you start it yourself)
- If the library created its own instance: Starts the server automatically
// Default: Library manages server const app = new APIServer({ port: 3000 }); await app.start(); // Server is now running // Custom Fastify: You manage server const customFastify = fastify(); const app = new APIServer({ fastify: customFastify }); await app.start(); // Endpoints attached, but server not started yet await customFastify.listen({ port: 3000 }); // You start it
Stop the server gracefully.
Behavior:
- If you provided your own Fastify instance: Doesn't close it (you close it yourself)
- If the library created its own instance: Closes the server automatically
// Default: Library manages server await app.stop(); // Server is closed // Custom Fastify: You manage server await customFastify.close(); // You close it yourself
Setup SIGINT/SIGTERM handlers for graceful shutdown.
app.setupGracefulShutdown(); await app.start();
The library provides flexible authentication with automatic OpenAPI documentation.
Add authenticated: true to any endpoint to require Bearer token authentication. This automatically:
- Adds Bearer auth button in Swagger UI
- Includes 401/403 error responses in OpenAPI spec
- Applies authentication middleware
- Validates tokens before your handler runs
const app = new APIServer({ apiToken: 'your-secret-token', }); // Public endpoint - no authentication app.createEndpoint({ method: 'GET', url: '/public', response: z.object({ message: z.string() }), handler: async () => { return { message: 'Anyone can access this!' }; }, }); // Protected endpoint - requires authentication app.createEndpoint({ method: 'GET', url: '/protected', authenticated: true, // π Requires Bearer token response: z.object({ secret: z.string() }), handler: async () => { return { secret: 'Only authenticated users see this!' }; }, }); // Usage: // curl http://localhost:3000/public (works) // curl -H "Authorization: Bearer your-secret-token" http://localhost:3000/protected (works) // curl http://localhost:3000/protected (401 Unauthorized)
Bearer token authentication middleware. Supports both simple string token validation and custom validation with typed context.
Note: Using authenticated: true on endpoints is preferred over manually registering authentication middleware, as it provides better OpenAPI documentation.
const app = new APIServer({ apiToken: 'secret-token', }); // Protect routes app.instance.register(async (scope) => { scope.addHook('onRequest', app.authenticateToken); app.createEndpoint({ method: 'GET', url: '/admin', response: z.object({ secret: z.string() }), handler: async () => ({ secret: 'data' }), }); }); // Usage: curl -H "Authorization: Bearer secret-token" http://localhost:3000/admin
// Define your auth context type interface AuthContext { userId: string; role: 'admin' | 'user'; permissions: string[]; } // Create server with custom validator const app = new APIServer<AuthContext>({ apiToken: async (token, request) => { // Your custom validation logic (e.g., check database, JWT, etc.) const user = await validateTokenInDatabase(token); if (!user) { return { valid: false, error: 'Invalid or expired token', }; } // Return validated context return { valid: true, context: { userId: user.id, role: user.role, permissions: user.permissions, }, }; }, }); // Protected endpoint with access to auth context (using authenticated: true) app.createEndpoint({ method: 'GET', url: '/profile', authenticated: true, // π Requires Bearer token response: z.object({ userId: z.string(), role: z.string() }), handler: async (request) => { // request.auth is fully typed as AuthContext const { userId, role } = request.auth!; return { userId, role }; }, }); // Admin-only endpoint with role checking app.createEndpoint({ method: 'DELETE', url: '/admin/users/:id', authenticated: true, // π Requires Bearer token params: z.object({ id: z.string(), }), response: z.object({ message: z.string() }), handler: async (request, reply) => { // Custom role check if (request.auth!.role !== 'admin') { return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required', }); } const { id } = request.params; return { message: `User ${id} deleted` }; }, }); // Usage: curl -H "Authorization: Bearer user-jwt-token" http://localhost:3000/profile
Alternative: Use app.instance.register() with scope.addHook() if you need to protect multiple routes at once:
app.instance.register(async (scope) => { scope.addHook('onRequest', app.authenticateToken); // All routes here will require authentication scope.get('/admin/stats', async () => ({ total: 100 })); scope.post('/admin/settings', async () => ({ success: true })); });
Access the underlying Fastify instance for advanced use cases.
// Add custom hooks app.instance.addHook('onRequest', async (request, reply) => { console.log(`${request.method} ${request.url}`); }); // Register plugins app.instance.register(yourPlugin);
Throw custom validation errors (returns 400):
import { ValidationError } from '@bitfocusas/api'; throw new ValidationError([ { field: 'body.email', message: 'Email already exists' }, { field: 'body.age', message: 'Must be 18 or older' }, ]);
Throw 404 errors:
import { NotFoundError } from '@bitfocusas/api'; throw new NotFoundError('User not found');
import { APIServer, ValidationError, NotFoundError, z } from '@bitfocusas/api'; const app = new APIServer({ port: 3000, apiTitle: 'User API', apiTags: [{ name: 'Users', description: 'User management' }], }); // In-memory database interface User { id: string; name: string; email: string; createdAt: string; } const users: User[] = []; // List users app.createEndpoint({ method: 'GET', url: '/users', query: z.object({ limit: z.coerce.number().int().positive().max(100).default(10) .describe('Maximum number of users to return (1-100)'), offset: z.coerce.number().int().nonnegative().default(0) .describe('Number of users to skip for pagination'), }), response: z.object({ users: z.array(z.object({ id: z.string().describe('Unique user identifier'), name: z.string().describe('User full name'), email: z.string().describe('User email address'), createdAt: z.string().describe('ISO 8601 creation timestamp'), })).describe('List of users'), total: z.number().describe('Total number of users in database'), }), config: { description: 'List users with pagination', tags: ['Users'], }, handler: async (request) => { const { limit, offset } = request.query; return { users: users.slice(offset, offset + limit), total: users.length, }; }, }); // Get user by ID app.createEndpoint({ method: 'GET', url: '/users/:id', params: z.object({ id: z.string(), }), response: z.object({ id: z.string(), name: z.string(), email: z.string(), createdAt: z.string(), }), config: { description: 'Get a user by ID', tags: ['Users'], }, handler: async (request) => { const { id } = request.params; const user = users.find(u => u.id === id); if (!user) { throw new NotFoundError('User not found'); } return user; }, }); // Create user app.createEndpoint({ method: 'POST', url: '/users', body: z.object({ name: z.string().min(1).max(100).describe('Full name of the user'), email: z.string().email().describe('Email address (must be unique)'), }), response: z.object({ id: z.string().describe('Unique user identifier'), name: z.string().describe('User full name'), email: z.string().describe('User email address'), createdAt: z.string().describe('ISO 8601 creation timestamp'), }), config: { description: 'Create a new user', tags: ['Users'], }, handler: async (request) => { const { name, email } = request.body; // Check email uniqueness if (users.some(u => u.email === email)) { throw new ValidationError([ { field: 'body.email', message: 'Email already exists' }, ]); } const newUser: User = { id: crypto.randomUUID(), name, email, createdAt: new Date().toISOString(), }; users.push(newUser); return newUser; }, }); // Update user app.createEndpoint({ method: 'PUT', url: '/users/:id', params: z.object({ id: z.string(), }), body: z.object({ name: z.string().min(1).max(100).optional(), email: z.string().email().optional(), }), response: z.object({ id: z.string(), name: z.string(), email: z.string(), createdAt: z.string(), }), config: { description: 'Update a user', tags: ['Users'], }, handler: async (request) => { const { id } = request.params; const updates = request.body; const userIndex = users.findIndex(u => u.id === id); if (userIndex === -1) { throw new NotFoundError('User not found'); } // Check email uniqueness if updating email if (updates.email && users.some(u => u.email === updates.email && u.id !== id)) { throw new ValidationError([ { field: 'body.email', message: 'Email already exists' }, ]); } users[userIndex] = { ...users[userIndex], ...updates }; return users[userIndex]; }, }); // Delete user app.createEndpoint({ method: 'DELETE', url: '/users/:id', params: z.object({ id: z.string(), }), response: z.object({ message: z.string(), }), config: { description: 'Delete a user', tags: ['Users'], }, handler: async (request) => { const { id } = request.params; const index = users.findIndex(u => u.id === id); if (index === -1) { throw new NotFoundError('User not found'); } users.splice(index, 1); return { message: 'User deleted' }; }, }); // Protected admin endpoint app.instance.register(async (scope) => { scope.addHook('onRequest', app.authenticateToken); scope.route({ method: 'GET', url: '/admin/stats', handler: async () => ({ totalUsers: users.length, timestamp: new Date().toISOString(), }), }); }); app.setupGracefulShutdown(); await app.start();
- GitHub: https://github.com/bitfocusas/api
- Issues: https://github.com/bitfocusas/api/issues
- Author: William Viker william@bitfocus.io
- License: MIT