100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Node.js & Express Backend
55 minintermediate

Practice — Fully-RESTful Product API

Practice: Fully-RESTful Product API

In this exercise you will build a fully-RESTful Product API that implements everything covered in Module 3: proper resource naming, HTTP verb semantics, pagination with filtering and sorting, content negotiation (JSON and CSV), HATEOAS hypermedia links, and dual versioning (URI v1 and Accept-header v2). This is the capstone exercise for the REST Design module.

Analogy🏏Cricket
🏏 Think of it like cricket: A capstone tournament is where a player finally combines every discipline drilled separately in the nets — footwork, shot selection, running between wickets, and game awareness — into one complete innings under match conditions. Just as no single net session tests all skills at once but the tournament demands them together, this exercise fuses everything from Module 3 into one Express app: proper resource naming, correct HTTP verb semantics, pagination with filtering and sorting, content negotiation across JSON and CSV, HATEOAS hypermedia links, and dual versioning via URI v1 and Accept-header v2. Just as a batsman is judged not on isolated drills but on stitching them into runs, you are judged on making these techniques work in concert. Just as the capstone match reveals which skills are truly match-ready, building the full API reveals whether each REST concept has become second nature. The payoff: integrating the whole module into one working API cements REST design as practical, not theoretical.

What You'll Build

A single Express application exposing /api/v1/products and /api/v2/products. Version 1 returns a flat array; Version 2 returns a paginated envelope with HATEOAS links. Both versions support ?search=, ?category=, ?sort=price&order=asc filtering. The /api/v1/products endpoint also responds with CSV when Accept: text/csv is sent.

Analogy🏏Cricket
🏏 Think of it like cricket: You are the data operations manager at the BCCI analytics division, responsible for processing the complete ball-by-ball feed from an IPL season — 74 matches, 888 overs, over 5,000 deliveries. The raw feed arrives as a continuous data stream from the scoring tablets at each ground. Your job is to build the pipeline that ingests that stream, filters out extras and wide deliveries for certain statistics, aggregates the useful data into player-level summaries, and publishes the final report to the BCCI's official statistics portal before the next morning's press conference. Just as the BCCI would never ask analysts to hold the entire season's data in memory before starting analysis (they process each match's data as it arrives from the ground), your pipeline processes each CSV chunk as it streams from disk — maintaining a constant memory footprint regardless of how many seasons of data you process.

Prerequisites

  • Completed lessons 13–17 (REST naming, pagination, HATEOAS, content negotiation, versioning)
  • Node.js 18+ and npm installed
  • Basic understanding of Express Router and middleware
  • Familiarity with curl or Postman for testing

Step 1: Project Setup

Initialise the project, install dependencies, and create the folder structure.

Analogy🏏Cricket
🏏 Think of it like cricket: Before a tournament begins, the organisers mark out the ground, erect the dressing rooms and stores, and hang the signage — a deliberate layout so that once play starts, every person and piece of equipment has an obvious home. Just as marking the boundary and pitch first prevents chaos on match day, initialising the project, installing Express, and laying out the folder structure — data, middleware, routes/v1, routes/v2, utils — up front means each later piece slots into a known place. Just as separate stores for bats, balls and protective gear keep the pavilion orderly, splitting seed data, negotiation and versioning middleware, versioned routers, and reusable utilities into distinct folders keeps the codebase navigable. Just as a well-marked ground lets officials find anything instantly, this structure lets you add a route or utility without hunting. The payoff: a clean initial setup is the foundation every subsequent step of the API builds on without friction.
bash
mkdir restful-product-api && cd restful-product-api
npm init -y
npm install express
javascript
// Folder structure
// restful-product-api/
//   src/
//     data/
//       products.js      (in-memory seed data)
//     middleware/
//       negotiation.js   (Vary: Accept header)
//       versioning.js    (X-API-Version header reader)
//     routes/
//       v1/
//         products.js
//       v2/
//         products.js
//     utils/
//       paginate.js
//       hateoas.js
//       toCSV.js
//   app.js
//   server.js

Step 2: Seed Data and Utilities

Create the in-memory product store and reusable helper utilities.

