0% completed
Creating Custom Middleware
On This Page
Give Every Request an ID
Measure How Long Each Request Takes
Protect One Route with an API Key
Load Data Before the Handler Runs
When an Async Function Fails
Handle Every Error in One Place
The task API has a problem that morgan cannot solve. When a user reports that one request failed, nothing in the log connects that user's request to the lines it produced. Morgan prints one line per request, but no value in that line also appears in the response that the user received. The fix is a few middleware functions of your own, written for this app. Each section below adds one of them to the same server, and the finished file is the sum of the sections.
Every function in this lesson follows the rule from the first lesson of this chapter: it receives (req, res, next), and it either calls next() or sends a response.
Give Every Request an ID
The first function gives each request a unique ID, stores it on req, and sends it back in a response header:
const crypto = require('node:crypto'); const express = require('express'); const app = express(); const tasks = [ { id: 1, title: 'Write the README', done: true }, { id: 2, title: 'Buy milk', done: false }, ]; function assignRequestId(req, res, next) { req.id = crypto.randomUUID(); res.set('X-Request-Id', req.id); next(); }
crypto.randomUUID() is built into Node.js and returns a random ID such as 825cbcfd-cdec-4a76-bfab-35582412c234. The method res.set() sets a response header, and the header goes out with whatever response a later function sends.
Adding a property to req is the normal way for middleware to pass data to the functions after it. Every later middleware function and every route handler receives the same req object, so all of them can read req.id. A user who reports a failure can now quote the X-Request-Id header, and you can search the log for it.
Measure How Long Each Request Takes
The second function needs to run code twice: once when the request arrives, and once when the response has been sent. A middleware function runs only on the way in. However, it can register a listener for the finish event of res, which Node.js emits after the last part of the response has been handed to the operating system:
function logDuration(req, res, next) { const start = performance.now(); res.on('finish', () => { const ms = (performance.now() - start).toFixed(1); console.log(`${req.id} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms} ms`); }); next(); }
The function records the start time, registers the listener, and calls next() at once, so it does not delay the request. The listener runs later, after a route handler or another middleware function has sent the response. At that moment res.statusCode holds the final status, whichever function set it. The function performance.now() is global in Node.js and returns the time in milliseconds with fractions.
This function reads req.id, so it must come after assignRequestId in the stack. Register both, followed by the JSON parser:
app.use(assignRequestId); app.use(logDuration); app.use(express.json()); app.get('/tasks', (req, res) => { res.json(tasks); });
A GET /tasks request now produces one log line, and the response carries the same ID:
8b6fc435-a1cc-49f0-aff8-8d9a748a62ee GET /tasks 200 6.1 ms
HTTP/1.1 200 OK X-Request-Id: 8b6fc435-a1cc-49f0-aff8-8d9a748a62ee Content-Type: application/json; charset=utf-8
Protect One Route with an API Key
Anyone can create tasks so far. Until chapter 6 adds real authentication, the API can at least require a shared secret, called an API key, that the client sends in the Authorization header as Bearer <key>.
The key must not be written into the source code, where everyone who can read the repository would see it. The app reads it from an environment variable instead, and the middleware receives it as an argument. A function that takes options and returns a middleware function is a middleware factory. The built-in express.json({ limit: '10kb' }) from the previous lesson works the same way.
function requireApiKey(expectedKey) { if (!expectedKey) { throw new Error('requireApiKey needs a key. Set the API_KEY environment variable.'); } return (req, res, next) => { if (req.get('Authorization') !== `Bearer ${expectedKey}`) { return res.status(401).json({ error: 'A valid API key is required' }); } next(); }; } app.post('/tasks', requireApiKey(process.env.API_KEY), (req, res) => { const task = { id: tasks.length + 1, title: req.body.title, done: false }; tasks.push(task); res.status(201).json(task); });
The check at the top of the factory runs once, when the app starts, and not for every request. Without it, a missing variable would make the expected header the string Bearer undefined, and any client that sent that exact string would get in. With the check, starting the app without the variable stops it at once with the message requireApiKey needs a key. Set the API_KEY environment variable..
Because the middleware sits between the path and the handler, it runs only for POST /tasks, and GET /tasks stays open. Start the server with the key, for example with API_KEY=dev-key-123 node server.js in a macOS or Linux shell, and three requests show the three outcomes:
POST /tasks (no Authorization header) -> 401 {"error":"A valid API key is required"} POST /tasks Authorization: Bearer wrong -> 401 {"error":"A valid API key is required"} POST /tasks Authorization: Bearer dev-key-123 -> 201 {"id":3,"title":"Buy bread","done":false}
The status is 401 Unauthorized, not 403 Forbidden. A 401 response means that the server does not know who the client is, because the credentials are missing or wrong. A 403 response means that the server knows the client, but this client is not allowed to perform the action. A missing or wrong key is the first case.
An API key that every client shares is a stand-in, not a complete solution: it cannot tell users apart, and the only way to revoke it is to change it for everyone. Chapter 6 replaces it with JSON Web Tokens.
Load Data Before the Handler Runs
Several routes for a single task need the same first step: find the task, or answer with 404. A middleware function can do that step once and pass the result on through req. Because a real lookup goes to a database and returns a promise, the function is async:
// Stands in for a database query. Chapter 5 replaces it with a real one. async function findTask(id) { return tasks.find((task) => task.id === id); } async function loadTask(req, res, next) { const task = await findTask(Number(req.params.id)); if (!task) { return res.status(404).json({ error: 'Task not found' }); } req.task = task; next(); } app.get('/tasks/:id', loadTask, (req, res) => { res.json(req.task); });
A request for /tasks/2 receives {"id":2,"title":"Buy milk","done":false}, and a request for /tasks/99 receives the 404 response from loadTask. The handler itself no longer contains any lookup code, and every later route for one task can reuse loadTask.
When an Async Function Fails
A real database query can fail. To see what happens, make findTask fail on every call by replacing its body with throw new Error('Database connection lost');.
An async function that throws returns a rejected promise. Express 5 waits for the promise that a middleware function or a handler returns. When the promise is rejected, Express passes the error to the error-handling middleware, exactly as if the function had called next(err). The request to /tasks/2 therefore skips the route handler and reaches the error handler.
Express 4 did not do this. In Express 4, nothing handled the rejected promise, so the request never received a response. Node.js reported an unhandled rejection, which stops the whole process on current Node.js versions. Express 4 code therefore wraps every await in try/catch and calls next(err) in the catch block, or it uses a package such as express-async-handler. On Express 5, neither is needed, and you can delete that boilerplate when you upgrade. You still call next(err) yourself for an error that you create inside a callback, such as a callback passed to fs.readFile(), because Express cannot see a callback's error.
Handle Every Error in One Place
The last function is the error handler. It builds on the one from the previous lesson and adds the request ID, so that a client who receives an error can quote it:
app.use((err, req, res, next) => { if (res.headersSent) { return next(err); } const status = err.status || 500; if (status >= 500) { console.error(`${req.id} ${err.stack}`); } res.status(status).json({ error: status < 500 ? err.message : 'Something went wrong', requestId: req.id, }); });
Register it after all routes, and end the file with app.listen(3000);. The first if covers a rare case: an error that happens after part of the response has already been sent. A second response is impossible then, so the handler passes the error to the built-in Express handler, which closes the connection.
With findTask failing, a GET /tasks/2 request now produces the stack trace and the usual log line on the server, and a short body for the client:
c5701f7c-3ce6-4c69-8aff-67b80193b535 Error: Database connection lost at findTask (server.js:54:9) at loadTask (server.js:58:22) ... c5701f7c-3ce6-4c69-8aff-67b80193b535 GET /tasks/2 500 6.6 ms
HTTP/1.1 500 Internal Server Error X-Request-Id: c5701f7c-3ce6-4c69-8aff-67b80193b535 Content-Type: application/json; charset=utf-8 {"error":"Something went wrong","requestId":"c5701f7c-3ce6-4c69-8aff-67b80193b535"}
Compare the two paths in the diagram. On a normal request, loadTask calls next() and the handler sends the task. When the promise from loadTask is rejected, Express passes over the handler and every other normal function, and the next function that runs is the one with four parameters. The ID appears on both sides of the failure: the client receives it in the header and the body, and the server log prints it next to the stack trace.
Reading Progress
0%
On This Page
Give Every Request an ID
Measure How Long Each Request Takes
Protect One Route with an API Key
Load Data Before the Handler Runs
When an Async Function Fails
Handle Every Error in One Place