res.status(200).json(envelope.success(results, filter.pagination));
});
// Single resource
catalogRouter.get('/deployments/:deploymentId', async (req: Request, res: Response) => {
const target = await resolveResource.fetch('deployment', req.params.deploymentId);
if (!target) return envelope.notFound(res, 'deployment', req.params.deploymentId);
res.status(200).json(envelope.success(target));
});
// Full replacement (idempotent)
catalogRouter.put('/deployments/:deploymentId', validatePayload(updateSchema), async (req: Request, res: Response) => {
const updated = await resolveResource.replace('deployment', req.params.deploymentId, req.body);
res.status(200).json(envelope.success(updated));
});
// Partial modification (non-idempotent)
catalogRouter.patch('/deployments/:deploymentId', validatePayload(patchSchema), async (req: Request, res: Response) => {
const modified = await resolveResource.modify('deployment', req.params.deploymentId, req.body);
res.status(200).json(envelope.success(modified));
});
// Action endpoint (when CRUD is insufficient)
catalogRouter.post('/deployments/:deploymentId/rollback', async (req: Request, res: Response) => {
const outcome = await resolveResource.executeAction('deployment', req.params.deploymentId, 'rollback');
res.status(202).json(envelope.accepted(outcome));
});
export { catalogRouter };
**Why this works:** Path segments remain nouns. HTTP methods dictate intent. Action endpoints are isolated to non-CRUD operations and return `202 Accepted` to signal asynchronous processing. This structure aligns with HTTP caching rules and enables predictable retry behavior.
### Step 2: Enforce a Standard Response Envelope
Clients should never parse raw database rows or handle inconsistent error shapes. Every response must wrap data in a predictable envelope. Success responses carry a `data` payload and optional `meta` fields. Error responses carry a structured `error` object with machine-readable codes, field-level details, and a traceable request identifier.
```typescript
interface ApiEnvelope<T = unknown> {
status: 'success' | 'error';
data?: T;
error?: {
code: string;
message: string;
details?: Array<{ field: string; issue: string }>;
traceId: string;
};
meta?: {
pagination?: { page: number; limit: number; total: number; totalPages: number };
rateLimit?: { remaining: number; resetAt: string };
};
}
const envelope = {
success: <T>(payload: T, pagination?: ApiEnvelope['meta']['pagination']): ApiEnvelope<T> => ({
status: 'success',
data: payload,
meta: pagination ? { pagination } : undefined,
}),
error: (code: string, message: string, traceId: string, details?: ApiEnvelope['error']['details']): ApiEnvelope => ({
status: 'error',
error: { code, message, details, traceId },
}),
notFound: (res: Response, resource: string, id: string) => {
const trace = res.locals.traceId;
res.status(404).json(envelope.error('RESOURCE_MISSING', `${resource} not found (id: ${id})`, trace));
},
accepted: <T>(payload: T): ApiEnvelope<T> => ({
status: 'success',
data: payload,
meta: { pagination: undefined },
}),
};
Why this works: The envelope decouples transport from domain. Clients can implement a single interceptor to handle status: 'error' globally. Field-level validation details enable UI form highlighting without custom parsing logic. Trace IDs bridge frontend errors to backend logs without exposing internals.
URL parameters should drive filtering, sorting, pagination, and field projection. The server must parse, validate, and cap these inputs before they reach the data layer. Unbounded queries cause database exhaustion; missing caps on limit or page are a common production failure mode.
interface QueryPipeline {
page: number;
limit: number;
sort: string[];
filters: Record<string, string | string[]>;
projection: string[];
eagerLoad: string[];
}
function buildFilterPipeline(raw: Record<string, unknown>): QueryPipeline {
const page = Math.max(1, Number(raw.page) || 1);
const limit = Math.min(100, Math.max(1, Number(raw.limit) || 20));
const sortRaw = typeof raw.sort === 'string' ? raw.sort : '-createdAt';
const sort = sortRaw.split(',').map(s => s.trim());
const projection = typeof raw.fields === 'string'
? raw.fields.split(',').map(f => f.trim())
: [];
const eagerLoad = typeof raw.include === 'string'
? raw.include.split(',').map(i => i.trim())
: [];
const filters: Record<string, string | string[]> = {};
const reserved = new Set(['page', 'limit', 'sort', 'fields', 'include']);
for (const [key, value] of Object.entries(raw)) {
if (!reserved.has(key) && typeof value === 'string') {
filters[key] = value.includes(',') ? value.split(',').map(v => v.trim()) : value;
}
}
return { page, limit, sort, filters, projection, eagerLoad };
}
Why this works: Reserved keywords are isolated from domain filters. Limits are capped at the transport layer to prevent query amplification. Sorting supports multi-field precedence via comma separation. Projections and eager loading are explicitly declared, preventing N+1 queries and over-fetching.
Step 4: Enforce Statelessness & Authentication Contracts
Every request must contain all necessary context. Session storage on the API gateway or application server breaks horizontal scaling and complicates failover. Authentication should be stateless by default: Bearer tokens for user-facing clients, API keys for service-to-service communication, and OAuth 2.0 for delegated third-party access.
Rate limiting must be communicated on every response, not just on failure. Headers like X-RateLimit-Remaining and X-RateLimit-Reset allow clients to implement exponential backoff without guessing. When limits are exceeded, the 429 response must include a Retry-After value and a structured error envelope.
// Middleware attachment example
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
res.locals.authScope = extractScopes(authHeader.split(' ')[1]);
}
// Attach rate limit headers from upstream counter
const rl = res.locals.rateLimit;
res.set('X-RateLimit-Limit', String(rl.limit));
res.set('X-RateLimit-Remaining', String(rl.remaining));
res.set('X-RateLimit-Reset', String(rl.resetTimestamp));
next();
});
Why this works: Statelessness enables stateless load balancers and CDN edge caching. Auth scopes are resolved early and attached to the request context for downstream authorization checks. Rate limit headers are standardized, enabling client-side throttling logic that respects server capacity.
Pitfall Guide
1. The Verb Trap in Path Segments
Explanation: Developers frequently route POST /api/getUser or POST /api/createOrder. This violates REST uniformity and forces clients to memorize action names instead of leveraging HTTP method semantics.
Fix: Replace verbs with nouns. Use GET /deployments/:id for retrieval and POST /deployments for creation. Reserve POST for non-idempotent actions only when CRUD is insufficient.
2. Inconsistent Error Envelopes
Explanation: Some endpoints return { error: string }, others return { message: string }, and some leak stack traces. Clients must write custom parsers for each route.
Fix: Enforce a single error shape across all routes. Use a centralized error handler that catches exceptions, assigns a trace ID, and returns the standard { status: 'error', error: { code, message, details, traceId } } structure.
3. Misusing PUT vs PATCH
Explanation: PUT implies full resource replacement. Sending a partial payload with PUT often results in data loss or validation failures. PATCH is designed for partial updates but is frequently mislabeled as PUT.
Fix: Use PUT only when the client sends the complete resource representation. Use PATCH for field-level modifications. Document the expected payload shape explicitly in the contract.
Explanation: Returning all records for a collection endpoint causes memory exhaustion, slow serialization, and client-side rendering freezes.
Fix: Enforce default pagination (page=1, limit=20). Cap maximum limits at the gateway. Return meta.pagination with total and totalPages so clients can implement virtual scrolling or lazy loading.
Explanation: Clients cannot implement intelligent backoff if rate limit state is only visible on 429 responses. Missing headers force polling or fixed delays.
Fix: Attach X-RateLimit-* headers to every response, including 200 and 4xx. Include Retry-After on 429 payloads. Align header values with the actual token bucket or sliding window counter.
6. Versioning via Query Parameters
Explanation: Using ?v=2 or &version=2 breaks caching, complicates routing rules, and makes deprecation tracking difficult.
Fix: Use URL path versioning (/api/v2/deployments) for public APIs. It is explicit, CDN-friendly, and allows parallel routing during migration. Reserve header versioning for internal microservices where URL cleanliness is prioritized.
7. Exposing Internal Stack Traces in Production
Explanation: Returning database errors, file paths, or framework internals in 500 responses leaks architecture details and aids attackers.
Fix: Strip internals in production environments. Return a generic { code: 'INTERNAL_ERROR', message: 'An unexpected condition occurred' } alongside a traceId. Log the full stack server-side and correlate it with the trace ID for debugging.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public Partner API | URL Path Versioning (/api/v1/) | Explicit routing, CDN compatibility, clear deprecation lifecycle | Moderate (requires parallel route maintenance) |
| Internal Microservice | Header Versioning (Accept: application/vnd.api.v2+json) | Clean URLs, reduces routing table size, easier to automate | Low (simplifies gateway config) |
| Mobile-First Client | Envelope + Field Projection (?fields=id,name) | Reduces payload size, improves latency on cellular networks | Low (minimal server overhead) |
| High-Throughput Ingestion | POST with Idempotency Keys | Prevents duplicate processing on retries, enables safe backpressure | Moderate (requires key storage & deduplication logic) |
| Read-Heavy Dashboard | GET with ETag + Conditional Requests | Leverages HTTP caching, reduces database load significantly | Low (standard HTTP feature) |
Configuration Template
// api-standards.config.ts
export const ApiStandards = {
versioning: {
strategy: 'url-path', // 'url-path' | 'header' | 'none'
prefix: '/api',
current: 'v1',
},
pagination: {
defaultPage: 1,
defaultLimit: 20,
maxLimit: 100,
cursorEnabled: false, // switch to true for high-volume datasets
},
sorting: {
defaultField: 'createdAt',
defaultDirection: 'desc',
allowedFields: ['createdAt', 'updatedAt', 'name', 'status'],
},
errorCodes: {
VALIDATION_FAILURE: 'VALIDATION_FAILURE',
RESOURCE_MISSING: 'RESOURCE_MISSING',
ACCESS_DENIED: 'ACCESS_DENIED',
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
INTERNAL_FAILURE: 'INTERNAL_FAILURE',
},
rateLimiting: {
unauthenticated: { windowMs: 3600000, max: 30 },
authenticated: { windowMs: 3600000, max: 100 },
premium: { windowMs: 3600000, max: 1000 },
headers: {
limit: 'X-RateLimit-Limit',
remaining: 'X-RateLimit-Remaining',
reset: 'X-RateLimit-Reset',
},
},
response: {
envelope: true,
includeTraceId: true,
stripStackTraces: true,
camelCaseJson: true,
snakeCaseHeaders: true,
},
};
Quick Start Guide
- Initialize the contract scaffold: Create a new route file and import the standard envelope, query parser, and validation middleware. Define your resource path using nouns only.
- Attach the response interceptor: Register the envelope middleware globally or per-router. Ensure it wraps all
res.json() calls and catches unhandled exceptions to return the standard error shape.
- Configure query parsing: Import
buildFilterPipeline and pass req.query to it. Use the returned QueryPipeline object to construct your database query, applying pagination caps and field projections.
- Enforce HTTP semantics: Map
GET to safe reads, PUT to full replacements, PATCH to partial updates, and POST to creation or non-idempotent actions. Add idempotency keys for critical POST endpoints.
- Validate and document: Run the endpoint against an OpenAPI generator. Verify that rate limit headers appear on every response, error payloads match the contract, and pagination metadata is present for collections. Ship with confidence.