API Patterns
REST and GraphQL API design patterns for consistent, versioned, and well-tested interfaces.
API Versioning
URL-Based Versioning
// routes/v1/markets.ts
// routes/v2/markets.ts
// URL: /api/v1/markets, /api/v2/markets
// Express router setup
import { Router } from 'express'
const v1Router = Router()
const v2Router = Router()
app.use('/api/v1', v1Router)
app.use('/api/v2', v2Router)
// Deprecation header middleware
function deprecationWarning(version: string, sunsetDate: string) {
return (_req: Request, res: Response, next: NextFunction) => {
res.setHeader('Deprecation', 'true')
res.setHeader('Sunset', sunsetDate)
res.setHeader('Link', `</api/v${parseInt(version) + 1}>; rel="successor-version"`)
next()
}
}
v1Router.use(deprecationWarning('1', 'Sat, 01 Jan 2027 00:00:00 GMT'))
Header-Based Versioning
// Accept: application/vnd.api+json;version=2
function versionMiddleware(req: Request, res: Response, next: NextFunction) {
const accept = req.headers['accept'] || ''
const match = accept.match(/version=(\d+)/)
req.apiVersion = match ? parseInt(match[1]) : 1
next()
}
Schema Validation with Zod
Request + Response Validation
import { z } from 'zod'
// Request schema
const CreateMarketSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
category: z.enum(['sports', 'politics', 'crypto', 'tech']),
closeAt: z.string().datetime(),
initialLiquidity: z.number().positive().max(1_000_000)
})
// Response schema - strip internal fields
const MarketResponseSchema = z.object({
id: z.string().uuid(),
name: z.string(),
category: z.string(),
status: z.enum(['open', 'closed', 'resolved']),
volume: z.number(),
createdAt: z.string().datetime()
})
type CreateMarketDto = z.infer<typeof CreateMarketSchema>
type MarketResponse = z.infer<typeof MarketResponseSchema>
// Validation middleware
function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({
success: false,
error: 'Validation failed',
code: 'VALIDATION_ERROR',
details: result.error.flatten()
})
}
req.validated = result.data
next()
}
}
// Usage
router.post('/markets', validate(CreateMarketSchema), async (req, res) => {
const dto = req.validated as CreateMarketDto
const market = await marketService.create(dto)
const response = MarketResponseSchema.parse(market)
res.status(201).json({ success: true, data: response })
})
Standardized Error Responses
// Always: { success, error, code, details? }
interface ApiError {
success: false
error: string
code: string
details?: unknown
requestId?: string
}
interface ApiSuccess<T> {
success: true
data: T
meta?: { total?: number; page?: number; limit?: number }
}
const ERROR_CODES = {
VALIDATION_ERROR: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
RATE_LIMITED: 429,
INTERNAL: 500
} as const
function apiError(
res: Response,
code: keyof typeof ERROR_CODES,
message: string,
details?: unknown
): Response {
return res.status(ERROR_CODES[code]).json({
success: false,
error: message,
code,
details,
requestId: res.locals.requestId
} satisfies ApiError)
}
Pagination Patterns
Cursor-Based Pagination (Recommended for large datasets)
interface CursorPage<T> {
items: T[]
nextCursor: string | null
prevCursor: string | null
hasMore: boolean
}
async function paginateWithCursor<T extends { id: string; createdAt: Date }>(
query: (cursor: string | null, limit: number) => Promise<T[]>,
cursor: string | null,
limit = 20
): Promise<CursorPage<T>> {
// Fetch one extra to detect hasMore
const items = await query(cursor, limit + 1)
const hasMore = items.length > limit
const page = hasMore ? items.slice(0, limit) : items
return {
items: page,
nextCursor: hasMore ? Buffer.from(page[page.length - 1].id).toString('base64') : null,
prevCursor: cursor,
hasMore
}
}
// GET /api/markets?cursor=<base64>&limit=20
router.get('/markets', async (req, res) => {
const cursor = req.query.cursor as string | null
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100)
const decoded = cursor ? Buffer.from(cursor, 'base64').toString() : null
const page = await paginateWithCursor(
(c, l) => db.market.findMany({
take: l,
skip: c ? 1 : 0,
cursor: c ? { id: c } : undefined,
orderBy: { createdAt: 'desc' }
}),
decoded,
limit
)
res.json({ success: true, ...page })
})
Offset Pagination (Simple use cases)
interface OffsetPage<T> {
items: T[]
total: number
page: number
limit: number
totalPages: number
}
// GET /api/markets?page=2&limit=20
Rate Limiting
Token Bucket with Redis
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
async function tokenBucket(
key: string,
capacity: number,
refillRate: number // tokens per second
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
const now = Date.now()
const bucketKey = `ratelimit:${key}`
const script = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
local elapsed = (now - last_refill) / 1000
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 1)
return {1, math.floor(tokens)}
else
return {0, 0}
end
`
const [allowed, remaining] = await redis.eval(
script, 1, bucketKey, capacity, refillRate, now
) as [number, number]
return {
allowed: allowed === 1,
remaining,
resetIn: allowed ? 0 : Math.ceil(1 / refillRate)
}
}
function rateLimitMiddleware(capacity: number, refillRate: number) {
return async (req: Request, res: Response, next: NextFunction) => {
const key = req.user?.id || req.ip || 'anonymous'
const result = await tokenBucket(key, capacity, refillRate)
res.setHeader('X-RateLimit-Limit', capacity)
res.setHeader('X-RateLimit-Remaining', result.remaining)
if (!result.allowed) {
res.setHeader('Retry-After', result.resetIn)
return apiError(res, 'RATE_LIMITED', 'Too many requests')
}
next()
}
}
API Endpoint Testing
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import supertest from 'supertest'
import { app } from '../app'
import { db } from '../db'
const request = supertest(app)
describe('POST /api/v1/markets', () => {
let authToken: string
beforeAll(async () => {
authToken = await getTestToken()
})
it('creates a market with valid payload', async () => {
const payload = {
name: 'Will BTC reach 100k?',
category: 'crypto',
closeAt: new Date(Date.now() + 86400000).toISOString(),
initialLiquidity: 1000
}
const res = await request
.post('/api/v1/markets')
.set('Authorization', `Bearer ${authToken}`)
.send(payload)
.expect(201)
expect(res.body.success).toBe(true)
expect(res.body.data).toMatchObject({
id: expect.any(String),
name: payload.name,
status: 'open'
})
})
it('rejects invalid payload with 400', async () => {
const res = await request
.post('/api/v1/markets')
.set('Authorization', `Bearer ${authTok