Writing Your Own API From Scratch in TypeScript
Tutorials that show you how to build an API in 50 lines of code are lying to you. This is what a production TypeScript API actually looks like — authentication, validation, error handling, rate limiting, database connection pooling, structured logging, and a test suite. All of it, from scratch.
Every tutorial on building a Node.js API shows you something like this: twenty lines of Express, a couple of route handlers, and a res.json({ message: 'hello' }). The tutorial calls that a REST API. It is technically true. It is also completely useless as a reference for building anything you would actually deploy.
Production APIs need authentication, input validation, meaningful error handling, database connection pooling, structured logging, rate limiting, and tests. They need configuration management that does not involve hardcoding secrets. They need graceful shutdown so in-flight requests complete when the server stops. They need documentation.
This article builds a real API from scratch. TypeScript throughout. Every piece that actually matters in production. No shortcuts dressed up as simplicity.
If you want to understand what the code is doing conceptually before writing it, start with What is an API. If you want to understand the database layer, read What is a Database. This article assumes you know JavaScript and have at least read about TypeScript.
Project Setup
mkdir my-api && cd my-api
npm init -y
npm install express pg bcryptjs jsonwebtoken zod express-rate-limit helmet cors morgan uuid
npm install --save-dev typescript @types/express @types/pg @types/bcryptjs @types/jsonwebtoken @types/morgan @types/cors @types/uuid ts-node-dev jest @types/jest ts-jest supertest @types/supertest
npx tsc --init
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "commonjs",
"moduleResolution": "node",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
package.json scripts:
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "jest --coverage",
"test:watch": "jest --watch",
"migrate": "ts-node src/database/migrate.ts"
}
}
Project structure:
src/
config/
index.ts
database/
index.ts
migrate.ts
middleware/
auth.ts
errorHandler.ts
validate.ts
rateLimiter.ts
requestLogger.ts
routes/
auth.ts
products.ts
orders.ts
index.ts
types/
index.ts
express.d.ts
utils/
logger.ts
errors.ts
jwt.ts
app.ts
index.ts
tests/
auth.test.ts
products.test.ts
setup.ts
.env
.env.test
Configuration
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
JWT_ACCESS_SECRET: z.string().min(32),
JWT_REFRESH_SECRET: z.string().min(32),
JWT_ACCESS_EXPIRES: z.string().default('15m'),
JWT_REFRESH_EXPIRES: z.string().default('30d'),
BCRYPT_ROUNDS: z.coerce.number().int().min(10).default(12),
RATE_LIMIT_WINDOW: z.coerce.number().int().positive().default(60000),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100),
CORS_ORIGINS: z.string().default('http://localhost:3000'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:');
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
export const config = parsed.data;
export type Config = typeof config;
Validating environment variables at startup is a pattern that prevents a whole class of production incidents. The server does not start with a missing DATABASE_URL. It fails immediately with a clear error message rather than running for an hour before the first database query fails. Zod validates and coerces types: PORT comes in as a string from process.env but is coerced to a number.
Structured Logging
import { config } from '../config';
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
type LogContext = Record<string, unknown>;
const levels: Record<LogLevel, number> = {
debug: 0, info: 1, warn: 2, error: 3
};
function shouldLog(level: LogLevel): boolean {
return levels[level] >= levels[config.LOG_LEVEL];
}
function formatLog(level: LogLevel, message: string, context?: LogContext): string {
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...(context && Object.keys(context).length > 0 ? { context } : {}),
pid: process.pid,
env: config.NODE_ENV,
};
return JSON.stringify(entry);
}
export const logger = {
debug: (message: string, context?: LogContext) => {
if (shouldLog('debug')) process.stdout.write(formatLog('debug', message, context) + '\n');
},
info: (message: string, context?: LogContext) => {
if (shouldLog('info')) process.stdout.write(formatLog('info', message, context) + '\n');
},
warn: (message: string, context?: LogContext) => {
if (shouldLog('warn')) process.stderr.write(formatLog('warn', message, context) + '\n');
},
error: (message: string, context?: LogContext) => {
if (shouldLog('error')) process.stderr.write(formatLog('error', message, context) + '\n');
},
};
Structured JSON logs are the standard for production systems. Every log entry is a JSON object with consistent fields. Log aggregation systems (Datadog, CloudWatch, ELK stack) parse JSON natively. You can query "all error logs where context.user_id = 123" in seconds. Unstructured text logs require regex to extract fields and break whenever the message format changes.
Custom Errors
export class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
public readonly details?: unknown
) {
super(message);
this.name = 'AppError';
Object.setPrototypeOf(this, AppError.prototype);
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id?: string | number) {
super(
404,
'NOT_FOUND',
id ? `${resource} with ID ${id} not found` : `${resource} not found`
);
}
}
export class ValidationError extends AppError {
constructor(details: Array<{ field: string; message: string }>) {
super(400, 'VALIDATION_ERROR', 'Request validation failed', details);
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(401, 'UNAUTHORIZED', message);
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Insufficient permissions') {
super(403, 'FORBIDDEN', message);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(409, 'CONFLICT', message);
}
}
Custom error classes let you throw semantically meaningful errors anywhere in your application and handle them uniformly in one place. throw new NotFoundError('Product', slug) from inside a repository function carries everything the error handler needs to produce the right HTTP response. No error code hunting. No if (err.code === 'some_string') scattered through route handlers.
Database Layer
import { Pool, PoolClient } from 'pg';
import { config } from '../config';
import { logger } from '../utils/logger';
const pool = new Pool({
connectionString: config.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
statement_timeout: 30000,
query_timeout: 30000,
application_name: 'my-api',
});
pool.on('error', (err) => {
logger.error('Unexpected error on idle database client', { error: err.message });
});
pool.on('connect', () => {
logger.debug('New database connection established');
});
export async function query<T extends Record<string, unknown> = Record<string, unknown>>(
text: string,
params?: unknown[]
): Promise<T[]> {
const start = Date.now();
try {
const result = await pool.query<T>(text, params);
const duration = Date.now() - start;
if (duration > 1000) {
logger.warn('Slow query detected', { duration, query: text.substring(0, 100) });
}
return result.rows;
} catch (err) {
logger.error('Database query error', {
error: (err as Error).message,
query: text.substring(0, 100),
});
throw err;
}
}
export async function queryOne<T extends Record<string, unknown> = Record<string, unknown>>(
text: string,
params?: unknown[]
): Promise<T | null> {
const rows = await query<T>(text, params);
return rows[0] ?? null;
}
export async function transaction<T>(
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function closePool(): Promise<void> {
await pool.end();
logger.info('Database pool closed');
}
The transaction helper wraps the BEGIN / COMMIT / ROLLBACK pattern that you need for any multi-step database operation. Pass a callback, do your work inside it, and the helper handles the lifecycle. If the callback throws, the transaction rolls back. If it succeeds, it commits. client.release() in the finally block returns the connection to the pool regardless of outcome.
Slow query logging in query() surfaces performance problems without requiring additional instrumentation. Any query taking more than one second writes a warning log with the query text and duration. This is how you find missing indexes in production before they cause outages.
Database Migrations
import { query, closePool } from './index';
import { logger } from '../utils/logger';
const migrations = [
{
version: 1,
name: 'create_users',
sql: `
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(80) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
is_admin BOOLEAN NOT NULL DEFAULT false,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_created ON users(created_at);
`,
},
{
version: 2,
name: 'create_refresh_tokens',
sql: `
CREATE TABLE IF NOT EXISTS refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
`,
},
{
version: 3,
name: 'create_categories_and_products',
sql: `
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL CHECK (price > 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','active','archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_products_status_created ON products(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_products_category_status ON products(category_id, status);
CREATE INDEX IF NOT EXISTS idx_products_slug ON products(slug);
`,
},
{
version: 4,
name: 'create_orders',
sql: `
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','confirmed','shipped','delivered','cancelled')),
total_amount NUMERIC(10,2) NOT NULL CHECK (total_amount >= 0),
shipping_address JSONB NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10,2) NOT NULL CHECK (unit_price > 0),
UNIQUE (order_id, product_id)
);
CREATE INDEX IF NOT EXISTS idx_orders_user_status ON orders(user_id, status);
CREATE INDEX IF NOT EXISTS idx_orders_status_created ON orders(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_product ON order_items(product_id);
`,
},
];
async function runMigrations(): Promise<void> {
await query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
const applied = await query<{ version: number }>('SELECT version FROM schema_migrations ORDER BY version');
const appliedVersions = new Set(applied.map(r => r.version));
for (const migration of migrations) {
if (appliedVersions.has(migration.version)) continue;
logger.info(`Running migration ${migration.version}: ${migration.name}`);
await query(migration.sql);
await query(
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
[migration.version, migration.name]
);
logger.info(`Migration ${migration.version} completed`);
}
logger.info('All migrations complete');
await closePool();
}
runMigrations().catch((err) => {
logger.error('Migration failed', { error: (err as Error).message });
process.exit(1);
});
JWT Utilities
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { UnauthorizedError } from './errors';
export interface TokenPayload {
sub: number;
username: string;
is_admin: boolean;
type: 'access' | 'refresh';
}
export function signAccessToken(payload: Omit<TokenPayload, 'type'>): string {
return jwt.sign(
{ ...payload, type: 'access' },
config.JWT_ACCESS_SECRET,
{ expiresIn: config.JWT_ACCESS_EXPIRES, algorithm: 'HS256' }
);
}
export function signRefreshToken(payload: Omit<TokenPayload, 'type'>): string {
return jwt.sign(
{ ...payload, type: 'refresh' },
config.JWT_REFRESH_SECRET,
{ expiresIn: config.JWT_REFRESH_EXPIRES, algorithm: 'HS256' }
);
}
export function verifyAccessToken(token: string): TokenPayload {
try {
const payload = jwt.verify(token, config.JWT_ACCESS_SECRET) as TokenPayload;
if (payload.type !== 'access') throw new Error('Wrong token type');
return payload;
} catch {
throw new UnauthorizedError('Invalid or expired access token');
}
}
export function verifyRefreshToken(token: string): TokenPayload {
try {
const payload = jwt.verify(token, config.JWT_REFRESH_SECRET) as TokenPayload;
if (payload.type !== 'refresh') throw new Error('Wrong token type');
return payload;
} catch {
throw new UnauthorizedError('Invalid or expired refresh token');
}
}
Express App and Middleware
import express, { Request, Response, NextFunction } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { config } from './config';
import { router } from './routes';
import { errorHandler } from './middleware/errorHandler';
import { requestLogger } from './middleware/requestLogger';
import { logger } from './utils/logger';
const app = express();
app.use(helmet());
app.use(cors({
origin: config.CORS_ORIGINS.split(',').map(o => o.trim()),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID', 'Idempotency-Key'],
}));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(requestLogger);
app.get('/health/live', (_req: Request, res: Response) => {
res.json({ status: 'alive', timestamp: new Date().toISOString() });
});
app.get('/health/ready', async (_req: Request, res: Response) => {
const checks: Record<string, { status: string; latency_ms?: number; error?: string }> = {};
const start = Date.now();
try {
const { query } = await import('./database');
await query('SELECT 1');
checks['database'] = { status: 'ok', latency_ms: Date.now() - start };
} catch (err) {
checks['database'] = { status: 'error', error: (err as Error).message };
}
const ready = Object.values(checks).every(c => c.status === 'ok');
res.status(ready ? 200 : 503).json({
status: ready ? 'ready' : 'not_ready',
checks,
timestamp: new Date().toISOString(),
});
});
app.use('/api/v1', router);
app.use((_req: Request, res: Response) => {
res.status(404).json({
error: { code: 'NOT_FOUND', message: 'The requested endpoint does not exist' }
});
});
app.use(errorHandler);
export { app };
helmet() sets security-relevant HTTP headers: X-Content-Type-Options, X-Frame-Options, Content-Security-Policy, and several others. One line of middleware that closes several common attack vectors.
express.json({ limit: '1mb' }) parses JSON request bodies. The 1MB limit prevents clients from crashing your server by sending enormous payloads.
Request Logger Middleware
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger';
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const requestId = (req.headers['x-request-id'] as string) ?? uuidv4();
const start = Date.now();
req.requestId = requestId;
res.setHeader('X-Request-ID', requestId);
res.on('finish', () => {
const duration = Date.now() - start;
const level = res.statusCode >= 500 ? 'error'
: res.statusCode >= 400 ? 'warn'
: 'info';
logger[level](`${req.method} ${req.path}`, {
request_id: requestId,
method: req.method,
path: req.path,
status: res.statusCode,
duration_ms: duration,
ip: req.ip,
user_id: req.user?.id,
});
});
next();
}
Express type augmentation (src/types/express.d.ts):
import { TokenPayload } from '../utils/jwt';
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
requestId?: string;
}
}
}
Auth Middleware
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
import { UnauthorizedError, ForbiddenError } from '../utils/errors';
export function authenticate(req: Request, _res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
throw new UnauthorizedError('Bearer token required');
}
const token = header.slice(7);
req.user = verifyAccessToken(token);
next();
}
export function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
if (!req.user) throw new UnauthorizedError();
if (!req.user.is_admin) throw new ForbiddenError('Admin access required');
next();
}
export function optionalAuth(req: Request, _res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (header?.startsWith('Bearer ')) {
try {
req.user = verifyAccessToken(header.slice(7));
} catch {
// Not authenticated — continue without user
}
}
next();
}
Validation Middleware
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema, ZodError } from 'zod';
import { ValidationError } from '../utils/errors';
type ValidationTarget = 'body' | 'query' | 'params';
export function validate<T>(schema: ZodSchema<T>, target: ValidationTarget = 'body') {
return (req: Request, _res: Response, next: NextFunction): void => {
const result = schema.safeParse(req[target]);
if (!result.success) {
const details = result.error.issues.map((issue: ZodError['issues'][number]) => ({
field: issue.path.join('.'),
message: issue.message,
}));
throw new ValidationError(details);
}
req[target] = result.data;
next();
};
}
export const schemas = {
register: z.object({
username: z.string().min(3).max(80).regex(/^[a-zA-Z0-9_-]+$/, 'Only letters, numbers, underscores and hyphens'),
email: z.string().email(),
password: z.string().min(8).max(128),
}),
login: z.object({
login: z.string().min(1),
password: z.string().min(1),
}),
createProduct: z.object({
name: z.string().min(1).max(255),
description: z.string().min(1),
price: z.number().positive(),
stock: z.number().int().nonnegative().default(0),
category_id: z.number().int().positive(),
status: z.enum(['draft', 'active', 'archived']).default('draft'),
}),
updateProduct: z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().min(1).optional(),
price: z.number().positive().optional(),
stock: z.number().int().nonnegative().optional(),
category_id: z.number().int().positive().optional(),
status: z.enum(['draft', 'active', 'archived']).optional(),
}),
createOrder: z.object({
items: z.array(z.object({
product_id: z.number().int().positive(),
quantity: z.number().int().positive(),
})).min(1, 'Order must contain at least one item'),
shipping_address: z.object({
name: z.string().min(1),
street: z.string().min(1),
city: z.string().min(1),
country: z.string().length(2),
postal: z.string().min(1),
}),
notes: z.string().max(1000).optional(),
}),
pagination: z.object({
page: z.coerce.number().int().positive().default(1),
per_page: z.coerce.number().int().positive().max(100).default(20),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
}),
productSearch: z.object({
status: z.enum(['draft', 'active', 'archived']).optional(),
category: z.string().optional(),
search: z.string().max(100).optional(),
min_price: z.coerce.number().positive().optional(),
max_price: z.coerce.number().positive().optional(),
}),
};
Zod validates and transforms input in one step. z.coerce.number() converts the string "1" from a query parameter to the number 1. z.string().email() validates and normalizes email format. When validation fails, Zod produces detailed error messages that map to specific fields. The ValidationError class carries those field-level details to the client.
Error Handler
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors';
import { logger } from '../utils/logger';
import { config } from '../config';
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction
): void {
if (err instanceof AppError) {
if (err.statusCode >= 500) {
logger.error('Application error', {
request_id: req.requestId,
code: err.code,
message: err.message,
stack: err.stack,
});
}
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
...(err.details ? { details: err.details } : {}),
request_id: req.requestId,
},
});
return;
}
logger.error('Unhandled error', {
request_id: req.requestId,
message: err.message,
stack: err.stack,
});
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: config.NODE_ENV === 'production' ? 'An internal error occurred' : err.message,
request_id: req.requestId,
},
});
}
One error handler handles all errors. Application errors (subclasses of AppError) get their status code and error code sent to the client. Unknown errors get a 500 with a generic message in production (you do not want to leak stack traces to users) and the actual message in development. Both cases log the full error with stack trace.
Auth Routes
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { query, queryOne, transaction } from '../database';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../utils/jwt';
import { authenticate } from '../middleware/auth';
import { validate, schemas } from '../middleware/validate';
import { config } from '../config';
import { ConflictError, UnauthorizedError, NotFoundError } from '../utils/errors';
import { logger } from '../utils/logger';
const router = Router();
router.post('/register', validate(schemas.register), async (req: Request, res: Response) => {
const { username, email, password } = req.body as {
username: string; email: string; password: string;
};
const existing = await queryOne<{ id: number }>(
'SELECT id FROM users WHERE email = $1 OR username = $2',
[email, username]
);
if (existing) throw new ConflictError('Username or email already registered');
const passwordHash = await bcrypt.hash(password, config.BCRYPT_ROUNDS);
const user = await queryOne<{ id: number; username: string; email: string; is_admin: boolean }>(
`INSERT INTO users (username, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, username, email, is_admin`,
[username, email, passwordHash]
);
if (!user) throw new Error('Failed to create user');
const tokenPayload = { sub: user.id, username: user.username, is_admin: user.is_admin };
const accessToken = signAccessToken(tokenPayload);
const refreshToken = signRefreshToken(tokenPayload);
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
);
logger.info('User registered', { user_id: user.id, username: user.username });
res.status(201).json({
data: { id: user.id, username: user.username, email: user.email },
access_token: accessToken,
refresh_token: refreshToken,
});
});
router.post('/login', validate(schemas.login), async (req: Request, res: Response) => {
const { login, password } = req.body as { login: string; password: string };
const user = await queryOne<{
id: number; username: string; email: string;
password_hash: string; is_active: boolean; is_admin: boolean;
}>(
'SELECT id, username, email, password_hash, is_active, is_admin FROM users WHERE email = $1 OR username = $1',
[login]
);
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
throw new UnauthorizedError('Invalid credentials');
}
if (!user.is_active) throw new UnauthorizedError('Account is disabled');
await query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]);
const tokenPayload = { sub: user.id, username: user.username, is_admin: user.is_admin };
const accessToken = signAccessToken(tokenPayload);
const refreshToken = signRefreshToken(tokenPayload);
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
);
res.json({
data: { id: user.id, username: user.username, email: user.email },
access_token: accessToken,
refresh_token: refreshToken,
});
});
router.post('/refresh', async (req: Request, res: Response) => {
const { refresh_token } = req.body as { refresh_token?: string };
if (!refresh_token) throw new UnauthorizedError('Refresh token required');
const payload = verifyRefreshToken(refresh_token);
const tokenHash = crypto.createHash('sha256').update(refresh_token).digest('hex');
const stored = await queryOne<{ id: number; revoked: boolean; expires_at: Date }>(
'SELECT id, revoked, expires_at FROM refresh_tokens WHERE token_hash = $1',
[tokenHash]
);
if (!stored || stored.revoked || stored.expires_at < new Date()) {
throw new UnauthorizedError('Invalid or expired refresh token');
}
await query('UPDATE refresh_tokens SET revoked = true WHERE id = $1', [stored.id]);
const user = await queryOne<{ id: number; username: string; is_active: boolean; is_admin: boolean }>(
'SELECT id, username, is_active, is_admin FROM users WHERE id = $1',
[payload.sub]
);
if (!user || !user.is_active) throw new UnauthorizedError('User not found or inactive');
const tokenPayload = { sub: user.id, username: user.username, is_admin: user.is_admin };
const newAccess = signAccessToken(tokenPayload);
const newRefresh = signRefreshToken(tokenPayload);
const newHash = crypto.createHash('sha256').update(newRefresh).digest('hex');
const newExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, newHash, newExpires]
);
res.json({ access_token: newAccess, refresh_token: newRefresh });
});
router.post('/logout', authenticate, async (req: Request, res: Response) => {
await query(
'UPDATE refresh_tokens SET revoked = true WHERE user_id = $1 AND revoked = false',
[req.user!.sub]
);
res.status(204).send();
});
router.get('/me', authenticate, async (req: Request, res: Response) => {
const user = await queryOne<{ id: number; username: string; email: string; created_at: Date }>(
'SELECT id, username, email, created_at FROM users WHERE id = $1',
[req.user!.sub]
);
if (!user) throw new NotFoundError('User');
res.json({ data: user });
});
export default router;
The refresh token rotation pattern (revoke the old token, issue a new one) prevents refresh token replay attacks. If a stolen token is used, the legitimate user's next refresh attempt fails because the token was already revoked, alerting them that their session was compromised.
Refresh tokens are stored as SHA-256 hashes, not plaintext. If the database is compromised, attackers get hashes, not the tokens themselves.
Products Routes
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../database';
import { authenticate, requireAdmin, optionalAuth } from '../middleware/auth';
import { validate, schemas } from '../middleware/validate';
import { NotFoundError, ForbiddenError, ConflictError } from '../utils/errors';
const router = Router();
router.get('/', optionalAuth, validate(schemas.pagination, 'query'), validate(schemas.productSearch, 'query'),
async (req: Request, res: Response) => {
const { page, per_page, order } = req.query as any;
const { status, category, search, min_price, max_price } = req.query as any;
const conditions: string[] = [];
const params: unknown[] = [];
let idx = 1;
const statusFilter = status ?? (req.user?.is_admin ? undefined : 'active');
if (statusFilter) {
conditions.push(`p.status = $${idx++}`);
params.push(statusFilter);
}
if (category) {
conditions.push(`c.slug = $${idx++}`);
params.push(category);
}
if (search) {
conditions.push(`(p.name ILIKE $${idx} OR p.description ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
if (min_price) { conditions.push(`p.price >= $${idx++}`); params.push(min_price); }
if (max_price) { conditions.push(`p.price <= $${idx++}`); params.push(max_price); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = await query<{ total: string }>(
`SELECT COUNT(*) AS total FROM products p
LEFT JOIN categories c ON c.id = p.category_id ${where}`,
params
);
const total = parseInt(countRow?.total ?? '0', 10);
const offset = (page - 1) * per_page;
const rows = await query(
`SELECT p.id, p.name, p.slug, p.price, p.stock, p.status,
p.created_at, c.name AS category_name, c.slug AS category_slug
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
${where}
ORDER BY p.created_at ${order === 'asc' ? 'ASC' : 'DESC'}
LIMIT $${idx} OFFSET $${idx + 1}`,
[...params, per_page, offset]
);
res.json({
data: rows,
pagination: {
page, per_page, total,
pages: Math.ceil(total / per_page),
},
});
});
router.get('/:slug', async (req: Request, res: Response) => {
const product = await queryOne(
`SELECT p.*, c.name AS category_name, c.slug AS category_slug,
u.username AS owner_username
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN users u ON u.id = p.owner_id
WHERE p.slug = $1`,
[req.params['slug']]
);
if (!product) throw new NotFoundError('Product', req.params['slug']);
res.json({ data: product });
});
router.post('/', authenticate, validate(schemas.createProduct), async (req: Request, res: Response) => {
const { name, description, price, stock, category_id, status } = req.body;
const slug = generateSlug(name);
const existing = await queryOne<{ id: number }>('SELECT id FROM products WHERE slug = $1', [slug]);
const finalSlug = existing ? `${slug}-${Date.now().toString(36)}` : slug;
const product = await queryOne(
`INSERT INTO products (name, slug, description, price, stock, category_id, owner_id, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
[name, finalSlug, description, price, stock, category_id, req.user!.sub, status]
);
res.status(201).json({ data: product });
});
router.patch('/:id', authenticate, validate(schemas.updateProduct), async (req: Request, res: Response) => {
const id = parseInt(req.params['id']!, 10);
const product = await queryOne<{ id: number; owner_id: number }>(
'SELECT id, owner_id FROM products WHERE id = $1', [id]
);
if (!product) throw new NotFoundError('Product', id);
if (product.owner_id !== req.user!.sub && !req.user!.is_admin) {
throw new ForbiddenError('You do not own this product');
}
const fields = req.body as Record<string, unknown>;
const updates: string[] = [];
const params: unknown[] = [];
let idx = 1;
const allowed = ['name', 'description', 'price', 'stock', 'category_id', 'status'];
for (const field of allowed) {
if (field in fields) {
updates.push(`${field} = $${idx++}`);
params.push(fields[field]);
}
}
if (!updates.length) { res.json({ data: product }); return; }
updates.push(`updated_at = NOW()`);
params.push(id);
const updated = await queryOne(
`UPDATE products SET ${updates.join(', ')} WHERE id = $${idx} RETURNING *`,
params
);
res.json({ data: updated });
});
router.delete('/:id', authenticate, async (req: Request, res: Response) => {
const id = parseInt(req.params['id']!, 10);
const product = await queryOne<{ id: number; owner_id: number }>(
'SELECT id, owner_id FROM products WHERE id = $1', [id]
);
if (!product) throw new NotFoundError('Product', id);
if (product.owner_id !== req.user!.sub && !req.user!.is_admin) {
throw new ForbiddenError('You do not own this product');
}
await query('DELETE FROM products WHERE id = $1', [id]);
res.status(204).send();
});
function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
export default router;
Server Entry Point and Graceful Shutdown
import { app } from './app';
import { config } from './config';
import { closePool } from './database';
import { logger } from './utils/logger';
const server = app.listen(config.PORT, () => {
logger.info('Server started', { port: config.PORT, env: config.NODE_ENV });
});
async function shutdown(signal: string): Promise<void> {
logger.info(`Received ${signal}. Starting graceful shutdown`);
server.close(async () => {
logger.info('HTTP server closed');
try {
await closePool();
logger.info('Graceful shutdown complete');
process.exit(0);
} catch (err) {
logger.error('Error during shutdown', { error: (err as Error).message });
process.exit(1);
}
});
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { error: err.message, stack: err.stack });
void shutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection', { reason: String(reason) });
});
Graceful shutdown lets in-flight requests complete before the server exits. server.close() stops accepting new connections and calls the callback when all active connections have finished. Without graceful shutdown, a deployment terminates connections mid-request, causing errors for users who happened to be making requests when the deployment happened. The 30-second timeout forces shutdown if requests are taking too long to complete.
Testing
import request from 'supertest';
import { app } from '../src/app';
import { query } from '../src/database';
beforeEach(async () => {
await query('TRUNCATE TABLE order_items, orders, products, refresh_tokens, users, categories RESTART IDENTITY CASCADE');
await query("INSERT INTO categories (name, slug) VALUES ('Electronics', 'electronics')");
});
describe('POST /api/v1/auth/register', () => {
it('creates a user and returns tokens', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({ username: 'testuser', email: '[email protected]', password: 'password123' });
expect(res.status).toBe(201);
expect(res.body.data.username).toBe('testuser');
expect(res.body.access_token).toBeDefined();
expect(res.body.refresh_token).toBeDefined();
});
it('returns 409 for duplicate email', async () => {
const payload = { username: 'user1', email: '[email protected]', password: 'password123' };
await request(app).post('/api/v1/auth/register').send(payload);
const res = await request(app).post('/api/v1/auth/register').send({ ...payload, username: 'user2' });
expect(res.status).toBe(409);
expect(res.body.error.code).toBe('CONFLICT');
});
it('returns 400 for short password', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({ username: 'user', email: '[email protected]', password: 'short' });
expect(res.status).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
});
async function registerAndLogin(): Promise<string> {
await request(app).post('/api/v1/auth/register')
.send({ username: 'tester', email: '[email protected]', password: 'password123' });
const loginRes = await request(app).post('/api/v1/auth/login')
.send({ login: 'tester', password: 'password123' });
return loginRes.body.access_token as string;
}
describe('GET /api/v1/products', () => {
it('returns empty list when no products', async () => {
const res = await request(app).get('/api/v1/products');
expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
expect(res.body.pagination.total).toBe(0);
});
it('returns active products only for unauthenticated users', async () => {
const token = await registerAndLogin();
await request(app).post('/api/v1/products').set('Authorization', `Bearer ${token}`)
.send({ name: 'Draft Product', description: 'desc', price: 9.99, stock: 10, category_id: 1, status: 'draft' });
await request(app).post('/api/v1/products').set('Authorization', `Bearer ${token}`)
.send({ name: 'Active Product', description: 'desc', price: 9.99, stock: 10, category_id: 1, status: 'active' });
const res = await request(app).get('/api/v1/products');
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].name).toBe('Active Product');
});
});
describe('POST /api/v1/products', () => {
it('creates a product when authenticated', async () => {
const token = await registerAndLogin();
const res = await request(app).post('/api/v1/products')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Test Product', description: 'A product', price: 19.99, stock: 50, category_id: 1 });
expect(res.status).toBe(201);
expect(res.body.data.name).toBe('Test Product');
expect(res.body.data.slug).toBeDefined();
});
it('returns 401 without authentication', async () => {
const res = await request(app).post('/api/v1/products')
.send({ name: 'Product', description: 'desc', price: 9.99, category_id: 1 });
expect(res.status).toBe(401);
});
it('returns 400 for negative price', async () => {
const token = await registerAndLogin();
const res = await request(app).post('/api/v1/products')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Bad Product', description: 'desc', price: -5, category_id: 1 });
expect(res.status).toBe(400);
expect(res.body.error.details).toContainEqual(
expect.objectContaining({ field: 'price' })
);
});
});
Supertest makes HTTP requests against your Express app without starting a network server. Tests run in-process, which is fast and deterministic. The TRUNCATE ... RESTART IDENTITY CASCADE before each test resets the database to a clean state. Every test starts with predictable data.
This is integration testing, not unit testing. Integration tests exercise the full stack: middleware, validation, route handlers, database queries. They catch bugs that unit tests of individual functions miss. They are slower than unit tests but far more valuable for API code where the integration between layers is where bugs live.
Run the full test suite:
npm test
Targeting 80% coverage on your route handlers and middleware is a reasonable starting goal. 100% coverage is usually not worth the effort and produces brittle tests that break when implementation details change.