Checkpoint: Build a REST API

Reviewed & published by Brayan K

This checkpoint ties the course together by having you build one small Express REST API that validates input, protects a route with a JWT, and logs every request — the same layers that power real production services.

Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

First you'll review everything you've learned, then take on a multi-step build challenge with a starter and a full solution, and finally test your understanding with a short checkpoint quiz before moving on to the capstone.

What You've Learned So Far

🔍 Warm-Up: the Core Logic, Running

Before the full build, here are the two ideas at the heart of the API — input validation and token issue/verify — as a small runnable program (no server needed), so you can confirm the logic with real output:

const jwt = require('jsonwebtoken');
const SECRET = 'checkpoint-secret';

// The two pieces of logic at the heart of the API, runnable without a server.

// 1) Validation (the body of POST /books)
function validateBook(body) {
  const errors = [];
  if (!body.title || typeof body.title !== 'string') errors.push('title is required');
  if (body.year !== undefined && typeof body.year !== 'number') errors.push('year must be a number');
  return errors;
}
console.log('Good body:', validateBook({ title: 'Node Mastery', year: 2024 }));
console.log('Bad body: ', validateBook({ year: 'nope' }));

// 2) Issue + verify a token (login + auth middleware)
const token = jwt.sign({ sub: 1, name: 'reader' }, SECRET, { expiresIn: '1h' });
const claims = jwt.verify(token, SECRET);
console.log('Logged in as:', claims.name);

Your mission, step by step

// Install once:  npm install express jsonwebtoken
const express = require('express');
const jwt = require('jsonwebtoken');
const SECRET = 'checkpoint-secret';

const app = express();
app.use(express.json());

const books = [];

// STEP 1 — a logging middleware that prints "LOG <method> <url>" for every request.
app.use((req, res, next) => {
  // TODO: console.log the method and url, then call next()
});

// STEP 2 — a VALIDATED POST /books.
//   - title is required and must be a string  -> else 400 { errors: [...] }
//   - year, if present, must be a number      -> else 400
//   - on success, push { id, title, year } and respond 201 with the book
app.post('/books', (req, res) => {
  // TODO: validate req.body, then create the book
});

// STEP 3 — POST /login issues a JWT for a demo user.
app.post('/login', (req, res) => {
  // TODO: sign { sub: 1, name: 'reader' } and return { token }
});

// STEP 4 — a JWT auth middleware: read "Authorization: Bearer <token>", verify it.
function auth(req, res, next) {
  // TODO: 401 if missing/invalid, else set req.user and call next()
}

// STEP 5 — a PROTECTED GET /books that returns { user, books }.
app.get('/books', auth, (req, res) => {
  // TODO: respond with the logged-in user's name and the books array
});

app.listen(3000, () => console.log('LOG api up'));

Here's a complete, working implementation. The comment at the bottom shows the exact output produced by hitting the routes in order — a created book, a rejected bad book, a blocked unauthenticated GET, a login, and finally an authorized GET:

📝 Checkpoint Quiz

Test yourself. Think through each answer first, then expand it to check.

Because middleware runs in order. express.json() parses the request body into req.body. If a route runs before it, req.body is still undefined and validation breaks.

400 Bad Request for invalid input (missing title, wrong type) and 401 Unauthorized for a missing or invalid token. They tell the client whether to fix the data or to authenticate.

Between the path and the handler: app.get('/books', auth, handler). Express runs auth first, and it can send a 401 (and skip the handler) when the token is missing or invalid.

It reads the Authorization header, splits off the "Bearer " prefix, and passes the remaining token to jwt.verify(token, SECRET), attaching the decoded claims to req.user.

In-memory data is lost on every restart and isn't shared across instances. Replace the array with a Mongoose model — await Book.create(...) and await Book.find() — to persist the data in MongoDB.

morgan as middleware logs every HTTP request automatically; pino writes your own structured app events. Piping morgan into pino gives you one consistent JSON format.

Pro Tips

🎉 Checkpoint Complete!

Practice quiz

Why must express.json() run before any route that reads req.body?

  • Because routes are slower than middleware
  • Because express.json() also handles authentication
  • Because middleware runs top to bottom, and express.json() is what parses the body into req.body
  • It does not matter where express.json() is placed

Answer: Because middleware runs top to bottom, and express.json() is what parses the body into req.body. Middleware runs in order. If a route runs before express.json(), req.body is still undefined and validation breaks.

What status code should a failed input validation return in this API?

  • 400 Bad Request
  • 200 OK
  • 401 Unauthorized
  • 500 Internal Server Error

Answer: 400 Bad Request. 400 Bad Request signals the client sent malformed or invalid data — here a missing title or a non-numeric year.

