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.
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.
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.
mkdir restful-product-api && cd restful-product-api
npm init -y
npm install express// 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.jsStep 2: Seed Data and Utilities
Create the in-memory product store and reusable helper utilities.
// 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 };// 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.
// 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.
// 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
// 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'));# 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