Skip to main content

๐Ÿ—‚๏ธ Weekend Project: Build a RESTful Task-Management API

This is the project where you stop consuming APIs and start building one. You'll design and code a real backend โ€” the kind of service a Todoist or Trello front end would talk to โ€” using Node and modern Express. Along the way you'll practice the moves that separate a toy route from a production API: resource-oriented REST routes, input validation you can trust, a single centralized error handler with your own custom Error classes, honest HTTP status codes, and a list endpoint that supports pagination, filtering, and sorting. No React this weekend โ€” this is pure server-side JavaScript.

Week 7 · Weekend Project · Backend Capstone

๐ŸŽฏ Learning Objectives

By completing this project, you will be able to:

  • Scaffold a modern Express app with express.json() and express.Router(), separating routes, controllers, and the data layer
  • Implement full CRUD for a tasks resource on correct REST routes, returning the right status code for each outcome (200, 201, 204, 400, 404, 422)
  • Validate and sanitize request input with express-validator and surface a single, consistent error shape
  • Centralize error handling in one 4-argument middleware fed by custom AppError subclasses and an asyncHandler wrapper
  • Add pagination, filtering, and sorting to the list endpoint and return useful pagination metadata
  • Choose a data store (in-memory or a JSON file) behind a repository so the routes never touch storage details
  • Test every endpoint from the terminal with curl (or a REST client) and read the status codes it returns

Estimated Time: 5โ€“8 hours across the weekend

Project: A runnable Express API with a full CRUD /api/tasks resource, validation, centralized errors, and a paginated/filterable/sortable list endpoint.

In This Project

The Goal

Build a backend service that manages tasks over HTTP. A client โ€” a browser, a mobile app, or just curl โ€” sends a request like POST /api/tasks with a JSON body, and your API creates the task, stores it, and answers with 201 Created and the new resource. Ask for GET /api/tasks?status=open&sort=-dueDate&page=2 and it returns the second page of open tasks, newest-due first, plus metadata telling the client how many pages exist. Send garbage and it answers 422 with a precise list of what's wrong โ€” never a stack trace, never a crash.

Every request that reaches your API travels the same pipeline: body parsing, then route matching, then validation, then the controller, and โ€” if anything throws โ€” straight to one error handler at the end. Hold this picture in your head; each stage below builds one band of it.

