Phase 2 — Building REST APIs lesson-0009

Routing

Routing is how Express maps incoming requests to handler functions. A well-structured router is the skeleton of your API — it should read like a table of contents for everything your API can do.

Anatomy of a URL

https:// api.example.com :3000 /api/v1/users/42 ?include=posts&page=2
PartExpress accessNotes
/api/v1/users/42req.pathPath only, no query string
42req.params.idCaptured from :id — always a string
include=postsreq.query.includeQuery param — always a string, validate before use
page=2req.query.pageCast to number: +req.query.page

Route Parameters — All Patterns

GET /users/:id
Single named segment. req.params.id = '42'. Always a string.
GET /orgs/:orgId/repos/:repoId
Multiple params. req.params = { orgId: 'google', repoId: 'tensorflow' }.
GET /files/:path(*)
Wildcard — matches any path after /files/. req.params.path = 'docs/readme.md'.
GET /users/:id(\\d+)
Regex constraint — only matches if id is digits. Returns 404 for /users/abc.
GET /users/:id?
Optional segment — matches both /users and /users/42. req.params.id may be undefined.
// req.params is always strings — cast before use
router.get('/:id', (req, res) => {
  const id = +req.params.id;       // cast to number
  if (!Number.isInteger(id) || id < 1) {
    return res.status(400).json({ error: 'Invalid ID' });
  }
  // now safe to use id as a number
});

Query Strings — Patterns & Best Practices

// GET /users?page=2&limit=20&sort=name&order=desc&role=admin&active=true
router.get('/', (req, res) => {
  // Pagination — clamp values to prevent abuse
  const page  = Math.max(1, parseInt(req.query.page)  || 1);
  const limit = Math.min(100, parseInt(req.query.limit) || 20);
  const offset = (page - 1) * limit;

  // Sorting — whitelist allowed values to prevent injection
  const ALLOWED_SORT = ['name', 'createdAt', 'email'];
  const sort  = ALLOWED_SORT.includes(req.query.sort) ? req.query.sort : 'createdAt';
  const order = req.query.order === 'asc' ? 'asc' : 'desc';

  // Filtering — only pass through known filters
  const filters = {};
  if (req.query.role)   filters.role   = req.query.role;
  if (req.query.active) filters.active = req.query.active === 'true';

  // Use for DB query
  res.json({ page, limit, offset, sort, order, filters });
});
SQL Injection via Sort Column
Never pass req.query.sort directly into a SQL ORDER BY clause. An attacker sends ?sort=1;DROP TABLE users--. Always whitelist allowed sort fields.

Router — Organising into Files

// src/routes/users.js — routes only, no business logic
const { Router } = require('express');
const ctrl     = require('../controllers/users');
const auth     = require('../middleware/auth');
const validate = require('../middleware/validate');
const schemas  = require('../schemas/users');

const router = Router();

// Public routes
router.post('/register', validate(schemas.register), ctrl.register);
router.post('/login',    validate(schemas.login),    ctrl.login);

// Protected routes — authenticate applies to everything below
router.use(auth.authenticate);

router.get('/',      ctrl.getAll);
router.get('/:id',   validate(schemas.getById), ctrl.getById);
router.patch('/:id', validate(schemas.update),  ctrl.update);
router.delete('/:id',
  auth.requireRole('admin'),   // only admins can delete
  ctrl.remove
);

module.exports = router;
// src/routes/index.js — single mounting point
const { Router } = require('express');
const router = Router();

router.use('/users', require('./users'));
router.use('/posts', require('./posts'));
router.use('/auth',  require('./auth'));

module.exports = router;

// In app.js:
// app.use('/api/v1', require('./routes'));
// Result: /api/v1/users, /api/v1/posts, /api/v1/auth

Controllers — The Right Level of Abstraction

Routes should be thin — just middleware + handler name. Controllers should also be thin — just request/response handling. Business logic lives in services:

// src/controllers/users.js
const userService = require('../services/users');
const { NotFoundError } = require('../errors/AppError');

exports.getById = async (req, res) => {
  const user = await userService.getById(+req.params.id);
  if (!user) throw new NotFoundError('User');
  res.json({ data: user });
};

exports.create = async (req, res) => {
  const user = await userService.create(req.body);
  res.status(201)
    .set('Location', `/api/v1/users/${user.id}`)
    .json({ data: user });
};
// src/services/users.js — pure business logic, no req/res, fully testable
const db = require('../db/client');
const bcrypt = require('bcrypt');

exports.create = async ({ name, email, password }) => {
  const hashed = await bcrypt.hash(password, 12);
  const user = await db.user.create({
    data: { name, email, password: hashed },
    select: { id: true, name: true, email: true }, // never return password
  });
  return user;
};

Nested Resources

// /api/v1/users/:userId/posts — posts belonging to a user
const postsRouter = require('./posts');

// Option A: mergeParams — child router can access parent params
const nestedPosts = Router({ mergeParams: true });
nestedPosts.get('/', async (req, res) => {
  const { userId } = req.params; // available because of mergeParams
  const posts = await db.post.findMany({ where: { userId: +userId } });
  res.json({ data: posts });
});

userRouter.use('/:userId/posts', nestedPosts);
Nesting Depth Rule
Never nest routes more than 2 levels deep. /users/:id/posts is fine. /users/:id/posts/:postId/comments/:commentId/likes is a maintenance nightmare. At that depth, flatten it: /comments/:id/likes works just as well and is far more readable.

Router-level param() — DRY Parameter Handling

// Run code whenever :userId appears in this router's routes
// Perfect for: load the entity, 404 if missing, attach to req
router.param('userId', async (req, res, next, id) => {
  const user = await db.user.findUnique({ where: { id: +id } });
  if (!user) throw new NotFoundError('User');
  req.targetUser = user; // now available in all routes with :userId
  next();
});

// All these routes automatically get req.targetUser loaded
router.get('/:userId',        (req, res) => res.json(req.targetUser));
router.patch('/:userId',      (req, res) => { /* update req.targetUser */ });
router.delete('/:userId',     (req, res) => { /* delete req.targetUser */ });
router.get('/:userId/posts', (req, res) => { /* req.targetUser.id available */ });

API Versioning Strategies

// Strategy 1 — URL path versioning (most common, easiest to reason about)
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

// Strategy 2 — Accept header versioning (more RESTful, harder to test in browser)
app.use('/api/users', (req, res, next) => {
  const version = req.get('Accept')?.match(/version=(\d+)/)?.[1] ?? '1';
  req.apiVersion = +version;
  next();
});

// Strategy 3 — Query param (avoid — query params are for filtering, not versioning)
// GET /users?version=2  ← don't do this

The 404 Handler and Catch-All

// In app.js — after ALL route registrations
// Catches any request that didn't match a defined route
app.use((req, res) => {
  res.status(404).json({
    error: {
      code:    'NOT_FOUND',
      message: `Cannot ${req.method} ${req.path}`,
    }
  });
});

// Why not throw new NotFoundError() here?
// You can, but this middleware is synchronous and simple — no need to go through
// the error handler for something this routine. Direct res.json() is fine.

🧠 Check Your Understanding


Go Deeper

Primary source: Express.js — Routing

Deep dive: Express Router API — every method documented.

Ask your teacher: "Show me how mergeParams works with a complete nested resource example." or "What's the difference between router.use() and router.all()?"