這一課完成後:你的 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 通常會一起看:

Method → 想做什麼
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"));

現在:

/api/tasks?done=false
↓
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 不會改變資源本身,只是決定這次回哪些資料。

Path → 你在操作 tasks
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;
}

目前不用把正規表示式研究很深。先看懂資料流:

/api/tasks/2 → 抽出 "2" → Number(2) → find task → 回資料或 404

接下來進 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 的流式特性:

data → 一段一段收到
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 必須包含:

POST method
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。

Method + Path → 決定 operation

後面你還會看到 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。

解析 URL
↓
嘗試 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、搜尋與顯示都會開始遇到不一致資料。

Request input → Validate → Accept / Reject → Backend logic

安全面會在 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 吃掉?

HTTP Request
↓
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 回應成功與錯誤狀態。