The Codex / Projects / REST API / JavaScript
Tier 3 — Advanced Beginner

REST API

Build a full REST API — HTTP methods, JSON store, status codes, CORS, query filters.

🐍 Python 🟨 JavaScript

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 MethodPathActionStatus
GET/tasksList all (with optional filters)200
POST/tasksCreate a new task201 Created
GET/tasks/:idGet one task200 or 404
PATCH/tasks/:idUpdate fields (partial)200 or 404
DELETE/tasks/:idDelete a task204 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

api.mjs
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

api.test.mjs
// 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

  1. Add JSON file persistence — load on startup, save on every mutation
  2. Add input validation middleware — extract the validation logic to a reusable function
  3. Add pagination: ?page=1&limit=10 on GET /tasks
  4. 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

Try It Yourself