Analogy🏏Cricket
🏏 Think of it like cricket: Before the match, the scoring desk loads the squad list into the system and prepares its reusable tools — a run-rate calculator, a partnership tracker, a printable-sheet formatter — so that during play the scorers reach for a ready helper rather than improvising each computation. Just as the pre-loaded squad list is the single source every report reads from, the in-memory products store is the shared data both API versions consume. Just as the run-rate calculator is written once and reused every over, the paginate helper slices a collection into pages, the toCSV helper formats records for export, and the hateoas helper attaches self and collection links — each a small, reusable utility. Just as sharing one calculator keeps every over's figures consistent, sharing these utilities keeps v1 and v2 behaviour aligned. The payoff: seeding data and factoring out helpers up front means the route handlers stay thin and focused on wiring, not recomputation.
javascript
// src/data/products.js
let products = Array.from({ length: 50 }, (_, i) => ({
  id: i + 1,
  name: `Product ${i + 1}`,
  category: ['electronics', 'apparel', 'books'][i % 3],
  price: parseFloat((Math.random() * 500 + 10).toFixed(2)),
  stock: Math.floor(Math.random() * 100)
}));

module.exports = { products };
javascript
// src/utils/paginate.js
function paginate(array, page = 1, limit = 10) {
  const total = array.length;
  const pages = Math.ceil(total / limit);
  const data  = array.slice((page - 1) * limit, page * limit);
  return { data, total, page, pages, limit };
}
module.exports = { paginate };

// src/utils/toCSV.js
function toCSV(items) {
  const headers = Object.keys(items[0]).join(',');
  const rows = items.map(p => Object.values(p).join(','));
  return [headers, ...rows].join('\n');
}
module.exports = { toCSV };

// src/utils/hateoas.js
function addLinks(product, baseUrl) {
  return {
    ...product,
    _links: {
      self:       { href: baseUrl + '/api/v2/products/' + product.id },
      collection: { href: baseUrl + '/api/v2/products' }
    }
  };
}
module.exports = { addLinks };

Step 3: Version 1 Routes

Build the v1 router with filtering, sorting, content negotiation, and partial update support.

Analogy🏏Cricket
🏏 Think of it like cricket: The classic scorecard desk offers the purist everything in one flexible view — the full ball-by-ball list, filterable to one batsman, sortable by runs, printable on demand as a paper sheet, plus the ability to correct a single entry without rewriting the whole card. Just as the desk filters the feed to a chosen batsman and orders it by a chosen metric, the v1 router applies ?search= and ?category= filters and sorts the result set. Just as the same desk hands out either the on-screen view or a printed sheet depending on what the requester wants, v1 uses res.format() for content negotiation — JSON by default, CSV with attachment headers when asked, and a 406 otherwise. Just as a scorer can amend one figure with a partial edit rather than reissuing the card, v1 supports PATCH partial update. The payoff: the v1 router delivers full REST behaviour — filter, sort, negotiate, and partial edit — over a plain flat-array response.
javascript
// src/routes/v1/products.js
const router  = require('express').Router();
const { products } = require('../../data/products');
const { toCSV }    = require('../../utils/toCSV');

// GET /api/v1/products  (flat array, filter, sort, CSV)
router.get('/', (req, res) => {
  let result = [...products];

  // filter
  if (req.query.search) {
    const s = req.query.search.toLowerCase();
    result = result.filter(p => p.name.toLowerCase().includes(s));
  }
  if (req.query.category) {
    result = result.filter(p => p.category === req.query.category);
  }

  // sort
  const { sort = 'id', order = 'asc' } = req.query;
  result.sort((a, b) =>
    order === 'desc' ? b[sort] - a[sort] : a[sort] - b[sort]
  );

  // content negotiation
  res.format({
    'application/json': () => res.json(result),
    'text/csv': () => {
      res.setHeader('Content-Disposition', 'attachment; filename="products.csv"');
      res.setHeader('Content-Type', 'text/csv');
      res.send(toCSV(result));
    },
    default: () => res.status(406).json({ error: 'Not Acceptable' })
  });
});

// GET /api/v1/products/:id
router.get('/:id', (req, res) => {
  const p = products.find(p => p.id === +req.params.id);
  if (!p) return res.status(404).json({ error: 'Not found' });
  res.json(p);
});

// POST /api/v1/products
router.post('/', (req, res) => {
  const p = { id: products.length + 1, ...req.body };
  products.push(p);
  res.status(201).location('/api/v1/products/' + p.id).json(p);
});

// PATCH /api/v1/products/:id
router.patch('/:id', (req, res) => {
  const idx = products.findIndex(p => p.id === +req.params.id);
  if (idx === -1) return res.status(404).json({ error: 'Not found' });
  products[idx] = { ...products[idx], ...req.body };
  res.json(products[idx]);
});

// DELETE /api/v1/products/:id
router.delete('/:id', (req, res) => {
  const idx = products.findIndex(p => p.id === +req.params.id);
  if (idx === -1) return res.status(404).json({ error: 'Not found' });
  products.splice(idx, 1);
  res.status(204).end();
});

module.exports = router;

