Web Day 3 HTTP 與 REST:API 設計的原則
執行需求:CPU 可跑。在昨天把環境建好之後,今天要進入後端最根本的觀念:HTTP 與 REST。你會學到 HTTP 方法與狀態碼怎麼選、URL 與資源怎麼命名、為什麼 REST 成為業界主流的 API 風格,最後把昨天的 hello-api 範例改寫成更符合 REST 慣例的版本。這些觀念會貫穿整個系列,每一天的 FastAPI 程式都會用到。
引言
寫後端程式的人很容易陷入一個誤區:「反正伺服器能回應就好」。如果只是自己測試的小程式,這個說法沒錯;但只要 API 要給別人(前端工程師、手機 App、合作廠商)呼叫,「能回應」就遠遠不夠。我們需要的是「讓別人一看 URL 就知道這個 API 在做什麼」、「讓別人一看狀態碼就知道請求成功或失敗」、「讓別人一看錯誤訊息就知道該怎麼修正」。這整套紀律,就是 HTTP 與 REST 要幫你解決的事。
HTTP 是網際網路上最普及的請求-回應協定,從 1991 年的 HTTP/0.9 一路到 2022 年推出的 HTTP/3,已經三十多年歷史。REST(Representational State Transfer)則是 Roy Fielding 在 2000 年博士論文中提出的一種架構風格,不是協定也不是標準,而是一組「怎麼用 HTTP 才合理」的設計原則。這兩個東西搭配起來,就形成現代 Web API 的主流寫法。
這篇文章會做四件事:第一,解釋 HTTP 請求與回應的結構;第二,介紹 HTTP 方法與狀態碼;第三,整理 REST 風格的設計原則(資源命名、統一介面、無狀態);第四,用 FastAPI 示範怎麼把昨天的範例改寫得更 RESTful。今天不寫複雜的應用,重點是把觀念弄清楚。
HTTP 通訊協定基礎
HTTP 是「超文字傳輸協定」(HyperText Transfer Protocol)的縮寫,運作模式非常簡單:呼叫端送出一段「請求」(request),伺服器回應一段「回應」(response)。整個交換過程是無狀態的(stateless)——意思是伺服器不會記得「上一個請求是誰送來的」,每個請求都是獨立的。
無狀態聽起來像缺點,其實是特性。它讓 HTTP 極度容易擴展:你可以隨時把請求送到任何一台伺服器(伺服器之間不需要共享呼叫端的狀態),伺服器之間不需要共享狀態(共享狀態改用資料庫、快取或外部儲存)。現代大型網站能撐住幾百萬使用者,就是靠這個特性。
一個 HTTP 請求由三部分組成:
- 請求行(request line):包含 HTTP 方法、路徑、HTTP 版本,例如
GET /api/items HTTP/1.1。 - 標頭(headers):零到多行的鍵值對,例如
Host、Content-Type、Authorization。 - 主體(body):選填,承載要送往伺服器的資料(JSON、表單、檔案等)。
回應也是三部分:狀態列(HTTP 版本、狀態碼、原因短語)、標頭、主體。我們用一支簡單的 Python 程式把這件事看清楚:
# demo_http.py
# 用 httpx 打 hello-api 的根端點,把請求與回應結構印出來
import httpx
# 啟動 hello-api 範例(uvicorn hello_api.main:app --reload --port 8000)
request = httpx.Request("GET", "http://127.0.0.1:8000/")
print("=== 請求 ===")
print(f"方法與路徑:{request.method} {request.url.path}")
print(f"HTTP 版本:HTTP/1.1(httpx 預設;HTTP/2 需另開選項)")
print(f"標頭:{dict(request.headers)}")
# 輸出(節錄):
# 方法與路徑:GET /
# HTTP 版本:HTTP/1.1
# 標頭:{'host': '127.0.0.1:8000', 'accept': '*/*', 'accept-encoding': 'gzip, deflate', 'user-agent': 'python-httpx/0.28.x', 'connection': 'keep-alive'}
response = httpx.get("http://127.0.0.1:8000/")
print()
print("=== 回應 ===")
print(f"狀態碼:{response.status_code} {response.reason_phrase}")
print(f"標頭:{dict(response.headers)}")
print(f"主體:{response.text}")
# 輸出(節錄):
# 狀態碼:200 OK
# 標頭:{'content-length': '...', 'content-type': 'application/json', 'date': '...', 'server': 'uvicorn'}
# 主體:{"message":"Hello, FastAPI!","service":"hello-api"}
這個小段把 HTTP 通訊的兩端都攤開來。注意 content-type: application/json 這個標頭——它告訴呼叫端「我回給你的是 JSON 格式,請用 JSON parser 解析」。這類內容協商(content negotiation)的機制,讓同一個 URL 理論上可以回應不同格式(例如 HTML、JSON、XML),只要呼叫端在請求標頭宣告自己要的格式即可。實務上現代 API 幾乎都是 JSON 格式,所以這個協商機制很少被用到,但理解它的存在有助於讀懂規格書。
另一個值得注意的細節:User-Agent 標頭會帶上函式庫與版本(這裡是 python-httpx/0.28.x)。伺服器可以根據這個標頭判斷呼叫端類型、限制某些舊版呼叫端的存取、或蒐集統計資料。我們在 Day 32 會看到如何在 CI/CD 裡用這個標頭做環境識別。
HTTP 方法:CRUD 與語意
HTTP 定義了九種方法(method),但實務上九成以上的 API 只會用到五種:
| 方法 | 語意 | 是否安全(safe) | 是否等冪(idempotent) |
|---|---|---|---|
| GET | 讀取資源 | 是 | 是 |
| POST | 建立資源或觸發動作 | 否 | 否 |
| PUT | 替換整個資源 | 否 | 是 |
| PATCH | 部分更新資源 | 否 | 否(一般來說) |
| DELETE | 刪除資源 | 否 | 是 |
「安全」指的是呼叫後伺服器狀態不變(純讀取);「等冪」指的是重複呼叫同樣的請求,結果一樣。例如刪除同一筆資源兩次:第一次回 204 No Content、第二次回 404 Not Found,從「資源是否存在」的角度看結果是一致的(資源不存在),所以它是等冪。建立一筆資源兩次會得到兩筆,這就是不等冪,所以 POST 不等冪。
理解「等冪」對除錯非常重要。當使用者說「我按了兩次按鈕,結果被建立兩筆」時,問題通常在於前端在 POST 之前沒有 disable 按鈕、或是後端沒有對 POST 做去重(idempotency key)。Day 10 處理錯誤時會示範這個議題。
HTTP 狀態碼:成功與失敗的標準語言
狀態碼(status code)是伺服器告訴呼叫端「結果如何」的標準化方式。第一位數代表大類:
| 類別 | 意義 | 常見狀態碼 |
|---|---|---|
| 1xx | 資訊 | 100 Continue(少用) |
| 2xx | 成功 | 200 OK、201 Created、204 No Content |
| 3xx | 重新導向 | 301 Moved Permanently、304 Not Modified、307 Temporary Redirect |
| 4xx | 呼叫端錯誤 | 400 Bad Request、401 Unauthorized、403 Forbidden、404 Not Found、422 Unprocessable Entity |
| 5xx | 伺服器錯誤 | 500 Internal Server Error、502 Bad Gateway、503 Service Unavailable |
實務上有幾個最容易用錯的狀態碼:
- 200 vs 201 vs 204:GET 成功用 200;POST 建立資源成功用 201,且常在標頭
Location給出新資源的 URL;PUT/PATCH/DELETE 成功且無主體回傳時用 204。 - 400 vs 422:400 是「請求格式錯了(例如 JSON 解析失敗)」;422(FastAPI/Pydantic 預設)是「請求格式對了,但內容驗證失敗(例如 email 格式不對)」。
- 401 vs 403:401 是「你沒帶 token 或 token 過期」;403 是「你有身份,但這個資源不允許你存取」。兩者常被混用,理解差異對做認證很重要(Day 12 會示範)。
把昨天的 hello-api 擴充一下,加上錯誤路徑示範狀態碼的差異:
# src/hello_api/main.py
# 加入錯誤示範端點
from fastapi import FastAPI, HTTPException
app = FastAPI(title="hello-api", version="0.2.0")
ITEMS = {
1: {"id": 1, "name": "Notebook", "price": 120},
2: {"id": 2, "name": "Pen", "price": 25},
}
@app.get("/items/{item_id}")
def read_item(item_id: int):
item = ITEMS.get(item_id)
if item is None:
# 找不到資源:404
raise HTTPException(status_code=404, detail="找不到這個資源")
return item
@app.get("/items-bad")
def read_items_bad():
# 伺服器內部錯誤:500
raise RuntimeError("故意的,示範 500")
# FastAPI 會把 RuntimeError 轉成 500 Internal Server Error
這段示範兩個常見的狀態碼:用 HTTPException(status_code=404) 主動告訴呼叫端「這個資源不存在」;故意觸發的 RuntimeError 則會被 FastAPI 攔截並回應 500。實務上 500 通常代表程式有 bug,應該在記錄伺服器端日誌(Day 22)後儘快修復;不應該在正常邏輯裡故意丟 500。
REST 風格的設計原則
REST 不是標準,而是一組設計原則。Roy Fielding 在 2000 年提出時列出六個約束,但實務上後端工程師關心的通常只有三個:
- 資源導向:URL 代表「資源」(resource),不是「動作」。所以 URL 用名詞(
/items、/users),不用動詞(/getItems、/createUser)。 - 統一介面:用 HTTP 方法表達動作(GET 讀取、POST 建立、PUT/PATCH 更新、DELETE 刪除)。同一個 URL 接受不同方法就有不同行為。
- 無狀態:每個請求都帶足夠資訊讓伺服器處理,不依賴伺服器端的 session。需要 session 的情境改用 token(Day 12)。
把昨天的 hello-api 改寫成 RESTful 版本:
# src/hello_api/main.py
# REST 風格的 hello-api 範例
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="hello-api", version="0.3.0")
class Item(BaseModel):
# 回應用的資料模型
id: int
name: str = Field(min_length=1, max_length=80)
price: int = Field(ge=0) # price >= 0
class ItemCreate(BaseModel):
# 建立用的請求模型(沒有 id,由伺服器產生)
name: str = Field(min_length=1, max_length=80)
price: int = Field(ge=0)
# 假資料(Day 6 會換成 SQLite)
_items: dict[int, Item] = {
1: Item(id=1, name="Notebook", price=120),
2: Item(id=2, name="Pen", price=25),
}
_next_id = 3
@app.get("/items")
def list_items():
# 讀取資源集合
return list(_items.values())
@app.get("/items/{item_id}")
def read_item(item_id: int):
# 讀取單一資源
item = _items.get(item_id)
if item is None:
raise HTTPException(status_code=404, detail="item not found")
return item
@app.post("/items", status_code=201)
def create_item(payload: ItemCreate):
# 建立新資源
global _next_id
new = Item(id=_next_id, **payload.model_dump())
_items[_next_id] = new
_next_id += 1
return new
@app.put("/items/{item_id}")
def replace_item(item_id: int, payload: ItemCreate):
# 完整替換資源
if item_id not in _items:
raise HTTPException(status_code=404, detail="item not found")
replaced = Item(id=item_id, **payload.model_dump())
_items[item_id] = replaced
return replaced
@app.patch("/items/{item_id}")
def update_item(item_id: int, payload: dict):
# 部分更新資源
item = _items.get(item_id)
if item is None:
raise HTTPException(status_code=404, detail="item not found")
data = item.model_dump()
data.update(payload)
updated = Item(**data)
_items[item_id] = updated
return updated
@app.delete("/items/{item_id}", status_code=204)
def delete_item(item_id: int):
# 刪除資源,回 204 無主體
if item_id not in _items:
raise HTTPException(status_code=404, detail="item not found")
del _items[item_id]
return None
這個版本完整展現 REST 慣例:URL /items 是資源集合、/items/{id} 是單一資源;GET 讀取、POST 建立(201)、PUT 完整替換、PATCH 部分更新、DELETE 刪除(204)。Pydantic 模型負責驗證請求內容,FastAPI 會自動把驗證失敗轉成 422 錯誤。
完整實作:用 httpx 走完整個 REST 迴圈
啟動 hello-api(記得 uv run uvicorn hello_api.main:app --reload --port 8000),然後用下面這支腳本走完整個 CRUD:
# tests/test_rest_cycle.py
# 完整走一次 REST CRUD,確認狀態
import httpx
BASE = "http://127.0.0.1:8000"
def main():
with httpx.Client(base_url=BASE, timeout=5.0) as client:
# 1. 讀取資源集合
r = client.get("/items")
print(f"GET /items -> {r.status_code}", r.json())
# 輸出(範例):GET /items -> 200 [{'id': 1, ...}, {'id': 2, ...}]
# 2. 讀取單一資源
r = client.get("/items/1")
print(f"GET /items/1 -> {r.status_code}", r.json())
# 輸出(範例):GET /items/1 -> 200 {'id': 1, 'name': 'Notebook', 'price': 120}
# 3. 建立新資源
new_item = {"name": "Mug", "price": 180}
r = client.post("/items", json=new_item)
print(f"POST /items -> {r.status_code}", r.json())
# 輸出(範例):POST /items -> 201 {'id': 3, 'name': 'Mug', 'price': 180}
created_id = r.json()["id"]
# 4. 部分更新資源
r = client.patch(f"/items/{created_id}", json={"price": 200})
print(f"PATCH /items/{created_id} -> {r.status_code}", r.json())
# 輸出(範例):PATCH /items/3 -> 200 {'id': 3, 'name': 'Mug', 'price': 200}
# 5. 完整替換資源
r = client.put(f"/items/{created_id}", json={"name": "Cup", "price": 220})
print(f"PUT /items/{created_id} -> {r.status_code}", r.json())
# 輸出(範例):PUT /items/3 -> 200 {'id': 3, 'name': 'Cup', 'price': 220}
# 6. 刪除資源
r = client.delete(f"/items/{created_id}")
print(f"DELETE /items/{created_id} -> {r.status_code}")
# 輸出(範例):DELETE /items/3 -> 204
# 7. 再次讀取(確認真的刪掉了)
r = client.get(f"/items/{created_id}")
print(f"GET /items/{created_id} -> {r.status_code}", r.json())
# 輸出(範例):GET /items/3 -> 404 {'detail': 'item not found'}
main()
這個迴圈把 POST/PATCH/PUT/DELETE 與狀態碼(201/200/204/404)一次展示。實務上 PATCH 的輸入通常會用另一個 Pydantic 模型(部分欄位都變 optional)來嚴格驗證,這裡為了簡化用 dict 收;Day 5 會深入探討請求驗證的細節。
驗證請求體格式錯誤會怎樣:
# 用 curl 試一個故意壞掉的請求
curl -s -X POST http://127.0.0.1:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "", "price": -1}' | python -m json.tool
# 輸出(節錄):
# {
# "detail": [
# {
# "type": "string_too_short",
# "loc": ["body", "name"],
# "msg": "String should have at least 1 character",
# "input": ""
# },
# {
# "type": "less_than_equal",
# "loc": ["body", "price"],
# "msg": "Input should be greater than or equal to 0",
# "input": -1
# }
# ]
# }
# HTTP 狀態碼:422
這段範例展現了 FastAPI/Pydantic 的強大:當請求不符合 ItemCreate 模型的約束時,伺服器會回 422 並列出每一個欄位的錯誤訊息(哪個欄位、為什麼錯、實際輸入是什麼)。前端可以直接拿這份結構化的錯誤回應做表單驗證,省下自己寫一堆檢查的功夫。
常見錯誤與踩雷
第一個常見的踩雷是「動詞型 URL」。例如 /getItems、/createUser、/deleteOrder/1001 這些寫法是 RPC(Remote Procedure Call)風格,不是 REST。問題在於動詞型 URL 必須對到特定方法,無法用 HTTP 方法區分動作;改用 /items + GET/POST/PUT/DELETE 組合,未來想加 cache header、批次操作、權限控管都更彈性。
第二個是「狀態碼用 200 包一切錯誤」。某些 API 會回 {"code": 404, "message": "..."} 但 HTTP 狀態碼仍然是 200。這違背了 HTTP 的設計意圖:呼叫端程式庫(如 httpx、瀏覽器 fetch)會根據 HTTP 狀態碼決定怎麼處理(重試、丟例外、顯示錯誤頁)。混用 200 + 自訂錯誤碼會讓這些自動化機制失靈。請一律用正確的 HTTP 狀態碼。
第三個是「GET 卻改變伺服器狀態」。例如「GET /user/clear-cache」會清掉使用者快取——這違背了 GET 應該是「安全」的語意。除錯或監控時偶爾會這樣設計,但千萬不要在正式 API 用 GET 來刪除或修改資料,否則瀏覽器預讀、爬蟲、CDN 快取都可能誤觸。
進階範例:用 Python 模擬 REST 客戶端
理解 REST 的最佳方式之一是「自己當客戶端」。前面用 httpx 走完整個 CRUD,這裡再展示一個更完整的「通用 API 客戶端」設計:把所有 REST 慣用語義包進一個 helper class,未來你自己的前端或 SDK 都能用同一套介面。
# rest_client.py
# 一個極簡的 REST 客戶端,封裝 GET/POST/PUT/PATCH/DELETE
from typing import Any
import httpx
class RestClient:
"""包裝基本 CRUD 操作的客戶端"""
def __init__(self, base_url: str, headers: dict[str, str] | None = None):
self._client = httpx.Client(
base_url=base_url,
timeout=10.0,
headers=headers or {},
)
def get(self, path: str, **kwargs) -> httpx.Response:
return self._client.get(path, **kwargs)
def post(self, path: str, json: dict[str, Any] | None = None) -> httpx.Response:
return self._client.post(path, json=json)
def put(self, path: str, json: dict[str, Any] | None = None) -> httpx.Response:
return self._client.put(path, json=json)
def patch(self, path: str, json: dict[str, Any] | None = None) -> httpx.Response:
return self._client.patch(path, json=json)
def delete(self, path: str) -> httpx.Response:
return self._client.delete(path)
def close(self):
self._client.close()
# 用法
client = RestClient("http://127.0.0.1:8000")
r = client.get("/items/1")
print(f"GET /items/1 -> {r.status_code}", r.json())
client.close()
這支 RestClient 把所有 HTTP 方法封裝好,未來你在寫前端或 SDK 時可以省下一些樣板程式碼。它的設計哲學是「一個動詞對應一個方法」,跟 REST 的精神一致。如果你還想再延伸,可以加上 authenticate(token) 自動加 Bearer 標頭,或加上錯誤處理(自動 raise 對應的例外)。本系列後續章節會逐步擴充這套介面。
另一個實用的工具是「HTTP 動詞與狀態碼速查表」。把它做成模組層級的常數,未來在 FastAPI 端點裡就可以直接引用,而不是寫魔術數字。
# http_constants.py
# HTTP 動詞與常用狀態碼的常數定義
from enum import IntEnum
class HttpMethod:
GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"
HEAD = "HEAD"
OPTIONS = "OPTIONS"
class StatusCode(IntEnum):
OK = 200
CREATED = 201
NO_CONTENT = 204
BAD_REQUEST = 400
UNAUTHORIZED = 401
FORBIDDEN = 403
NOT_FOUND = 404
CONFLICT = 409
UNPROCESSABLE_ENTITY = 422
INTERNAL_SERVER_ERROR = 500
BAD_GATEWAY = 502
SERVICE_UNAVAILABLE = 503
# 用法示範
def describe(code: int) -> str:
descriptions = {
StatusCode.OK: "成功",
StatusCode.CREATED: "資源已建立",
StatusCode.NO_CONTENT: "成功但無回應主體",
StatusCode.BAD_REQUEST: "請求格式錯誤",
StatusCode.NOT_FOUND: "找不到資源",
StatusCode.UNPROCESSABLE_ENTITY: "請求格式對了,但內容驗證失敗",
StatusCode.INTERNAL_SERVER_ERROR: "伺服器內部錯誤",
}
return descriptions.get(code, "未知狀態碼")
print(describe(StatusCode.NOT_FOUND))
# 輸出:找不到資源
這個 http_constants.py 是後端專案的常見小工具——把所有會用到的 HTTP 概念集中在一個地方,避免散落在各處。實務上你會把這個檔案放到 src/<project>/constants.py 裡,讓所有端點與客戶端都能引用。IntEnum 的好處是「值等於意義」,但 IDE 與型別檢查器都能給你自動完成與跳轉。
到這裡我們已經把 HTTP 與 REST 的核心觀念走完——協定、請求回應、方法、等冪、狀態碼、設計原則,以及一個能實際跑的 hello-api RESTful 範例。明天我們會進入 FastAPI 的細節:路由、Pydantic v2 資料模型、以及那兩份會自動長出來的說明頁面。你會看到 FastAPI 怎麼把今天這些觀念變成「型別提示就是規格、規格就是測試」的設計。
補充:HTTP 設計的反例與啟示
理解 REST 最好的方式之一是看反例。SOAP 是 2000 年代初期主流的 API 風格,它把方法、參數、回應都包在一段 XML 裡,每個動作都有自己的動詞型動詞 GetUserById、CreateOrder,就像 RPC 一樣。SOAP 確實能解決當時企業團隊整合的問題,卻缺了不少方法:因為每個動詞都是自訂的,方法行為與 HTTP 動詞綁定的,規格書必須另外寫;對早中期 HTTP 快取頻寬驗證不友善。REST 的反應語意帶來幾組優勢,這次把幾組動作一個一個拿出來看。SOAP 雖然已經主流化了,但統一標準的演化仍然在 REST 圖譜裡繼續進行。
效能與實務提醒
REST 的無狀態特性對效能有兩個重要含意:第一,伺服器不需要保留 session,可以用任何一台機器處理請求,這對水平擴展(horizontal scaling)非常友善;第二,每個請求都帶著所有資訊(例如 token、查詢條件),所以請求體可能比有狀態設計稍大。實務上 token 通常只有幾百位元組,不是問題;查詢條件則要注意不要把整個篩選邏輯塞進 URL,可以用 POST + body(但這樣又違反 REST 的冪等性)或改用 GraphQL。
快取(cache)是另一個跟 HTTP 高度整合的議題。GET 請求應該可以被快取(瀏覽器、CDN、伺服器端),適當加上 Cache-Control、ETag、Last-Modified 標頭可以省下大量後端運算。Day 20 會介紹 Redis 快取,今天先把概念建立好。
API 版本管理也是 REST 設計的重要環節。常見做法是在 URL 加版本前綴(/v1/items、/v2/items),或是用 Accept 標頭做內容協商(Accept: application/vnd.myapi.v2+json)。前者比較直覺,後者更乾淨但較難除錯。我們這個系列會用前者,後續貫穿專案裡可以看到具體寫法。
小結
今天我們把 HTTP 與 REST 的核心觀念整理了一遍:HTTP 是無狀態的請求-回應協定,用方法與狀態碼表達動作與結果;REST 是一組設計原則,主張資源導向、統一介面、無狀態。我們把昨天的 hello-api 改寫成完整的 REST 風格 CRUD,端點語意清楚、狀態碼正確、用 Pydantic 自動驗證請求體,並用 httpx 走過一輪完整的建立、讀取、更新、刪除測試。這些觀念會在後續每天的 FastAPI 程式反覆出現,是整個系列最重要的基礎之一。
結語
今天我們停在「正確的 REST 慣例」這個關卡,看清楚了資源命名、方法、狀態碼之間的對應關係。明天,我們會進入 FastAPI 的核心機制:路由、Pydantic v2 的資料模型、以及 FastAPI 自動產生的 API 說明頁面(Swagger UI)。你會學到怎麼用裝飾器組合複雜的 URL 模式、用 Pydantic 設計嚴謹的請求與回應模型,以及怎麼讓 FastAPI 幫你寫說明頁面。
延伸資源
- MDN Web Docs:HTTP 介紹(2025):
https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Overview - RFC 9110:HTTP 語意(2022):
https://www.rfc-editor.org/rfc/rfc9110 - Roy Fielding 博士論文(2000):
https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm - FastAPI 教學:Bigger Applications(2025):
https://fastapi.tiangolo.com/tutorial/bigger-applications/ - HTTP 狀態碼速查(HTTP Cats,2025):
https://http.cat/ - Pydantic v2 官方教學(2025):
https://docs.pydantic.dev/latest/
留言
張貼留言