graph LR Req["HTTP request"] --> J["app.use(express.json())"] J --> R["express.Router()
match /api/tasks"] R --> V["validation middleware
express-validator"] V --> C["controller
async handler"] C --> D["data layer
store"] C --> Res["JSON response
+ status code"] V -.->|invalid| E["error middleware
4 args"] C -.->|throws| E E --> Res

The discipline that makes this maintainable is separation of concerns. Routes only say which URL maps to which handler. Controllers hold the request/response logic. The data layer knows how tasks are stored โ€” and only it does. When a bug appears, its layer is obvious: a wrong URL is a route problem, a wrong status code is a controller problem, a lost task is a store problem.

๐Ÿ“– What makes an API "RESTful"?

REST models your app as resources (here, tasks) addressed by nouns in the URL, acted on by HTTP verbs. The collection is /api/tasks; a single task is /api/tasks/:id. You never put a verb in the path (/getTasks, /deleteTask are anti-patterns) โ€” the method is the verb. GET reads, POST creates, PUT/PATCH update, DELETE removes. The status code carries the outcome. Get those two conventions right and any developer can guess your API without reading docs.

Prerequisites

This is the Week 7 capstone, so it leans on the whole "Backend with Node & Express" week. Before you start, make sure you're comfortable with:

  • Node fundamentals โ€” running a script with node, npm init, installing packages, and CommonJS require/module.exports
  • Express basics โ€” app.get()/app.post(), route parameters (req.params), query strings (req.query), and the request body (req.body)
  • Middleware โ€” the (req, res, next) signature and how next() passes control down the chain
  • REST & HTTP status codes โ€” the meaning of 2xx / 4xx / 5xx and which verb does what
  • Custom error classes โ€” class MyError extends Error (fresh from the previous lesson) and why err.statusCode is handy
  • async/await & promises โ€” because our data layer and controllers are all async

You'll need Node.js 18 or newer (check with node --version), a code editor, and a terminal. That's it โ€” no database, no framework beyond Express. If you have Postman, Insomnia, or the VS Code REST Client extension you can use them, but plain curl is all this guide assumes.

Required Features Checklist

These are the non-negotiables. Every one is achievable with Express, express-validator, and the standard library โ€” no database required. Tick each off as you go.

โœ… Must-have features

  • โ˜ Full CRUD for tasks โ€” create, read one, read all, update, delete
  • โ˜ Proper REST routes โ€” noun-based resource URLs mounted with express.Router()
  • โ˜ Body parsing โ€” app.use(express.json()) so JSON bodies arrive as req.body
  • โ˜ Input validation โ€” express-validator rules on create/update, returning a consistent error list
  • โ˜ Centralized error handling โ€” one 4-argument error middleware fed by custom Error classes
  • โ˜ Correct status codes โ€” 200, 201, 204 on success; 400, 404, 422 on failure
  • โ˜ Pagination, filtering & sorting on GET /api/tasks, with pagination metadata in the response
  • โ˜ A data store โ€” in-memory or a JSON file, hidden behind a repository module
  • โ˜ async handlers โ€” every controller wrapped so a thrown error reaches the error middleware via next(err)

Project Structure

Backend Express apps are organized by role: routes, controllers, middleware, data, and errors each get a folder under src/. This is the layout you'll build. It looks like a lot for one resource, but it's exactly the shape a ten-resource API keeps โ€” you're learning the pattern, not just the app.

task-api/
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ .gitignore              <-- ignores node_modules, data/*.json
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ server.js           <-- entry point: starts app.listen()
    โ”œโ”€โ”€ app.js              <-- builds the Express app (no listen)
    โ”œโ”€โ”€ routes/
    โ”‚   โ””โ”€โ”€ taskRoutes.js   <-- express.Router(): URL โ†’ controller
    โ”œโ”€โ”€ controllers/
    โ”‚   โ””โ”€โ”€ taskController.js  <-- request/response logic per action
    โ”œโ”€โ”€ middleware/
    โ”‚   โ”œโ”€โ”€ validateTask.js    <-- express-validator rule chains
    โ”‚   โ”œโ”€โ”€ validationResult.js<-- turns validator errors into ValidationError
    โ”‚   โ””โ”€โ”€ errorHandler.js    <-- the one 4-arg error middleware + 404
    โ”œโ”€โ”€ data/
    โ”‚   โ””โ”€โ”€ taskStore.js    <-- the repository (in-memory or JSON file)
    โ”œโ”€โ”€ models/
    โ”‚   โ””โ”€โ”€ task.js         <-- factory that shapes a Task object
    โ””โ”€โ”€ errors/
        โ””โ”€โ”€ AppError.js     <-- AppError + NotFoundError, ValidationError, โ€ฆ

๐Ÿ’ก Why split app.js from server.js?

app.js builds and returns the configured Express app but never calls listen(). server.js imports it and starts the server. Keeping them apart means your tests can import the app and hit its routes in memory (with a tool like supertest) without ever opening a real port. It's a tiny habit now that pays off the moment you add automated tests โ€” one of this project's stretch goals.

Stage 1 โ€” Scaffold the Express App

Start the project and install the two runtime dependencies plus nodemon for auto-restart during development.

# Create and enter the project
mkdir task-api && cd task-api
npm init -y

# Runtime deps: Express + input validation
npm install express express-validator

# Dev dep: auto-restart on save
npm install --save-dev nodemon

Add scripts to package.json so you can run the server two ways. (Setting "type": "commonjs" is the Node default; we use require throughout for familiarity.)

{
  "name": "task-api",
  "version": "1.0.0",
  "main": "src/server.js",
  "type": "commonjs",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  }
}

Now build the app. Modern Express has built-in body parsers โ€” you no longer need the old body-parser package. express.json() reads a JSON request body and hands it to you as req.body; without it, req.body is undefined and every POST silently fails.

// src/app.js โ€” builds the app but does NOT start it
const express = require('express');
const taskRoutes = require('./routes/taskRoutes');
const { notFoundHandler, errorHandler } = require('./middleware/errorHandler');

function createApp() {
  const app = express();

  // --- Global middleware ---
  app.use(express.json());                          // parse JSON bodies โ†’ req.body
  app.use(express.urlencoded({ extended: true }));  // parse form bodies too

  // --- Health check / API root ---
  app.get('/', (req, res) => {
    res.json({ name: 'Task Management API', version: '1.0.0', resource: '/api/tasks' });
  });

  // --- Feature routes (mounted under a resource prefix) ---
  app.use('/api/tasks', taskRoutes);

  // --- 404 for anything unmatched, then the ONE error handler ---
  app.use(notFoundHandler);   // must come AFTER all routes
  app.use(errorHandler);      // must be LAST, and take 4 args

  return app;
}

module.exports = createApp;
// src/server.js โ€” the only file that opens a port
const createApp = require('./app');

const PORT = process.env.PORT || 3000;
const app = createApp();

app.listen(PORT, () => {
  console.log(`Task API listening on http://localhost:${PORT}`);
});

โš ๏ธ Order is everything with middleware

Express runs middleware top to bottom. express.json() must come before your routes or req.body won't be parsed yet. The 404 handler must come after all routes (it's the catch-all for "no route matched"). And the error handler must be dead last and take exactly four arguments โ€” that four-argument signature is how Express recognizes it as an error handler. Get this order wrong and you'll chase ghosts.

Stage 2 โ€” Task Model & Data Layer

Before routes, decide what a Task is and where it lives. Two small files: a model that shapes a task consistently, and a store (repository) that owns all reads and writes. The rest of the app talks to the store and never touches the raw array or file โ€” so if you later swap in a real database, only this one file changes.

The Task model

A factory function gives every task the same shape and sensible defaults, and stamps timestamps. Using crypto.randomUUID() (built into Node 18+) means no uuid package.

// src/models/task.js
const { randomUUID } = require('crypto');

const PRIORITIES = ['low', 'medium', 'high'];
const STATUSES = ['open', 'in-progress', 'done'];

// Build a fully-formed task from partial input, filling defaults.
function makeTask(input) {
  const now = new Date().toISOString();
  return {
    id: randomUUID(),
    title: input.title.trim(),
    description: input.description?.trim() || '',
    status: input.status || 'open',
    priority: input.priority || 'medium',
    dueDate: input.dueDate || null,
    createdAt: now,
    updatedAt: now,
  };
}

module.exports = { makeTask, PRIORITIES, STATUSES };

The data store (repository)

Here's the in-memory version โ€” an array plus async methods. Every method is async even though nothing awaits yet; that keeps the interface identical to a real database, so upgrading later is a drop-in. Each lookup throws a NotFoundError when the id is missing, so controllers never have to check for undefined.

// src/data/taskStore.js โ€” in-memory repository
const { makeTask } = require('../models/task');
const { NotFoundError } = require('../errors/AppError');

let tasks = [];   // the entire "database" โ€” one array

const taskStore = {
  // Return a shallow copy so callers can't mutate our array by reference.
  async findAll() {
    return [...tasks];
  },

  async findById(id) {
    const task = tasks.find((t) => t.id === id);
    if (!task) throw new NotFoundError(`Task ${id} not found`);
    return task;
  },

  async create(data) {
    const task = makeTask(data);
    tasks.push(task);
    return task;
  },

  async update(id, changes) {
    const task = await this.findById(id);          // throws if missing
    Object.assign(task, changes, { updatedAt: new Date().toISOString() });
    return task;
  },

  async remove(id) {
    const index = tasks.findIndex((t) => t.id === id);
    if (index === -1) throw new NotFoundError(`Task ${id} not found`);
    tasks.splice(index, 1);
  },
};

module.exports = taskStore;

๐Ÿ“– Want persistence instead? Swap to a JSON file

The in-memory store forgets everything when the server restarts. To persist, keep the same method names and back them with a JSON file โ€” load on read, write after each change:

// src/data/taskStore.js โ€” JSON-file variant (same interface, so nothing else changes)
const fs = require('fs/promises');
const path = require('path');
const { makeTask } = require('../models/task');
const { NotFoundError } = require('../errors/AppError');

const FILE = path.join(__dirname, 'tasks.json');

async function load() {
  try {
    return JSON.parse(await fs.readFile(FILE, 'utf8'));
  } catch (err) {
    if (err.code === 'ENOENT') return [];   // no file yet โ†’ empty list
    throw err;
  }
}
const save = (tasks) => fs.writeFile(FILE, JSON.stringify(tasks, null, 2));

const taskStore = {
  async findAll() { return load(); },
  async findById(id) {
    const task = (await load()).find((t) => t.id === id);
    if (!task) throw new NotFoundError(`Task ${id} not found`);
    return task;
  },
  async create(data) {
    const tasks = await load();
    const task = makeTask(data);
    tasks.push(task);
    await save(tasks);
    return task;
  },
  // update() and remove() follow the same load โ†’ change โ†’ save pattern.
};

module.exports = taskStore;

Because the interface is identical, nothing else in the app changes. That's the whole point of hiding storage behind a repository. Add src/data/tasks.json to your .gitignore.

Stage 3 โ€” CRUD Routes & Controllers

Now the heart of the API. First the route table, which maps each URL + verb to a controller and slots the validation middleware in front of the writes. This is a classic REST resource router.

// src/routes/taskRoutes.js
const express = require('express');
const controller = require('../controllers/taskController');
const { validateCreateTask, validateUpdateTask } = require('../middleware/validateTask');
const { runValidation } = require('../middleware/validationResult');

const router = express.Router();

router
  .route('/')
  .get(controller.list)                                       // GET    /api/tasks
  .post(validateCreateTask, runValidation, controller.create); // POST   /api/tasks

router
  .route('/:id')
  .get(controller.getOne)                                     // GET    /api/tasks/:id
  .put(validateUpdateTask, runValidation, controller.update)  // PUT    /api/tasks/:id
  .delete(controller.remove);                                 // DELETE /api/tasks/:id

module.exports = router;

โœ… The REST route map at a glance

Verb + PathActionSuccess code
GET /api/tasksList (paginated/filtered/sorted)200 OK
POST /api/tasksCreate one task201 Created
GET /api/tasks/:idRead one task200 OK
PUT /api/tasks/:idUpdate a task200 OK
DELETE /api/tasks/:idDelete a task204 No Content

The asyncHandler wrapper

Every controller is async, and an error thrown inside an async function does not automatically reach Express's error middleware โ€” you'd have to write try/catch with next(err) in all five handlers. A tiny wrapper removes that boilerplate: it catches any rejected promise and forwards it to next. Write it once, wrap every handler.

// src/middleware/asyncHandler.js
// Wrap an async route handler so any thrown/rejected error goes to next().
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

module.exports = asyncHandler;

The controllers

Each controller is small and does one job: read what it needs off req, call the store, and send a response with the correct status. Notice there's no try/catch โ€” asyncHandler handles it, and a missing task throws NotFoundError inside the store, which sails straight to the error middleware.

// src/controllers/taskController.js
const taskStore = require('../data/taskStore');
const asyncHandler = require('../middleware/asyncHandler');
const { paginateAndSort } = require('../data/query');   // built in Stage 6

// GET /api/tasks  โ€” list with pagination, filter, and sort (Stage 6)
exports.list = asyncHandler(async (req, res) => {
  const all = await taskStore.findAll();
  const result = paginateAndSort(all, req.query);
  res.status(200).json(result);   // { data: [...], pagination: {...} }
});

// GET /api/tasks/:id  โ€” read one
exports.getOne = asyncHandler(async (req, res) => {
  const task = await taskStore.findById(req.params.id);   // throws NotFoundError
  res.status(200).json({ data: task });
});

// POST /api/tasks  โ€” create
exports.create = asyncHandler(async (req, res) => {
  const task = await taskStore.create(req.body);
  res.status(201).json({ data: task });    // 201 Created for a new resource
});

// PUT /api/tasks/:id  โ€” update
exports.update = asyncHandler(async (req, res) => {
  const task = await taskStore.update(req.params.id, req.body);
  res.status(200).json({ data: task });
});

// DELETE /api/tasks/:id  โ€” delete
exports.remove = asyncHandler(async (req, res) => {
  await taskStore.remove(req.params.id);   // throws NotFoundError if missing
  res.status(204).send();                  // 204 No Content โ€” empty body
});

๐Ÿ’ก Why 201 and 204 matter

A lazy API returns 200 for everything. A good one is precise: 201 Created tells the client "a new resource now exists" (and ideally returns it), while 204 No Content says "done, and there's deliberately nothing to send back" โ€” which is exactly right for a delete. Clients and caches rely on these distinctions. Sending the correct code is free; sending the wrong one quietly misleads everyone downstream.

Stage 4 โ€” Validation Middleware

Never trust the request body. Before a task reaches the store, express-validator checks it against a set of rules. A rule chain is just an array of middleware you drop into the route before the controller โ€” you already wired them into taskRoutes.js in Stage 3.

// src/middleware/validateTask.js
const { body } = require('express-validator');
const { PRIORITIES, STATUSES } = require('../models/task');

// Rules for creating a task: title required, the rest optional but typed.
const validateCreateTask = [
  body('title')
    .trim()
    .notEmpty().withMessage('title is required')
    .isLength({ max: 100 }).withMessage('title must be 100 characters or fewer'),
  body('description')
    .optional().trim()
    .isLength({ max: 500 }).withMessage('description must be 500 characters or fewer'),
  body('status')
    .optional()
    .isIn(STATUSES).withMessage(`status must be one of: ${STATUSES.join(', ')}`),
  body('priority')
    .optional()
    .isIn(PRIORITIES).withMessage(`priority must be one of: ${PRIORITIES.join(', ')}`),
  body('dueDate')
    .optional({ nullable: true })
    .isISO8601().withMessage('dueDate must be an ISO-8601 date string'),
];

// Rules for updating: same fields, but title is now optional too.
// (Reuse everything except title from the create rules, then add an optional title.)
const validateUpdateTask = [
  body('title')
    .optional().trim()
    .notEmpty().withMessage('title cannot be empty')
    .isLength({ max: 100 }).withMessage('title must be 100 characters or fewer'),
  ...validateCreateTask.slice(1),   // description, status, priority, dueDate rules
];

module.exports = { validateCreateTask, validateUpdateTask };

The rule chains only record problems onto the request. A second, shared middleware reads that record and, if it's non-empty, throws your custom ValidationError โ€” so validation failures flow through the exact same error handler as everything else.

// src/middleware/validationResult.js
const { validationResult } = require('express-validator');
const { ValidationError } = require('../errors/AppError');

// Collect any validation errors and hand them to the error middleware.
function runValidation(req, res, next) {
  const result = validationResult(req);
  if (result.isEmpty()) return next();     // all good, continue

  // Normalize into a clean, consistent shape for the client.
  const details = result.array().map((e) => ({ field: e.path, message: e.msg }));
  next(new ValidationError('Validation failed', details));
}

module.exports = { runValidation };

โš ๏ธ Validation is not the same as authorization

express-validator answers "is this input well-formed?" โ€” a missing title, a bad date, an unknown priority. It does not answer "is this user allowed to do this?" That's authorization, a separate concern (a stretch goal below). Keep them apart: a 422 means "your data is malformed," a 401/403 means "you can't do that." Conflating them confuses clients.

Stage 5 โ€” Centralized Error Handling

Here's the payoff for all that structure. Instead of formatting errors in every controller, you define a small family of custom error classes that each carry an HTTP status, and one error middleware that turns any of them into a clean JSON response. Throw an error anywhere โ€” a route, a controller, the store โ€” and it lands here.

// src/errors/AppError.js
// Base class: every operational error carries an HTTP status code.
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;               // "expected" error, not a bug
    Error.captureStackTrace(this, this.constructor);
  }
}

class BadRequestError extends AppError {
  constructor(message = 'Bad request') { super(message, 400); }
}

class NotFoundError extends AppError {
  constructor(message = 'Resource not found') { super(message, 404); }
}

class ValidationError extends AppError {
  constructor(message = 'Validation failed', details = []) {
    super(message, 422);
    this.details = details;                  // per-field error list
  }
}

module.exports = { AppError, BadRequestError, NotFoundError, ValidationError };

The error middleware is the last thing mounted in app.js, and its four-argument signature (err, req, res, next) is how Express knows it handles errors. It reads err.statusCode (defaulting to 500), attaches field details when present, and hides stack traces outside development.

// src/middleware/errorHandler.js
const { AppError, NotFoundError } = require('../errors/AppError');

// Any request that matched no route ends up here โ†’ a 404 error.
function notFoundHandler(req, res, next) {
  next(new NotFoundError(`Route ${req.method} ${req.originalUrl} not found`));
}

// THE central error handler. Must take 4 args and be mounted last.
function errorHandler(err, req, res, next) {   // eslint-disable-line no-unused-vars
  // Unknown/unexpected errors default to 500 and a safe message.
  const isKnown = err instanceof AppError;
  const statusCode = isKnown ? err.statusCode : 500;

  const body = {
    error: {
      message: isKnown ? err.message : 'Internal Server Error',
      status: statusCode,
    },
  };

  if (err.details) body.error.details = err.details;         // validation fields
  if (process.env.NODE_ENV !== 'production') body.error.stack = err.stack;

  // Log server-side; only 5xx are true surprises worth shouting about.
  if (statusCode >= 500) console.error(err);

  res.status(statusCode).json(body);
}

module.exports = { notFoundHandler, errorHandler };

๐Ÿ“– One click through the whole error path

Client sends GET /api/tasks/nope โ†’ getOne calls taskStore.findById('nope') โ†’ the store finds nothing and throw new NotFoundError(...) โ†’ the rejected promise is caught by asyncHandler, which calls next(err) โ†’ Express skips every normal middleware and jumps to the 4-arg errorHandler โ†’ it reads statusCode = 404 and responds {"error":{"message":"Task nope not found","status":404}}. You wrote the formatting once, and it covers validation, not-found, and unexpected crashes alike.

A thrown error skips the normal handlers and funnels into one error middleware controller throws NotFoundError data layer throws NotFoundError asyncHandler .catch(next) errorHandler (err, req, res, next) Every throw converges on one place โ€” format the response once.
Custom error classes carry the status; asyncHandler forwards; one middleware formats.

Stage 6 โ€” Pagination, Filter & Sort

A list endpoint that returns everything falls over the moment there are ten thousand tasks. Real APIs let the client ask for a slice: filter to narrow the set, sort to order it, and paginate to take one page at a time. All three are read from the query string on GET /api/tasks, e.g. ?status=open&priority=high&sort=-dueDate&page=2&limit=20.

Keep the logic in one small, testable helper so the controller stays clean:

// src/data/query.js
// Apply filter โ†’ sort โ†’ paginate to an array, driven by req.query.
function paginateAndSort(items, query) {
  let result = [...items];

  // --- 1. FILTER: exact-match on known fields, if present ---
  for (const field of ['status', 'priority']) {
    if (query[field]) {
      result = result.filter((t) => t[field] === query[field]);
    }
  }

  // --- 2. SORT: "?sort=dueDate" ascending, "?sort=-dueDate" descending ---
  if (query.sort) {
    const desc = query.sort.startsWith('-');
    const key = desc ? query.sort.slice(1) : query.sort;
    result.sort((a, b) => {
      if (a[key] === b[key]) return 0;
      const cmp = a[key] > b[key] ? 1 : -1;
      return desc ? -cmp : cmp;
    });
  }

  // --- 3. PAGINATE: clamp page/limit to safe numbers, then slice ---
  const page = Math.max(1, parseInt(query.page, 10) || 1);
  const limit = Math.min(100, Math.max(1, parseInt(query.limit, 10) || 20));
  const total = result.length;
  const totalPages = Math.max(1, Math.ceil(total / limit));
  const start = (page - 1) * limit;
  const data = result.slice(start, start + limit);

  return {
    data,
    pagination: { page, limit, total, totalPages, hasNext: page < totalPages },
  };
}

module.exports = { paginateAndSort };

Because controller.list already calls this helper and returns its result, the response now carries both the page of tasks and the metadata a client needs to build "next / previous" controls:

{
  "data": [
    { "id": "a1b2โ€ฆ", "title": "Ship the API", "status": "open", "priority": "high", "dueDate": "2026-08-05", "createdAt": "โ€ฆ", "updatedAt": "โ€ฆ" }
  ],
  "pagination": { "page": 2, "limit": 20, "total": 47, "totalPages": 3, "hasNext": true }
}

๐Ÿ’ก Always clamp pagination inputs

Query parameters are strings typed by strangers. Someone will request ?limit=999999 (to dump your whole database) or ?page=-5 (to break your math). The helper defends against both: Math.min(100, โ€ฆ) caps the page size, Math.max(1, โ€ฆ) floors the page number, and parseInt(...) || default handles non-numeric junk. Clamp first, slice second โ€” never let raw query input drive a database read.

โš ๏ธ Whitelist your sort fields

The sort code above trusts whatever key the client sends. For a demo that's fine, but in production you'd restrict it: const allowed = ['dueDate', 'priority', 'createdAt']; if (!allowed.includes(key)) key = 'createdAt';. Blindly sorting by an arbitrary field name invites both errors and, with a real database, injection-style abuse. Whitelisting is a one-line habit worth forming now.

Stage 7 โ€” Test With curl

Fire up the server with npm run dev, then drive every endpoint from a second terminal. Watch not just the JSON but the status code โ€” that's the real contract. Add -i to any command to print response headers including the status line.

# Create a task โ†’ expect 201 Created
curl -i -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Ship the API","priority":"high","dueDate":"2026-08-05T00:00:00.000Z"}'

# List tasks, filtered + sorted + paginated โ†’ expect 200 OK
curl "http://localhost:3000/api/tasks?status=open&sort=-dueDate&page=1&limit=10"

# Read one task (paste a real id from the create response) โ†’ 200 or 404
curl http://localhost:3000/api/tasks/PASTE_ID_HERE

# Update a task โ†’ expect 200 OK
curl -X PUT http://localhost:3000/api/tasks/PASTE_ID_HERE \
  -H "Content-Type: application/json" \
  -d '{"status":"done"}'

# Delete a task โ†’ expect 204 No Content (empty body)
curl -i -X DELETE http://localhost:3000/api/tasks/PASTE_ID_HERE

Now prove the unhappy paths, which are where beginner APIs fall apart:

# Missing title โ†’ expect 422 with a per-field details array
curl -i -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"priority":"high"}'

# Unknown id โ†’ expect 404 Not Found
curl -i http://localhost:3000/api/tasks/does-not-exist

# Bad route โ†’ expect 404 from the notFoundHandler
curl -i http://localhost:3000/api/nope

The 422 response should look like this โ€” a stable shape your future front end can rely on:

{
  "error": {
    "message": "Validation failed",
    "status": 422,
    "details": [
      { "field": "title", "message": "title is required" }
    ]
  }
}

โœ… Prefer a REST client? Use a .http file

If you have the VS Code REST Client extension, you can keep these requests in a checked-in requests.http file and click "Send Request" above each one:

### Create a task
POST http://localhost:3000/api/tasks
Content-Type: application/json

{ "title": "Ship the API", "priority": "high" }

### List open tasks, newest due first
GET http://localhost:3000/api/tasks?status=open&sort=-dueDate

It's the same requests as the curl commands, just easier to re-run and share with teammates.

Stretch Goals

Finished the required build with time to spare? Level it up โ€” pick whichever excites you; none are needed to pass the rubric.

  • ๐Ÿ” Auth preview โ€” protect the write routes with a simple API-key middleware that checks an x-api-key header and throws a 401 Unauthorized (add an UnauthorizedError to your error family) when it's missing or wrong
  • ๐Ÿ’พ Persistence โ€” swap the in-memory store for the JSON-file variant from Stage 2 so tasks survive a restart, then add a graceful shutdown that flushes to disk
  • ๐Ÿงช Automated tests โ€” install jest and supertest, import createApp(), and assert on status codes and bodies for the happy and error paths (this is why app.js and server.js are separate!)
  • ๐Ÿฉน PATCH support โ€” add PATCH /api/tasks/:id for partial updates alongside the full-replace PUT, and explain the difference in your README
  • ๐Ÿท๏ธ A second resource โ€” add /api/projects and a projectId on tasks, plus GET /api/projects/:id/tasks as a nested route
  • ๐Ÿšฆ Rate limiting & security headers โ€” add express-rate-limit and helmet to see how one-line middleware hardens an API

API-key auth starter

Here's how little code the auth preview takes โ€” one middleware, mounted only on the routes you want to protect:

// src/middleware/requireApiKey.js
const { AppError } = require('../errors/AppError');

class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') { super(message, 401); }
}

function requireApiKey(req, res, next) {
  if (req.get('x-api-key') !== process.env.API_KEY) {
    return next(new UnauthorizedError('Valid x-api-key header required'));
  }
  next();
}

module.exports = { requireApiKey, UnauthorizedError };
// In taskRoutes.js: router.post('/', requireApiKey, validateCreateTask, runValidation, controller.create);

Because it throws an AppError subclass, it plugs into your existing error handler with zero changes โ€” the same reward, one more time, for centralizing errors early.

Self-Check Rubric

Before you call this done, grade yourself against the rubric. Aim to answer "yes" to everything in the first two columns โ€” the stretch column is bonus.

AreaMeets expectations (required)Exceeds (stretch)
App structure express.json() mounted; routes, controllers, middleware, data & errors in separate folders; app.js split from server.js Tests import createApp() without opening a port
CRUD & routes All five actions work on noun-based REST routes via express.Router() Adds PATCH and/or a second nested resource
Status codes 201 on create, 204 on delete, 200 on read/update; 400/404/422 on failure 401/403 added for the auth preview
Validation express-validator rules on create & update; a consistent per-field error list Whitelisted sort fields; query-param validation too
Error handling One 4-arg middleware; custom AppError classes; asyncHandler on every controller; no try/catch scattered around 5xx logged; stack hidden in production; graceful 500 fallback
List endpoint Pagination, filter & sort all work, with pagination metadata in the body Clamped & whitelisted inputs; hasNext/links returned
Data & quality Store hidden behind a repository; no console errors; server never crashes on bad input JSON-file persistence; automated tests green

๐Ÿงช Final testing checklist

  • โ˜ POST a valid task returns 201 and the created task with an id
  • โ˜ POST with no title returns 422 and a details array naming title
  • โ˜ GET /api/tasks returns { data, pagination }, and ?limit=2&page=2 returns the right slice
  • โ˜ ?status=open&sort=-dueDate filters and orders correctly
  • โ˜ GET/PUT/DELETE on an unknown id all return 404 (never a 500 crash)
  • โ˜ DELETE of a real task returns 204 with an empty body
  • โ˜ A totally unknown route returns 404 from the notFoundHandler
  • โ˜ No unhandled-promise-rejection warnings in the server console

Summary

๐ŸŽ‰ What You Built

  • A modern Express API scaffolded with built-in express.json() and express.Router(), cleanly split into routes, controllers, middleware, data, and errors
  • Full CRUD for a tasks resource on correct REST routes, each returning the precise status code its outcome deserves
  • express-validator input validation that produces one consistent, per-field error shape
  • A single 4-argument error middleware fed by custom AppError classes and an asyncHandler wrapper โ€” write the response format once, cover every failure
  • Pagination, filtering, and sorting on the list endpoint, with clamped inputs and useful metadata in the body
  • A data store hidden behind a repository, so in-memory today swaps to a JSON file โ€” or a real database โ€” without touching your routes

This project is proof that Week 7 stuck. You crossed from calling other people's servers to running your own, and you did it the way professionals do: resources over verbs, validation you can trust, errors funneled to one place, and a list endpoint that scales. The moves you practiced here โ€” separate the layers, wrap your async, centralize your errors, never trust the query string โ€” are the everyday grammar of production backends.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

Your API works โ€” but every task lives in an array (or a JSON file) that a single restart can wipe, and there's no querying beyond a linear scan. Real apps need a real database. Week 8 begins exactly there: the next lesson, Relational vs NoSQL Databases, weighs tables-and-rows (SQL, like PostgreSQL) against documents-and-collections (NoSQL, like MongoDB), so you can choose the right home for your data before you wire it into this very API.

๐ŸŽ‰ You finished Week 7!

You shipped a real backend with validation, centralized errors, and a paginated resource. Push it to GitHub, add it to your portfolio โ€” you're building APIs now, not just calling them.