| name | api-patterns |
| description | API design, versioning, testing, schema validation, and contract testing patterns for REST and GraphQL APIs. |
API Patterns
REST and GraphQL API design patterns for consistent, versioned, and well-tested interfaces.
API Versioning
URL-Based Versioning
import { Router } from 'express'
const v1Router = Router()
const v2Router = Router()
app.use('/api/v1', v1Router)
app.use('/api/v2', v2Router)
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
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'
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)
})
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().()
})
= z.< >
= z.< >
validate<T>(: z.<T>) {
{
result = schema.(req.)
(!result.) {
res.().({
: ,
: ,
: ,
: result..()
})
}
req. = result.
()
}
}
router.(, (), (req, res) => {
dto = req.
market = marketService.(dto)
response = .(market)
res.().({ : , : response })
})
Standardized Error Responses
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 {
res.([code]).({
: ,
: message,
code,
details,
: res..
} )
}
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>> {
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') : ,
: cursor,
hasMore
}
}
router.(, (req, res) => {
cursor = req.. |
limit = .((req.. ) || , )
decoded = cursor ? .(cursor, ).() :
page = (
db..({
: l,
: c ? : ,
: c ? { : c } : ,
: { : }
}),
decoded,
limit
)
res.({ : , ...page })
})
Offset Pagination (Simple use cases)
interface OffsetPage<T> {
items: T[]
total: number
page: number
limit: number
totalPages: number
}
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
): 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]
{
: allowed === ,
remaining,
: allowed ? : .( / refillRate)
}
}
() {
(: , : , : ) => {
key = req.?. || req. ||
result = (key, capacity, refillRate)
res.(, capacity)
res.(, result.)
(!result.) {
res.(, result.)
(res, , )
}
()
}
}
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)
.()
(res..).()
(res..).({
: expect.(),
: payload.,
:
})
})
(, () => {
res = request
.()
.(, )
.({ : })
.()
(res..).()
(res..).()
})
(, () => {
res = request.().({}).()
(res..).()
})
})
Breaking Change Detection Checklist
Before releasing a new API version, verify:
BREAKING changes (require version bump):
[ ] Removed a field from response
[ ] Changed field type (string → number)
[ ] Renamed a field
[ ] Changed HTTP method
[ ] Removed an endpoint
[ ] Changed required → optional (ok) vs optional → required (breaking)
[ ] Changed error codes/format
NON-BREAKING changes (safe to ship):
[ ] Added new optional fields to response
[ ] Added new optional query parameters
[ ] Added new endpoints
[ ] Added new enum values (check client handling)
[ ] Relaxed validation rules
OpenAPI Spec Generation
import { extendZodWithOpenApi } from 'zod-to-openapi'
import { z } from 'zod'
extendZodWithOpenApi(z)
const MarketSchema = z.object({
id: z.string().uuid().openapi({ example: 'abc-123' }),
name: z.string().openapi({ example: 'Will BTC hit 100k?' }),
status: z.enum(['open', 'closed', 'resolved'])
}).openapi('Market')
Plan-Based Authorization
Tier-Aware Middleware
enum PlanTier {
FREE = 'free',
PRO = 'pro',
ENTERPRISE = 'enterprise'
}
interface PlanLimits {
tier: PlanTier
rateLimit: number
maxItems: number
features: Set<string>
}
const PLAN_LIMITS: Record<PlanTier, PlanLimits> = {
[PlanTier.FREE]: { tier: PlanTier.FREE, rateLimit: 60, maxItems: 100, features: new Set(['read']) },
[PlanTier.PRO]: { tier: PlanTier.PRO, rateLimit: 600, maxItems: 10_000, features: new Set(['read', , ]) },
[.]: { : ., : , : , : ([, , , , ]) },
}
() {
{
plan = [req.. ]
(!plan..(feature)) {
(res, , )
}
()
}
}
() {
(: , : , : ) => {
plan = [req.. ]
current = (req..)
(current >= plan.) {
(res, , )
}
()
}
}
router.(, (), (req, res) => { })
router.(, (countUserProjects), (req, res) => { })
Serverless Rate Limiting
Sliding Window without Redis
const windows = new Map<string, number[]>()
function slidingWindowRateLimit(
key: string,
maxRequests: number,
windowMs: number
): { allowed: boolean; remaining: number; retryAfter: number } {
const now = Date.now()
const windowStart = now - windowMs
const timestamps = windows.get(key) ?? []
const valid = timestamps.filter(t => t > windowStart)
if (valid.length >= maxRequests) {
const oldestInWindow = valid[0]
const retryAfter = Math.ceil((oldestInWindow + windowMs - now) / 1000)
return { allowed: false, remaining: 0, retryAfter }
}
valid.push(now)
windows.(key, valid)
{ : , : maxRequests - valid., : }
}
( {
cutoff = .() -
( [key, timestamps] windows) {
valid = timestamps.( t > cutoff)
(valid. === ) windows.(key)
windows.(key, valid)
}
}, )
API Key Authentication
import { randomBytes, createHash, timingSafeEqual } from 'crypto'
function generateApiKey(prefix: string): { raw: string; hash: string } {
const raw = `${prefix}_${randomBytes(24).toString('base64url')}`
const hash = createHash('sha256').update(raw).digest('hex')
return { raw, hash }
}
async function verifyApiKey(rawKey: string): Promise<ApiKeyRecord | null> {
const hash = createHash('sha256').update(rawKey).digest('hex')
return db.apiKey.findFirst({
where: { hash, revokedAt: null, : { : () } }
})
}
() {
key = req.[]
(!key) (res, , )
record = (key)
(!record) (res, , )
(!record..(req..())) {
(res, , )
}
req. = { : record., : record. }
()
}
Usage Metering and Quota Management
interface UsageRecord {
tenantId: string
metric: string
value: number
period: string
}
async function trackUsage(tenantId: string, metric: string, increment: number): Promise<void> {
const period = new Date().toISOString().slice(0, 7)
await db.usage.upsert({
where: { tenantId_metric_period: { tenantId, metric, period } },
update: { value: { increment } },
create: { tenantId, metric, period, value: increment }
})
}
async function checkQuota(tenantId: string, metric: string, limit: number): Promise<boolean> {
period = ().().(, )
usage = db..({
: { : { tenantId, metric, period } }
})
(usage?. ?? ) < limit
}
() {
(: , : , : ) => {
plan = [req.. ]
withinQuota = (req.., metric, plan. * * )
(!withinQuota) {
(res, , )
}
(req.., metric, increment)
()
}
}
Remember: Consistent versioning and validation contracts make APIs maintainable across client teams and breaking-change deployments.