Say What You Mean
A REST API talks through two vocabularies. The method says what the client wants: GET read, POST create, PUT replace, PATCH change a part, DELETE remove. The status code says how it went: 2xx success, 4xx the client's mistake, 5xx the server's.
A detail worth knowing: PUT is idempotent — sending it twice ends the same as once. POST is not: two POSTs create two resources.
The Right Method
The router matches the method as well as the path, so every verb gets its own handler. Creation answers 201 Created and points at the new resource with a Location header; deletion answers 204 No Content — success with nothing to say, so the body stays empty.
GET, POST and DELETE on one resource
Three verbs, three answers: the list comes back with 200, the creation with 201 and a Location, the deletion with 204 and an empty body.
Output: TypeScript file and captured output
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
params: Record<string, string>,
) => void
interface Route {
method: string
pattern: string
handler: RouteHandler
}
function matchPath(pattern: string, path: string): Record<string, string> | undefined {
const want = pattern.split("/")
const got = path.split("/")
if (want.length !== got.length) return undefined
const params: Record<string, string> = {}
for (let i = 0; i < want.length; i++) {
if (want[i].startsWith("{") && want[i].endsWith("}")) {
params[want[i].slice(1, -1)] = got[i]
} else if (want[i] !== got[i]) {
return undefined
}
}
return params
}
function router(routes: Route[]) {
return (req: IncomingMessage, res: ServerResponse): void => {
const url = new URL(req.url ?? "/", "http://localhost")
for (const route of routes) {
const params = matchPath(route.pattern, url.pathname)
if (params !== undefined && route.method === req.method) {
route.handler(req, res, params)
return
}
}
res.writeHead(404).end("not found\n")
}
}
const routes: Route[] = [
{
method: "GET",
pattern: "/notes",
handler: (req, res) => {
res.end("notes: shopping, ideas\n")
},
},
{
method: "POST",
pattern: "/notes",
handler: (req, res) => {
// 201 Created, and the Location header points at the new resource.
res.writeHead(201, { Location: "/notes/3" }).end("created\n")
},
},
{
method: "DELETE",
pattern: "/notes/{id}",
handler: (req, res) => {
// 204 No Content: success with nothing to say.
res.writeHead(204).end()
},
},
]
async function main(): Promise<void> {
const server = createServer(router(routes))
await new Promise<void>(resolve => server.listen(0, () => resolve()))
const base = `http://localhost:${(server.address() as { port: number }).port}`
const list = await fetch(`${base}/notes`)
console.log("GET /notes ->", list.status, (await list.text()).trim())
const created = await fetch(`${base}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: '{"text": "call Ben"}',
})
console.log("POST /notes ->", created.status, "Location:", created.headers.get("location"))
const removed = await fetch(`${base}/notes/3`, { method: "DELETE" })
console.log("DELETE /notes/3 ->", removed.status, "(no content)")
server.close()
}
main()
The Right Status Code
Errors deserve precision too: a missing resource is 404 Not Found, with a JSON error body so machines can read it. A wrong verb on a known path is 405 Method Not Allowed.
Nothing sends that 405 for you here. The router checks both parts separately: if some route's path matched but no route's method did, it answers 405 and adds an Allow header listing the verbs that would have worked.
200, 404 and 405
The existing note returns 200 with data, the missing one 404 with a JSON error, and the DELETE on a GET-only route is refused with 405 by the router itself.
Output: TypeScript file and captured output
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
params: Record<string, string>,
) => void
interface Route {
method: string
pattern: string
handler: RouteHandler
}
function matchPath(pattern: string, path: string): Record<string, string> | undefined {
const want = pattern.split("/")
const got = path.split("/")
if (want.length !== got.length) return undefined
const params: Record<string, string> = {}
for (let i = 0; i < want.length; i++) {
if (want[i].startsWith("{") && want[i].endsWith("}")) {
params[want[i].slice(1, -1)] = got[i]
} else if (want[i] !== got[i]) {
return undefined
}
}
return params
}
// The path matched but the method did not: answer 405 and say what is allowed.
function router(routes: Route[]) {
return (req: IncomingMessage, res: ServerResponse): void => {
const url = new URL(req.url ?? "/", "http://localhost")
const allowed: string[] = []
for (const route of routes) {
const params = matchPath(route.pattern, url.pathname)
if (params === undefined) continue
if (route.method === req.method) {
route.handler(req, res, params)
return
}
allowed.push(route.method)
}
if (allowed.length > 0) {
res.writeHead(405, { Allow: allowed.join(", ") }).end()
return
}
res.writeHead(404).end()
}
}
const notes = new Map<string, string>([["1", "shopping list"]])
const routes: Route[] = [
{
method: "GET",
pattern: "/notes/{id}",
handler: (req, res, params) => {
res.setHeader("Content-Type", "application/json")
const text = notes.get(params.id)
if (text === undefined) {
res.writeHead(404).end(JSON.stringify({ error: "note not found" }))
return
}
res.end(JSON.stringify({ id: params.id, text }))
},
},
]
async function main(): Promise<void> {
const server = createServer(router(routes))
await new Promise<void>(resolve => server.listen(0, () => resolve()))
const base = `http://localhost:${(server.address() as { port: number }).port}`
for (const path of ["/notes/1", "/notes/99"]) {
const res = await fetch(base + path)
console.log("GET ", path.padEnd(9), "->", res.status, await res.text())
}
const refused = await fetch(`${base}/notes/1`, { method: "DELETE" })
console.log("DELETE", "/notes/1".padEnd(9), "->", refused.status, refused.statusText)
server.close()
}
main()