REST APIs
A RESTful service organises endpoints around resources, uses HTTP verbs for CRUD, returns appropriate status codes, supports versioning, and documents itself with OpenAPI. The shape scales from a single Node process to a fleet. Get the conventions right and clients can be written without a phone call.
A versioned REST API with pagination, errors, and OpenAPI
EXAMPLE
// npm i express zod swagger-ui-express
import express from 'express';
import { z } from 'zod';
import swagger from 'swagger-ui-express';
const app = express();
app.use(express.json({ limit: '64kb' }));
// 1) Versioned router — /api/v1/* is a stable contract
const v1 = express.Router();
app.use('/api/v1', v1);
// 2) Resource: orders
const orders = new Map<string, any>();
const CreateOrder = z.object({
customer: z.string().min(1).max(120),
totalCents: z.number().int().nonnegative(),
items: z.array(z.object({
sku: z.string(), qty: z.number().int().positive(), priceCents: z.number().int().nonnegative()
})).min(1),
});
const ListQuery = z.object({
status: z.enum(['new', 'paid', 'shipped', 'cancelled']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
});
// 3) GET /orders — paged list with cursor
v1.get('/orders', (req, res, next) => {
try {
const q = ListQuery.parse(req.query);
const all = [...orders.values()].sort((a, b) => a.id.localeCompare(b.id));
const filtered = q.status ? all.filter((o) => o.status === q.status) : all;
const start = q.cursor ? filtered.findIndex((o) => o.id > q.cursor!) : 0;
const page = start < 0 ? [] : filtered.slice(start, start + q.limit);
const nextCursor = page.length === q.limit ? page[page.length - 1].id : null;
res.json({ data: page, nextCursor });
} catch (e) { next(e); }
});
// 4) POST /orders — create, return 201 + Location
v1.post('/orders', (req, res, next) => {
try {
const body = CreateOrder.parse(req.body);
const id = crypto.randomUUID();
const o = { id, ...body, status: 'new', createdAt: new Date().toISOString() };
orders.set(id, o);
res.status(201)
.header('Location', \`/api/v1/orders/${id}\`)
.json(o);
} catch (e) { next(e); }
});
// 5) GET /orders/:id
v1.get('/orders/:id', (req, res) => {
const o = orders.get(req.params.id);
if (!o) return res.status(404).json({ error: { code: 'not_found', message: 'order not found' } });
res.json(o);
});
// 6) PATCH /orders/:id — partial update with If-Match for concurrency
v1.patch('/orders/:id', (req, res) => {
const o = orders.get(req.params.id);
if (!o) return res.status(404).json({ error: { code: 'not_found' } });
const incoming = req.header('If-Match');
if (incoming && incoming !== etagOf(o)) {
return res.status(412).json({ error: { code: 'etag_mismatch' } });
}
Object.assign(o, req.body, { updatedAt: new Date().toISOString() });
res.json(o);
});
// 7) DELETE /orders/:id
v1.delete('/orders/:id', (req, res) => {
if (!orders.delete(req.params.id)) return res.status(404).end();
res.status(204).end();
});
// 8) Central error handler — JSON, consistent shape
app.use((err: any, _req: any, res: any, _next: any) => {
if (err instanceof z.ZodError) {
return res.status(400).json({ error: { code: 'validation', issues: err.issues } });
}
console.error(err);
res.status(500).json({ error: { code: 'internal' } });
});
// 9) OpenAPI — minimal hand-written spec, served at /docs
const openapi = {
openapi: '3.1.0',
info: { title: 'Shop API', version: '1.0.0' },
paths: {
'/api/v1/orders': {
get: { summary: 'List orders', parameters: [
{ name: 'status', in: 'query', schema: { type: 'string', enum: ['new','paid','shipped','cancelled'] } },
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 20, maximum: 100 } },
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
]},
post: { summary: 'Create an order', requestBody: { required: true } },
},
'/api/v1/orders/{id}': {
get: { summary: 'Get one order' },
patch: { summary: 'Partial update; supports If-Match' },
delete: { summary: 'Delete an order' },
},
},
};
app.use('/docs', swagger.serve, swagger.setup(openapi));
app.listen(3000);
function etagOf(o: any) { return '"' + Buffer.from(JSON.stringify(o)).length.toString(16) + '"'; }
Why it matters
Pick a single, consistent error envelope — { error: { code, message, issues? } } — and use it everywhere. Clients write one error path and reuse it across endpoints; without it, every consumer ends up coding around per-route quirks, which makes versioning the API later a much bigger lift.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
app.get('/users', listUsers);
app.post('/users', createUser);
app.get('/users/:id', getUser);
app.put('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);
Try it Yourself »
Discussion
Loading…