1. The Problem
Build a REST API for a task manager. The API supports full CRUD: Create (POST), Read (GET), Update (PATCH), Delete (DELETE). Data lives in memory (Map). Responses are JSON. Support filtering by completion status and priority via query parameters.
Why this project? REST is how the web works. Every web app you'll build — from React frontends to mobile apps — talks to an API like this. You'll understand HTTP methods, status codes, request bodies, and query parameters by building the thing that handles them.
2. How to Think About This
REST maps HTTP verbs to CRUD operations:
| HTTP Method | Path | Action | Status |
|---|---|---|---|
| GET | /tasks | List all (with optional filters) | 200 |
| POST | /tasks | Create a new task | 201 Created |
| GET | /tasks/:id | Get one task | 200 or 404 |
| PATCH | /tasks/:id | Update fields (partial) | 200 or 404 |
| DELETE | /tasks/:id | Delete a task | 204 No Content or 404 |
Separate the data store (TaskStore class) from the HTTP handlers. The store is pure and testable; the handlers deal with parsing requests and formatting responses.
3. The Build
import { createServer } from "http";
import { URL } from "url";
// ── Data store ─────────────────────────────────────────────────────────────
class TaskStore {
#tasks = new Map();
#nextId = 1;
create({ title, description = "", priority = "medium" }) {
if (!title?.trim())
throw Object.assign(new Error("title is required"), { status: 400 });
if (!["low","medium","high"].includes(priority))
throw Object.assign(new Error("priority must be low, medium, or high"), { status: 400 });
const task = {
id: this.#nextId++,
title: title.trim(),
description: description.trim(),
priority,
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this.#tasks.set(task.id, task);
return task;
}
findAll(query = {}) {
let tasks = [...this.#tasks.values()];
if (query.completed !== undefined)
tasks = tasks.filter(t => t.completed === (query.completed === "true"));
if (query.priority)
tasks = tasks.filter(t => t.priority === query.priority);
return tasks;
}
findById(id) {
const task = this.#tasks.get(Number(id));
if (!task) throw Object.assign(new Error(`Task ${id} not found`), { status: 404 });
return task;
}
update(id, data) {
const task = this.findById(id); // throws 404 if not found
const { id: _id, createdAt, ...allowed } = data; // prevent overriding id/createdAt
const updated = { ...task, ...allowed, updatedAt: new Date().toISOString() };
this.#tasks.set(task.id, updated);
return updated;
}
delete(id) {
this.findById(id); // throws 404 if not found
this.#tasks.delete(Number(id));
}
}
const store = new TaskStore();
// ── HTTP helpers ───────────────────────────────────────────────────────────
function sendJSON(res, status, body) {
const json = JSON.stringify(body, null, 2);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(json),
"Access-Control-Allow-Origin": "*", // CORS: allow any frontend
});
res.end(json);
}
function readJSON(req) {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", chunk => { data += chunk; if (data.length > 1e6) req.destroy(); });
req.on("end", () => {
if (!data.trim()) return resolve({});
try { resolve(JSON.parse(data)); }
catch { reject(Object.assign(new Error("Invalid JSON"), { status: 400 })); }
});
req.on("error", reject);
});
}
// ── Request router ─────────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
const { pathname, searchParams } = new URL(req.url, "http://localhost");
const method = req.method;
const query = Object.fromEntries(searchParams);
// Match /tasks or /tasks/123
const tasksRoot = /^\/tasks\/?$/.test(pathname);
const tasksById = /^\/tasks\/(\d+)\/?$/.exec(pathname);
const id = tasksById?.[1];
// Logger
console.log(`${method} ${pathname}`);
try {
// OPTIONS — CORS preflight
if (method === "OPTIONS") {
res.writeHead(204, { "Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET,POST,PATCH,DELETE","Access-Control-Allow-Headers":"Content-Type" });
return res.end();
}
// GET /tasks
if (method === "GET" && tasksRoot) {
return sendJSON(res, 200, store.findAll(query));
}
// POST /tasks
if (method === "POST" && tasksRoot) {
const body = await readJSON(req);
const task = store.create(body);
return sendJSON(res, 201, task);
}
// GET /tasks/:id
if (method === "GET" && id) {
return sendJSON(res, 200, store.findById(id));
}
// PATCH /tasks/:id
if (method === "PATCH" && id) {
const body = await readJSON(req);
const updated = store.update(id, body);
return sendJSON(res, 200, updated);
}
// DELETE /tasks/:id
if (method === "DELETE" && id) {
store.delete(id);
res.writeHead(204);
return res.end();
}
sendJSON(res, 404, { error: "Route not found" });
} catch (e) {
sendJSON(res, e.status ?? 500, { error: e.message });
if (!e.status) console.error(e);
}
});
server.listen(3000, () => console.log("REST API at http://localhost:3000"));
4. Test & Prove
// Test the TaskStore in isolation — no HTTP needed
class TaskStore {
#tasks = new Map(); #nextId = 1;
create({title,description="",priority="medium"}) {
if(!title?.trim()) throw Object.assign(new Error("title required"),{status:400});
const t={id:this.#nextId++,title:title.trim(),description,priority,completed:false,
createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};
this.#tasks.set(t.id,t); return t;
}
findAll(q={}) {
let r=[...this.#tasks.values()];
if(q.completed!==undefined) r=r.filter(t=>t.completed===(q.completed==="true"));
if(q.priority) r=r.filter(t=>t.priority===q.priority);
return r;
}
findById(id){const t=this.#tasks.get(Number(id));if(!t)throw Object.assign(new Error("not found"),{status:404});return t;}
update(id,data){const t=this.findById(id);const u={...t,...data,updatedAt:new Date().toISOString()};this.#tasks.set(t.id,u);return u;}
delete(id){this.findById(id);this.#tasks.delete(Number(id));}
}
let pass=0,fail=0;
function test(desc,fn){try{fn();console.log(" ✓",desc);pass++;}catch(e){console.log(" ✗",desc,"—",e.message);fail++;}}
const s = new TaskStore();
const t1 = s.create({title:"Buy milk",priority:"low"});
const t2 = s.create({title:"Write tests",priority:"high"});
test("create assigns id", () => { if(t1.id!==1) throw new Error(t1.id); });
test("findAll returns both", () => { if(s.findAll().length!==2) throw new Error(); });
test("findById returns task", () => { if(s.findById(1).title!=="Buy milk") throw new Error(); });
test("findById 404 throws", () => { try{s.findById(99);throw new Error("no throw");}catch(e){if(!e.status)throw new Error("no status");} });
test("filter by priority", () => { if(s.findAll({priority:"high"}).length!==1) throw new Error(); });
test("update changes field", () => { s.update(1,{completed:true}); if(!s.findById(1).completed) throw new Error(); });
test("filter completed=true", () => { if(s.findAll({completed:"true"}).length!==1) throw new Error(); });
test("delete removes task", () => { s.delete(2); if(s.findAll().length!==1) throw new Error(); });
test("delete missing throws 404", () => { try{s.delete(99);throw new Error("no throw");}catch(e){if(!e.status)throw new Error(); } });
test("empty title throws 400", () => { try{s.create({title:""});throw new Error("no throw");}catch(e){if(e.status!==400)throw e;} });
console.log(\`
\${pass} passed, \${fail} failed\`);
5. The Interface
curl http://localhost:3000/tasks
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Learn REST","priority":"high"}'
curl http://localhost:3000/tasks/1
curl -X PATCH http://localhost:3000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"completed":true}'
curl -X DELETE http://localhost:3000/tasks/1
# Filtering:
curl "http://localhost:3000/tasks?priority=high"
curl "http://localhost:3000/tasks?completed=false"6. Run It
node api.mjs # start the server
node api.test.mjs # test the store logic (no server needed)🎯 Try this next
- Add JSON file persistence — load on startup, save on every mutation
- Add input validation middleware — extract the validation logic to a reusable function
- Add pagination:
?page=1&limit=10on GET /tasks - Port to Express.js — compare the line count and notice what disappears
What you practised
REST design (verbs → actions, paths → resources, status codes → meaning),
request body parsing as a Promise, CORS headers, attaching .status
to Error objects for typed HTTP errors, PATCH vs PUT (partial vs full update),
204 No Content for DELETE, and URL query parameter parsing with URLSearchParams.
Reference: Async & Promises · Error Handling · OOP & Classes