Phase 2 — Building REST APIs lesson-0012

REST API Design

REST is an architectural style, not a protocol. The goal isn't strict compliance — it's building APIs that are predictable, self-consistent, and a pleasure for other developers to consume.

REST Constraints — What They Actually Mean

REST defines 6 constraints. The ones that matter most in practice:

  • Stateless — each request contains all information needed. The server holds no session state. Auth tokens, not server sessions.
  • Uniform Interface — resources are identified by URLs; HTTP methods describe the action; responses are self-describing (Content-Type header).
  • Client-Server — the client and server are independent. The API is the contract. Change the server implementation without changing clients.
  • Cacheable — responses should indicate whether they can be cached. GET responses for public data should be cacheable.

URL Design Rules

Nouns, not verbs
The HTTP method IS the verb. The URL names the resource. /users not /getUsers or /createUser.
Plural nouns
/users not /user. Collections are plural. Single resources are accessed via /users/:id.
Lowercase + hyphens
/blog-posts not /blogPosts or /blog_posts. URLs are case-sensitive; lowercase prevents errors.
No trailing slashes
/users not /users/. Trailing slashes cause redirect issues and cache misses.
Hierarchical nesting
/users/42/posts for posts owned by user 42. But never more than 2 levels deep — flatten beyond that.
Version from day one
/api/v1/users. Always. You will need v2 someday. Starting without versioning means a painful migration later.
Consistent casing in JSON
Use camelCase in JSON responses (createdAt, userId). Snake_case is common in databases — transform at the API boundary.
❌ Bad URL Design
GET  /getUsers
POST /createUser
GET  /deleteUser?id=42
POST /user/update/42
GET  /UserProfile/Me
GET  /users/get-active-ones
✅ Good URL Design
GET    /api/v1/users
POST   /api/v1/users
DELETE /api/v1/users/42
PATCH  /api/v1/users/42
GET    /api/v1/users/me
GET    /api/v1/users?active=true

Full CRUD Mapping

MethodURLActionBodySuccessErrors
GET/usersList (paginated)200 + array400
GET/users/:idGet one200 + object404
POST/usersCreateNew data201 + object + Location header400, 409
PATCH/users/:idPartial updateChanged fields only200 + updated object400, 404, 409
PUT/users/:idFull replaceComplete object200 + replaced object400, 404
DELETE/users/:idDelete204 empty404

Consistent Response Envelope

Adopt a consistent shape and never deviate. Clients and frontend teams will thank you. Here's a widely used convention:

// Single resource
{
  "data": { "id": 42, "name": "Alice", "email": "alice@example.com" }
}

// Collection with pagination metadata
{
  "data": [{ "id": 1 }, { "id": 2 }],
  "meta": {
    "total":      243,
    "page":       2,
    "limit":      20,
    "totalPages": 13,
    "hasNext":    true,
    "hasPrev":    true
  }
}

// Error — always the same shape regardless of error type
{
  "error": {
    "code":      "VALIDATION_ERROR",
    "message":   "Request body is invalid",
    "requestId": "3f2a1b4c-...",
    "details": {
      "email":    ["Invalid email format"],
      "password": ["Minimum 8 characters required"]
    }
  }
}

Pagination: Offset vs Cursor

// ── Offset Pagination ─────────────────────────────────────────────
// Simple. Works for most admin UIs and dashboards.
// Problem: if items are inserted/deleted between pages, you get duplicates/skips.
GET /posts?page=3&limit=20

// Server-side implementation
const offset = (page - 1) * limit;
const posts = await db.post.findMany({ skip: offset, take: limit });
const total = await db.post.count();

// ── Cursor Pagination ─────────────────────────────────────────────
// Stable under inserts/deletes. Required for infinite scroll and real-time feeds.
// Cursor is an opaque value (base64-encoded) pointing to the last seen item.
GET /posts?limit=20&after=eyJpZCI6NDJ9

// Server-side implementation
const cursor = req.query.after
  ? JSON.parse(Buffer.from(req.query.after, 'base64').toString())
  : undefined;