A request arrives with no token. Which status code does the auth middleware return?

  • 400 Bad Request
  • 403 Forbidden
  • 404 Not Found
  • 401 Unauthorized

Answer: 401 Unauthorized. 401 Unauthorized means the client is not authenticated — no token, or an invalid/expired one — so it must log in first.

Where does the auth middleware go for a protected route like GET /books?

  • Before express.json() at the top of the app
  • Between the path and the handler: app.get('/books', auth, handler)
  • Inside the route handler as the last line
  • It must be the very first app.use() call

Answer: Between the path and the handler: app.get('/books', auth, handler). Placed between the path and the handler, Express runs auth first and it can send a 401 and skip the handler when the token is bad.

How does the auth middleware extract the token from the request?

  • It reads the Authorization header, strips the 'Bearer ' prefix, and verifies the rest
  • It reads req.body.token
  • It reads a cookie named jwt
  • It reads req.query.token

Answer: It reads the Authorization header, strips the 'Bearer ' prefix, and verifies the rest. The middleware splits the Authorization header on a space, checks for the Bearer scheme, then passes the token to jwt.verify(token, SECRET).

The books are stored in an in-memory array. What is the main downside?

  • Arrays are slower than databases for any size
  • Arrays cannot store objects
  • The data is lost on every restart and is not shared across instances
  • Express cannot return arrays as JSON

Answer: The data is lost on every restart and is not shared across instances. In-memory data vanishes on restart and is not shared between processes; a Mongoose model persists it in MongoDB instead.

After a successful POST /books, which status code does the handler send?

  • 200 OK
  • 201 Created
  • 204 No Content
  • 202 Accepted

Answer: 201 Created. 201 Created indicates a new resource was created and is returned in the response body, which is exactly what the route does.

Which logger records every incoming HTTP request automatically as middleware?

  • pino
  • winston-file
  • console.trace
  • morgan

Answer: morgan. morgan plugs in as middleware and logs every HTTP request; pino is better suited to your own structured application events.

What does POST /login return to the client in this checkpoint?

  • A redirect to /books
  • A signed JWT, e.g. { token }
  • The full user record with a password
  • A session cookie only

Answer: A signed JWT, e.g. { token }. The login route signs a JWT with jwt.sign({ sub: 1, name: 'reader' }, SECRET) and returns it as { token }.

How would you make the in-memory books persistent with a real database?

  • Write the array to a global variable
  • Store the books in process.env
  • Replace the array with a Mongoose model: await Book.create(...) and await Book.find()
  • Increase the array size limit

Answer: Replace the array with a Mongoose model: await Book.create(...) and await Book.find(). Swapping books.push for await Book.create and the array read for await Book.find persists data in MongoDB while validation, JWT, and logging stay the same.

Continue this course

Frequently asked questions

Which middleware order should I use in this API?

Order matters in Express because middleware runs top to bottom. Put express.json() first so req.body is parsed before any route reads it, then your request logger so it records every request, then your routes. For protected routes, the auth middleware goes between the path and the handler — app.get('/books', auth, handler) — so auth runs first and can reject the request with a 401 before the handler ever executes. A general rule: parsing and logging are app-level (app.use) and run for everything; auth is route-level and guards only the endpoints that need a logged-in user.

Why return 400 for validation errors but 401 for auth failures?

HTTP status codes communicate what went wrong. 400 Bad Request means the client sent something malformed or invalid — here, a missing title or a non-numeric year — so the fix is to correct the request body. 401 Unauthorized means the client isn't authenticated — no token, or an invalid/expired one — so the fix is to log in and send a valid token. Using the right code lets clients and tools react correctly: a 400 says 'fix your data', a 401 says 'authenticate first'. Returning 200 with an error message buried in the body, by contrast, hides failures from anything inspecting the status.

How would I connect this API to a real database?

Right now the books live in an in-memory array, so they vanish on restart. To persist them, replace the array with a Mongoose model (from the MongoDB lesson): define a Book schema, compile it into a model, and swap books.push(book) for await Book.create({ title, year }) and the array read for await Book.find(). Make the route handlers async, await the database calls, and wrap them in try/catch (or an async error handler) so a database failure returns a 500 instead of crashing the process. The validation, JWT, and logging layers stay exactly the same.

How should I test a REST API like this?

Start by exercising it manually with curl or a REST client (Postman, the VS Code REST extension): POST a valid book, POST an invalid one to confirm the 400, GET without a token to confirm the 401, log in to get a token, then GET with the Authorization: Bearer header to confirm the 200. For automated tests, a library like supertest lets you fire requests at your Express app in-process and assert on the status and JSON body, so you can verify validation, auth, and the happy path on every commit — exactly the flow shown in this checkpoint's solution output.