iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…