Middleware and input

Compose middleware

Use small ordered middleware for cross-cutting behavior without turning request flow into a maze.

Middleware can do four things to a request: observe it, change it, stop it, or forward it. Every middleware you write should do one of those, and you should be able to say which. When I can’t, the function is doing too much.

The other decision is scope. A request id and a logger belong to every request. A check that the current user owns a note belongs to the notes router and nowhere else. Mount each one at the narrowest place that still covers what it needs.

Global: observe and change

The request id middleware changes the request by attaching an id, then forwards:

import { randomUUID } from 'node:crypto'

export function requestId(req, res, next) {
  req.id = req.get('x-request-id') ?? randomUUID()
  res.set('x-request-id', req.id)
  next()
}

The logger observes. It records after the response finishes, so it can see the status and the duration:

export function logger(req, res, next) {
  const start = performance.now()
  res.on('finish', () => {
    const ms = Math.round(performance.now() - start)
    console.log(`${req.id} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`)
  })
  next()
}

Both go first in createApp(), before anything that could send a response:

app.use(requestId)
app.use(logger)

Scoped: stop or forward

Checking that a visitor is logged in either stops the request or lets it through:

export function requireLogin(req, res, next) {
  if (!req.session.userId) {
    return res.redirect(303, '/login')
  }
  next()
}

The notes router needs it. The login page and the stylesheet do not. So it goes on the mount, not on the app:

app.use('/notes', requireLogin, notesRouter)

app.use() accepts several functions in a row, and runs them in order for that path only. Requests for /login never see requireLogin, which is what we want. A global requireLogin would redirect the login page to itself.

Write the order down

For the notes app, the full chain for a POST /notes/42 looks like this:

  1. requestId, changes req
  2. logger, observes
  3. express.static() for /assets, stops or forwards
  4. requireLogin, stops or forwards
  5. express.urlencoded(), changes req.body
  6. requireOwner, stops or forwards
  7. the route handler, stops
  8. error middleware, only if something threw

Two things fall out of this list. Parsing runs after the login check, so anonymous requests never cost us a body parse. And ownership runs after parsing, because it may need the body. If I ever need a rate limiter, it goes between 2 and 3, before any real work.

The test that proves the scope

Predict what happens for GET /login with no session, then check:

test('the login page does not require a login', async () => {
  const res = await request(app).get('/login')
  assert.equal(res.status, 200)
})

If someone later moves requireLogin to app.use() for convenience, this test turns red. That’s a middleware scope bug caught in a second instead of in a bug report.

Try this: change the order of requireLogin and express.urlencoded() and confirm that an anonymous POST is now parsed before being rejected. Then put them back.

Lesson completed