1 The Problem
We want a REST API: a web service that lets clients create, read, update, and delete records (a to-do list, say) using standard HTTP methods (GET, POST, PUT, DELETE) and JSON. It teaches the conventions of REST — how the entire web of apps and services talks to itself.
2 How to Think About It
Think about how REST maps actions to HTTP, before any code:
/todos/3. → 2. The HTTP method says what to do: GET reads, POST creates, DELETE removes. → 3. The body carries JSON data. → 4. The server routes each request to the right handler and returns JSON. This method-plus-URL convention is REST.
3 The Build — explained part by part
Here is the complete API. Read each part’s note below — you should understand the whole thing from the notes alone.
import * as http from "node:http";
export interface Todo {
id: number;
task: string;
done: boolean;
}
export type Store = Map<number, Todo>;
export function createTodo(store: Store, nextId: number, task: string): Todo {
const todo: Todo = { id: nextId, task, done: false };
store.set(nextId, todo);
return todo;
}
export function deleteTodo(store: Store, todoId: number): boolean {
return store.delete(todoId);
}
function send(res: http.ServerResponse, code: number, data: unknown): void {
res.writeHead(code, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function main(): void {
const todos: Store = new Map();
let nextId = 1;
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/todos") {
// GET /todos -> list every todo.
send(res, 200, [...todos.values()]);
return;
}
if (req.method === "POST" && req.url === "/todos") {
// POST /todos -> create a todo from the JSON body.
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
const parsed = JSON.parse(body) as { task: string };
const todo = createTodo(todos, nextId, parsed.task);
nextId += 1;
send(res, 201, todo);
});
return;
}
if (req.method === "DELETE" && req.url?.startsWith("/todos/")) {
// DELETE /todos/3 -> remove that todo.
const todoId = Number(req.url.split("/").pop());
if (deleteTodo(todos, todoId)) {
send(res, 200, { deleted: todoId });
} else {
send(res, 404, { error: "not found" });
}
return;
}
send(res, 404, { error: "not found" });
});
server.listen(8000, "localhost");
}
if (require.main === module) {
main();
}Todo always has an id, a task, and a done flag; the in-memory database (Store) is a Map from numeric ID to Todo, Node’s nearest equivalent to Python’s plain dict used the same way.createTodo / deleteTodo — pulled out of the server exactly like the Python version’s helper functions, so the core logic is tested without starting a real server or making a real HTTP request.
http.createServer((req, res) => { ... }) — Node’s built-in HTTP server, no framework required. Each request is routed by hand, checking
req.method and req.url — the same manual routing style as the Python version’s do_GET/do_POST/do_DELETE methods.req.on("data", ...) / req.on("end", ...) — an incoming request body arrives in chunks as a stream; we collect every chunk and only parse the JSON once the
'end' event says the whole body has arrived.
JSON.parse(body) before the 'end' event fires.'data' events; parsing too early gets a truncated, invalid JSON string. Always parse inside the 'end' handler.{}) for the todo store and numeric-looking string keys.Map<number, Todo> keeps the keys genuinely numeric and makes .has()/.delete() explicit, rather than relying on JavaScript’s implicit string coercion of object keys.if blocks above falls through to a final send(res, 404, ...), so the server always responds instead of hanging.4 Test & Prove Each Part
How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createTodo, deleteTodo, type Store } from "./rest-api";
test("creating stores a todo with its ID", () => {
const store: Store = new Map();
const todo = createTodo(store, 1, "Buy milk");
assert.equal(todo.id, 1);
assert.equal(store.get(1)?.task, "Buy milk");
});
test("deleting removes the todo", () => {
const store: Store = new Map([[1, { id: 1, task: "x", done: false }]]);
assert.equal(deleteTodo(store, 1), true);
assert.equal(store.has(1), false);
});
test("deleting a missing todo returns false", () => {
assert.equal(deleteTodo(new Map(), 99), false);
});Compile with npx tsc then run node --test api.test.js. createTodo/deleteTodo operate on a plain Map, so the tests never start the real server or make an HTTP request — that is verified separately, by hand, with curl (see below).
5 The Interface
The API’s endpoints, documented like any professional REST service.
What it expects
{"task": "Buy milk"}What it returns
{"id":1,"task":"Buy milk","done":false}6 Run It & Automate It
Save the code as api.ts, compile with npx tsc, and run with node api.js — or run it directly with npx tsx api.ts. It listens on localhost:8000.
npx tsc api.ts && node api.jsStarts the server; leave it running in one terminal and talk to it with curl from another.
A CI tool like Jenkins runs the type-checker and tests automatically whenever the code changes — every line below has a plain explanation.
$ curl http://localhost:8000/todos
[]
$ curl -X POST http://localhost:8000/todos -d '{"task":"Buy milk"}'
{"id":1,"task":"Buy milk","done":false}
$ curl http://localhost:8000/todos
[{"id":1,"task":"Buy milk","done":false}]EADDRINUSE: address already in use :::8000server.listen(8000, ...).POST hangs and never respondsContent-Length — and that every code path inside the handler eventually calls send(...), since res.end() is what finishes the response.// Jenkinsfile — runs the type-checker and tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Node') {
steps {
sh 'node --version' // confirm Node is installed
sh 'npm install -D typescript @types/node' // zero runtime deps — just the compiler and its Node types
}
}
stage('Type-check and test') {
steps {
sh 'npx tsc --noEmit' // catch type errors before anything runs
sh 'npx tsc' // compile to plain JavaScript
sh 'node --test api.test.js' // Node's built-in test runner, no extra install needed
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
You have a working rest api. Extend it:
- Add a PUT /todos/:id route. Let an existing todo’s
taskordonebe updated. (Teaches: parsing a body for an update instead of a create.) - Validate the request body. Reject a
POSTwith notaskfield with a400. (Teaches: a type guard on untrusted JSON before trusting its shape.) - Persist to a file. Save the
Storeto JSON on every change, as the to-do list and CLI task manager projects do. (Teaches: combining this project with another on this page.) - Add query-string filtering. Support
GET /todos?done=true. (Teaches:URLandURLSearchParamsfor parsing query strings.)
interface, route raw HTTP requests by hand with Node’s built-in http module, and why a streamed request body must be fully collected before JSON.parse. Related reference: Interfaces vs. Types, Typing Async Code.