Step 4: Version 2 Routes with HATEOAS and Pagination

Build the v2 router that returns the paginated envelope with HATEOAS hypermedia links on every item and on the collection response.

Analogy🏏Cricket
🏏 Think of it like cricket: The premium match-centre view does not dump every delivery at once — it serves one tidy page of results and, crucially, embeds navigation on the card itself: a link to the next page, to the first and last, and from each player entry a link to that player's own profile. Just as the match centre shows page 2 of 5 with working next and prev links rather than an overwhelming scroll, the v2 router wraps results in a paginated envelope carrying self, first, last, prev and next links. Just as each player row links straight to its detail page so a fan never has to guess the URL, addLinks attaches self and collection hypermedia links to every item and to the collection response. Just as embedded navigation lets a viewer explore the match centre without a manual, HATEOAS lets a client traverse the API by following links. The payoff: v2 returns a discoverable, paginated response that clients can navigate purely from the links it provides.
javascript
// src/routes/v2/products.js
const router   = require('express').Router();
const { products } = require('../../data/products');
const { paginate } = require('../../utils/paginate');
const { addLinks }  = require('../../utils/hateoas');

router.get('/', (req, res) => {
  let result = [...products];

  if (req.query.search) {
    const s = req.query.search.toLowerCase();
    result = result.filter(p => p.name.toLowerCase().includes(s));
  }
  if (req.query.category) {
    result = result.filter(p => p.category === req.query.category);
  }

  const { sort = 'id', order = 'asc' } = req.query;
  result.sort((a, b) =>
    order === 'desc' ? b[sort] - a[sort] : a[sort] - b[sort]
  );

  const page  = parseInt(req.query.page  || '1',  10);
  const limit = parseInt(req.query.limit || '10', 10);
  const paginated = paginate(result, page, limit);
  const base = req.protocol + '://' + req.get('host');

  paginated.data = paginated.data.map(p => addLinks(p, base));

  paginated._links = {
    self: { href: base + '/api/v2/products?page=' + page + '&limit=' + limit },
    first: { href: base + '/api/v2/products?page=1&limit=' + limit },
    last:  { href: base + '/api/v2/products?page=' + paginated.pages + '&limit=' + limit },
    ...(page > 1 && { prev: { href: base + '/api/v2/products?page=' + (page-1) + '&limit=' + limit }}),
    ...(page < paginated.pages && { next: { href: base + '/api/v2/products?page=' + (page+1) + '&limit=' + limit }})
  };

  res.json(paginated);
});

router.get('/:id', (req, res) => {
  const p = products.find(p => p.id === +req.params.id);
  if (!p) return res.status(404).json({ error: 'Not found' });
  const base = req.protocol + '://' + req.get('host');
  res.json(addLinks(p, base));
});

module.exports = router;

Step 5: Wire Up app.js and Test

javascript
// app.js
const express  = require('express');
const app      = express();

app.use(express.json());
app.use((req, res, next) => { res.setHeader('Vary', 'Accept'); next(); });

app.use('/api/v1/products', require('./src/routes/v1/products'));
app.use('/api/v2/products', require('./src/routes/v2/products'));

module.exports = app;

// server.js
const app = require('./app');
app.listen(3000, () => console.log('API running on :3000'));
bash
# Test v1 JSON
curl http://localhost:3000/api/v1/products?sort=price&order=desc

# Test v1 CSV
curl -H "Accept: text/csv" http://localhost:3000/api/v1/products

# Test v2 pagination + HATEOAS
curl "http://localhost:3000/api/v2/products?page=2&limit=5"

# Test filter + sort
curl "http://localhost:3000/api/v2/products?category=electronics&sort=price&order=asc"

# Test POST
curl -X POST http://localhost:3000/api/v1/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Bat","category":"sports","price":49.99,"stock":20}'

If your sort comparator uses a[sort] - b[sort] and the field is a string (like 'name'), it will return NaN. Add a guard: use localeCompare for strings and numeric subtraction only for numbers.

The in-memory products array is shared between v1 and v2 routes because both require the same ../../data/products module. Node.js caches require() results, so mutations in v1 (POST, PATCH, DELETE) are immediately visible in v2.

  • URI versioning mounts entire route sets under /api/v1 and /api/v2 prefixes
  • res.format() handles content negotiation cleanly with a default 406 fallback
  • HATEOAS links should include at minimum: self, collection, prev, and next
  • Pagination metadata (total, pages, limit) belongs in the envelope, not in headers
  • Sort comparators must handle both numeric and string fields correctly
  • The Vary: Accept header prevents CDNs from serving the wrong cached format
Lesson 18 of 36
0% complete