這一課完成後:你的 API 會有可查詢的 GET /api/tasks 與可建立資料的 POST /api/tasks。你會理解 path、query string、request body、JSON parsing、validation,以及 200 / 201 / 400 / 404 這些 status code 各自在表達什麼。
先把 API 當成一份「請求契約」
一個 request 不只有 URL。Backend 通常會一起看:
Path → 對哪個資源
Query → 這次怎麼查
Headers → request 的額外資訊
Body → 要送進來的資料
例如:
POST /api/tasks
Content-Type: application/json
{
"title": "學會 request body"
}
這表示 client 想對 /api/tasks 建立一筆新資料,而且 body 是 JSON。
先建立一份記憶體中的 tasks
這一課還沒有 database,所以先在 server.js 放:
let tasks = [
{ id: 1, title: "Learn routes", done: true },
{ id: 2, title: "Read query strings", done: false }
];
let nextId = 3;
這只是暫時狀態。Server 重啟時資料仍會消失;下一課 database 才會解決持久化。
request.url 不只可能是一個 path
如果 client 請求:
GET /api/tasks?done=false
request.url 會包含 path 與 query string。
所以不要再直接拿整段字串和 "/api/tasks" 比較。先解析:
const url = new URL(
request.url,
`http://localhost:${port}`
);
console.log(url.pathname);
console.log(url.searchParams.get("done"));
現在:
↓
pathname → /api/tasks
searchParams.get("done") → "false"
Query parameter 讀出來通常是字串,不是 boolean。"false" 本身也不是 JavaScript 的 false。
先做好 GET /api/tasks
建立一個 helper,統一回 JSON:
function sendJson(response, statusCode, data) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8"
});
response.end(JSON.stringify(data));
}
Route:
if (request.method === "GET" && url.pathname === "/api/tasks") {
sendJson(response, 200, tasks);
return;
}
測試:
curl -i http://localhost:3000/api/tasks
你應該會收到 array JSON。
Query string 用來調整「怎麼查」
現在讓這個 endpoint 支援:
GET /api/tasks?done=true
GET /api/tasks?done=false
可以寫:
if (request.method === "GET" && url.pathname === "/api/tasks") {
const done = url.searchParams.get("done");
if (done === "true") {
sendJson(response, 200, tasks.filter((task) => task.done));
return;
}
if (done === "false") {
sendJson(response, 200, tasks.filter((task) => !task.done));
return;
}
sendJson(response, 200, tasks);
return;
}
這裡 query 不會改變資源本身,只是決定這次回哪些資料。
Query → 你想看哪一部分 tasks
如果 query 值亂填,要不要接受?
例如:
GET /api/tasks?done=banana
不要默默當成「全部」。Client 明明傳了格式不符合契約的值,backend 應該清楚回應。
if (done !== null && done !== "true" && done !== "false") {
sendJson(response, 400, {
error: "done must be true or false"
});
return;
}
400 Bad Request 表示:server 收到 request 了,但 client 提供的輸入不符合要求。
Path 也可以帶 resource id
常見 API 會有:
GET /api/tasks/2
這裡的 2 是 path parameter。先不用 framework,我們手動拆:
const match = url.pathname.match(/^\/api\/tasks\/(\d+)$/);
if (request.method === "GET" && match) {
const id = Number(match[1]);
const task = tasks.find((task) => task.id === id);
if (!task) {
sendJson(response, 404, { error: "Task not found" });
return;
}
sendJson(response, 200, task);
return;
}
目前不用把正規表示式研究很深。先看懂資料流:
接下來進 POST:資料不再只放在 URL
建立 task 時,title 不適合塞成:
POST /api/tasks?title=...
我們會把要建立的資源內容放進 request body:
{
"title": "Learn request body"
}
但是 Node.js 的 request body 不是「自動已經變成 object」。它是 request stream 的一部分,要先收完內容再解析。
先寫一個讀 JSON body 的 helper
function readJsonBody(request) {
return new Promise((resolve, reject) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
reject(new Error("Request body too large"));
request.destroy();
}
});
request.on("end", () => {
try {
resolve(body === "" ? {} : JSON.parse(body));
} catch {
reject(new Error("Invalid JSON"));
}
});
request.on("error", reject);
});
}
這裡第一次真正看到 request body 的流式特性:
end → body 收完
JSON.parse → 字串變 JavaScript value
1 MB 限制只是這堂課的簡單保護,重點是不要無限制地一直累積 client 傳進來的內容。
讓 server handler 可以 await body
createServer 裡的 handler 可以寫成 async function:
const server = createServer(async (request, response) => {
const url = new URL(
request.url,
`http://localhost:${port}`
);
// routes...
});
現在 POST route 就能:
if (request.method === "POST" && url.pathname === "/api/tasks") {
try {
const body = await readJsonBody(request);
console.log(body);
} catch (error) {
sendJson(response, 400, { error: error.message });
}
return;
}
先驗證,再建立資料
Client 傳來的東西不能直接信任。
我們要求 title 必須是非空字串:
if (
typeof body.title !== "string" ||
body.title.trim() === ""
) {
sendJson(response, 400, {
error: "title is required"
});
return;
}
然後才建立:
const task = {
id: nextId,
title: body.title.trim(),
done: false
};
nextId += 1;
tasks.push(task);
sendJson(response, 201, task);
201 Created 比單純回 200 更精確:這次 request 成功,而且建立了一個新資源。
完整 POST /api/tasks
if (request.method === "POST" && url.pathname === "/api/tasks") {
let body;
try {
body = await readJsonBody(request);
} catch (error) {
sendJson(response, 400, { error: error.message });
return;
}
if (
typeof body.title !== "string" ||
body.title.trim() === ""
) {
sendJson(response, 400, {
error: "title is required"
});
return;
}
const task = {
id: nextId,
title: body.title.trim(),
done: false
};
nextId += 1;
tasks.push(task);
sendJson(response, 201, task);
return;
}
用 curl 送真正的 JSON body
macOS / Linux / PowerShell 通常可以:
curl -i \
-X POST \
-H "Content-Type: application/json" \
-d '{"title":"Learn POST requests"}' \
http://localhost:3000/api/tasks
如果你的 shell 對引號處理不同,可以改用它適合的 quoting 方式。重點是 request 必須包含:
Content-Type: application/json
JSON body
/api/tasks path
成功後應該拿到 201 與新 task。
再 GET 一次,確認記憶體真的改了
curl http://localhost:3000/api/tasks
剛剛建立的 task 應該已經出現在 array 裡。
但如果你停止並重新啟動 node server.js,它又會消失。這正是下一課 database 要解決的問題。
Content-Type 也屬於 request 契約
既然這個 POST endpoint 期待 JSON,可以先檢查:
const contentType = request.headers["content-type"] ?? "";
if (!contentType.includes("application/json")) {
sendJson(response, 415, {
error: "Content-Type must be application/json"
});
return;
}
415 Unsupported Media Type 表示 server 不接受這種 request body 格式。
這不是每個 beginner API 都非做不可,但能幫你建立一個重要觀念:method、path、headers、body 都可以是 API 契約的一部分。
同一個 path,不同 method,可以是不同操作
GET /api/tasks → 取得 tasks
POST /api/tasks → 建立 task
這也是為什麼 route 不能只看 path。
後面你還會看到 PATCH / DELETE;先不用一次全部塞進這堂課。
404 和 405 不完全一樣
如果 client 打:
GET /api/does-not-exist
這是找不到 route,適合 404。
但如果 client 對已知資源打:
DELETE /api/tasks
你也可以選擇回:
405 Method Not Allowed
意思是:「這個 path 我知道,但這個 method 不支援。」
目前可以先做簡化版:所有未匹配 route 最後都回 404;知道 405 的語意差別即可。
Route 順序很重要
你的 server 現在會有多個 if + return。只要匹配成功,就結束這次 request;最後才走 fallback。
↓
嘗試 GET /api/tasks
↓
嘗試 GET /api/tasks/:id
↓
嘗試 POST /api/tasks
↓
都沒匹配 → 404
如果忘了 return,就可能在已經送出 response 後又繼續跑其他邏輯,最後出現 headers already sent 之類的錯誤。
Validation 不是只為了「防駭客」
Validation 最直接的用途是守住資料形狀。
如果 backend 接受:
{ "title": 123 }
{ "title": "" }
{ "title": null }
後面的 frontend、database、搜尋與顯示都會開始遇到不一致資料。
安全面會在 Lesson 05 再深入;現在先把「client input 永遠要檢查」變成習慣。
小挑戰:替 tasks API 再加一個 query
需求 1:GET /api/tasks?q=learn 可以依 title 搜尋。
需求 2:搜尋不分大小寫。
需求 3:q 和 done 可以同時使用,例如 ?done=false&q=read。
需求 4:不要重新複製一份完全獨立的 filtering 邏輯;從同一份 tasks 一步一步套條件。
需求 5:用至少三個 curl request 測試不同組合。
提示:你可以先:
let result = tasks;
再依 query 條件逐步:
result = result.filter(...);
故意測錯誤輸入
不要只測 happy path。至少試:
送空的 title。
送錯誤 JSON。
送錯 Content-Type。
查不存在的 task id。
傳 ?done=banana。
看 status code 和 body 是否都能讓 client 理解發生什麼事。
最後收斂:一個 request 怎麼被 backend 吃掉?
↓
Method + URL + Headers + Body
↓
Route matching
↓
Parse input
↓
Validate input
↓
Run backend logic
↓
HTTP status + JSON response
到這裡,你的 API 已經不是固定回一段文字,而是真的可以根據 client 傳來的輸入查詢與建立資源。
但資料目前只活在 RAM。下一課會加入 database,讓 task 不會因為 server process 重新啟動就全部消失。
完成條件:你能解析 URL path 與 query、接收 JSON request body、驗證輸入、建立新資源,並使用合理的 status code 回應成功與錯誤狀態。