const posts = await db.post.findMany({
  take:   limit,
  skip:   cursor ? 1 : 0,  // skip the cursor item itself
  cursor: cursor ? { id: cursor.id } : undefined,
  orderBy: { id: 'asc' },
});

const nextCursor = posts.length === limit
  ? Buffer.from(JSON.stringify({ id: posts.at(-1).id })).toString('base64')
  : null;

res.json({ data: posts, meta: { nextCursor } });

Filtering, Sorting, Sparse Fieldsets

// Filtering — query params map to WHERE conditions
GET /users?role=admin&active=true&createdAfter=2024-01-01

// Sorting
GET /users?sort=createdAt&order=desc
GET /users?sort=-createdAt          // minus prefix = desc (alternative)

// Field selection — reduce payload size
GET /users?fields=id,name,email     // return only these fields

// Related resource inclusion — avoid N+1 by fetching in one call
GET /users/42?include=posts,comments

// Complex filters (for advanced APIs)
GET /products?price[gte]=10&price[lte]=100&category=electronics

Actions That Don't Fit CRUD

// ✅ Use sub-resource noun endpoints for state transitions
POST /orders/42/cancellation        // creates a "cancellation" on the order
DELETE /orders/42/cancellation      // removes it (uncancel)

// ✅ Or use action verbs under the resource (pragmatic and widely used)
POST /orders/42/cancel
POST /orders/42/ship
POST /orders/42/refund
POST /users/42/activate
POST /users/42/deactivate
POST /posts/99/publish

// ✅ Non-resource operational endpoints are fine
POST /auth/login
POST /auth/logout
POST /auth/refresh
POST /auth/forgot-password
POST /auth/reset-password
POST /webhooks/stripe             // receive external webhooks
GET  /health                      // health check endpoint
GET  /metrics                     // Prometheus metrics endpoint

Idempotency — Critical for Reliability

Idempotent operations can be safely retried. This matters when networks fail and clients don't know if their request was received:

// ✅ Make POST idempotent with Idempotency-Key header
// Client generates a UUID for each logical operation and sends it
// Server stores (key → response) and returns the cached response on retry
POST /payments
Idempotency-Key: a6f3b2c1-...

// Server implementation
const idempotent = (handler) => async (req, res) => {
  const key = req.get('Idempotency-Key');
  if (key) {
    const cached = await redis.get(`idempotency:${key}`);
    if (cached) return res.json(JSON.parse(cached));
  }

  const result = await handler(req);
  if (key) await redis.setex(`idempotency:${key}`, 86400, JSON.stringify(result));
  res.json(result);
};

HATEOAS — Self-Describing APIs

HATEOAS (Hypermedia As The Engine Of Application State) embeds links to related actions in responses. Clients don't need to construct URLs — they follow links. Most production REST APIs include a pragmatic subset:

{
  "data": { "id": 42, "status": "pending" },
  "links": {
    "self":   "/api/v1/orders/42",
    "cancel": "/api/v1/orders/42/cancel",
    "items":  "/api/v1/orders/42/items",
    "user":   "/api/v1/users/7"
  }
}

Health & Observability Endpoints

// GET /health — basic liveness (is the process running?)
app.get('/health', (req, res) => {
  res.json({ status: 'ok', uptime: process.uptime() });
});

// GET /ready — readiness (can the app serve traffic? DB connected?)
app.get('/ready', async (req, res) => {
  try {
    await db.$queryRaw`SELECT 1`;  // verify DB connection
    res.json({ status: 'ready', db: 'connected' });
  } catch {
    res.status(503).json({ status: 'not ready', db: 'disconnected' });
  }
});

🧠 Check Your Understanding


Go Deeper

Primary source: restfulapi.net — comprehensive REST reference.

Read: Stripe API Docs — the gold standard for REST API design. Study their error shapes, pagination, and idempotency keys.

Ask your teacher: "Design the full API for a multi-tenant SaaS with organisations, members, projects, and tasks."