FE Day 36 串接 FastAPI:資料層設計
執行需求:CPU 可跑(無後端時走 mock 模式;接 FastAPI 時需 Web 系列 Day 35–44 的 API 在跑)。今天進入貫穿專案「預約管理系統」串接篇。Day 31–35 我們把所有 UI 元件都寫好了,但資料層一直指向 Day 31 的 mock fixture。今天我們把資料層從「讀 mock 檔」換成「打 FastAPI REST API」,整個切換的關鍵是「元件不知道底下是 mock 還是 live」。具體要做四件事:第一,把後端契約整理成一份精簡的 OpenAPI 對照表;第二,寫一個統一的 apiClient(fetch + token + 錯誤格式);第三,寫一個 snake_case → camelCase 的轉換層;第四,把 fetchServices、fetchAllBookings、confirmBooking 等函式改成「呼叫 API 或回傳 mock」。今天所有資料都是虛構示範,後端沒啟動時 NEXT_PUBLIC_API_MODE=mock 會自動走內建 fixture,整個流程在 CPU 上可重現。
引言
前端串接後端是 side project 最容易被低估的工作。看起來「把 fetch 路徑換掉就好」,但實際接上後才發現:後端回 snake_case 而前端要 camelCase、後端時間是 UTC 字串而前端要本地時區、後端 401 要自動 refresh、後端錯誤訊息有 detail 欄位而我們預期 message。每一條都會決定系統能不能上線。今天刻意把資料層抽象化到「元件完全感知不到 mock 或 live」,讓兩種模式切換時只需要改一個環境變數,不會出現「測試 OK 但上 production 後壞掉」的窘境。這也是 Day 14「資料取得與前端快取」觀念的延伸,只是把「取資料」這件事從一個 fetch 升級成一整套系統設計。
貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、語言家教、諮詢工作室,所有資料皆為虛構示範)。Web 系列的 FastAPI 後端在 Day 35–44 完成了完整的 REST API:/auth/login、/auth/refresh、/services、/services/{id}/slots、/bookings、/bookings/{id}/cancel、/admin/... 等等。我們今天的任務是把這份契約接上 Next.js 15 前端:寫一個統一的 client、把 mock 與 live 共用同一個介面、處理 FastAPI 的錯誤格式。本篇所有端點名稱與欄位都直接對應 Web 系列,沒有自行延伸任何端點;如果未來需要新端點,會先回 Web 系列補上、再回到本系列使用,這條守則可以避免前後端契約漂移,也讓兩份規格文件保持同步可讀。
今天的內容分五段:第一段把 Day 31–35 的共用設定再次統整;第二段說明 API 契約與 snake_case → camelCase 的轉換;第三段實作統一的 apiClient;第四段把 lib/api/*.ts 所有函式改寫成「mock + live」雙模式;第五段在 Day 32 的元件中實際呼叫新版資料層。讀完之後你會拿到一個完整的 API 對照表、一個 production-ready 的 apiClient、一份 mock fixture 的維護指南,以及 6 個具體的 fetch* 函式實作。今天的範例在沒有 FastAPI 後端時會自動走 mock 模式(Day 32 已經做好的機制),讓沒有啟動後端的讀者也能直接跑完整流程;接上 FastAPI 時只要改環境變數,元件程式碼完全不動。
貫穿專案共用設定(Day 31–44 沿用)
今天所有串接都建立在 Day 31–35 已建好的 stack 上,繼續沿用:
- 框架:Next.js 15(App Router、Server Components 預設)、React 19.x、TypeScript 5.9
- 樣式:Tailwind CSS 4.x、CSS variables 主題;沿用 Day 32 的
components/ui/ - 狀態管理:TanStack Query 5.x(伺服器狀態)
- 後端:FastAPI 預約管理系統(Web 系列 Day 35–44 定義的 REST API)
- Mock:
NEXT_PUBLIC_API_MODE=mock時走lib/mock/*.tsfixture;正式模式live - 認證:Day 38 的 JWT access token 與 refresh token;今天的 API client 已經預留 header 注入點
這份設定從 Day 31 到 Day 44 不變。今天的資料層只在既有設定上「加東西」(API client、轉換層、mock fixture),不改 UI 元件、不改路由。
原理解念:API 契約、轉換層與雙模式切換
前端串接後端的核心是「契約對齊」。我們先整理一份對照表,把 Web 系列 Day 35–44 定義的端點與 Day 31 的型別定義對應起來:
# 預約系統 API 對照表(前端讀、後端讀,必須同步)
# 認證(Day 36 Web 系列)
POST /auth/register → 註冊新帳號(customer)
POST /auth/login → 登入,回 access_token
POST /auth/refresh → 用 refresh token 換新 access_token
# 服務(Day 36 Web 系列)
GET /services → 列出啟用中的服務(公開)
POST /services → 新增服務(admin)
PATCH /services/{id} → 編輯服務(admin)
DELETE /services/{id} → 停用服務(admin)
GET /services/{id}/slots → 列出可預約時段
# 預約(Day 37 Web 系列)
GET /bookings/me → 客戶自己的預約
GET /bookings → 所有預約(admin)
POST /bookings → 建立預約
PATCH /bookings/{id}/cancel → 取消預約
# 通知(Day 38 Web 系列)
GET /notifications → 我的通知清單
這張表刻意放在 docs/api-contract.md(不是程式碼),兩邊團隊讀同一份來源、任何端點變更都會同時反映到前端與後端,避免「前端寫了一個不存在的端點」或「後端改了契約前端沒跟上」。實務上我們會把這份 Markdown 檔放在 repo 根目錄、隨 PR 一起 review,確保契約改動是雙方都有共識的共識決策,而不是「前端先寫、後端被迫配合」或反過來。
第二個關鍵設計是「snake_case → camelCase 轉換層」。FastAPI 的 Pydantic 預設用 snake_case(Python 慣例),前端 TypeScript 慣例用 camelCase。如果兩邊直接對接,元件會出現 booking.customer_name 與 booking.customerName 兩種寫法混雜,維護地獄。我們把轉換集中在 lib/transform.ts,所有 fetch* 函式都回傳 camelCase,元件永遠只寫一種:
// lib/transform.ts
// 把後端的 snake_case 鍵名轉成前端的 camelCase(深拷貝,避免汙染快取)。
type AnyObj = Record<string, unknown>;
function snakeToCamel(s: string): string {
return s.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
}
export function toCamel(value) {
if (Array.isArray(value)) return value.map((v) => toCamel(v));
if (value && typeof value === "object" && value.constructor === Object) {
const out = {};
for (const [k, v] of Object.entries(value)) {
out[snakeToCamel(k)] = toCamel(v);
}
return out;
}
return value;
}
export function toSnake(value) {
if (Array.isArray(value)) return value.map((v) => toSnake(v));
if (value && typeof value === "object" && value.constructor === Object) {
const out = {};
for (const [k, v] of Object.entries(value)) {
const snake = k.replace(/([A-Z])/g, (_, c) => "_" + c.toLowerCase());
out[snake] = toSnake(v);
}
return out;
}
return value;
}
這個 toCamel 用遞迴處理巢狀物件與陣列,深層的 booking.service.price_cents 也能正確轉成 booking.service.priceCents。toSnake 反向用在 POST / PATCH 的 request body,把前端的 camelCase 轉回 snake_case 送給後端。這條轉換邏輯只寫一次、未來加任何欄位都自動支援。另一個延伸設計是「保留特定欄位名不轉換」,例如 _id(MongoDB)或 created_at_iso(特殊命名)可以加白名單;對 side project 規模來說暫時用不到,但寫在註解裡提醒未來的自己即可。
完整實作:統一的 apiClient
先寫 apiClient:包裝 fetch、注入 token、處理錯誤、轉換格式。整個專案只允許從這裡發 HTTP 請求:
// lib/api/client.ts
import { toCamel, toSnake } from "@/lib/transform";
// 從環境變數讀 base URL;正式環境用 Vercel 部署的網域,本機用 localhost:8000
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
export class ApiError extends Error {
constructor(status, detail, payload) {
super(detail);
this.name = "ApiError";
this.status = status;
this.payload = payload;
}
}
export async function apiFetch(path, opts = {}) {
const { method = "GET", body, query, headers = {}, camelize = true } = opts;
const qs = query
? "?" +
Object.entries(query)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(String(v)))
.join("&")
: "";
const url = BASE_URL + path + qs;
const init = {
method,
credentials: "include", // 把 cookie(refresh token)一起帶
headers: {
"Content-Type": "application/json",
...headers,
},
};
if (body !== undefined) {
init.body = JSON.stringify(toSnake(body));
}
const res = await fetch(url, init);
const text = await res.text();
let data = undefined;
if (text) {
try {
data = JSON.parse(text);
} catch {
throw new ApiError(res.status, text);
}
}
if (!res.ok) {
const detail = data && typeof data.detail === "string" ? data.detail : "HTTP " + res.status;
throw new ApiError(res.status, detail, data);
}
return camelize ? toCamel(data) : data;
}
這個 client 有五個關鍵設計。第一,credentials: "include" 確保 cookie(refresh token)跟著每個請求帶到後端,這是 httpOnly cookie 機制的前提。第二,所有寫入請求的 body 都先 toSnake,讓元件可以用 camelCase 寫程式、自動轉成 snake_case 送出。第三,query 物件自動組 query string,並過濾 undefined,避免 ?status=undefined 出現。第四,ApiError 自訂例外帶 status 與 payload,呼叫端可以用 err instanceof ApiError 判斷;Day 37 會用這個機制做精準錯誤處理。第五,camelize 預設 true,mutation 用 false(避免把剛送出的 body 轉回來)。
另一個細節是 BASE_URL 的環境變數讀取。我們刻意把 NEXT_PUBLIC_API_BASE_URL 寫成「必須設、不設就 fallback 到 localhost」。在 Vercel 部署時把它設成 https://api.hao-code.com(Day 41 部署篇章會展開),preview 環境設成 staging;不同環境用不同 base URL 是分離測試流量的關鍵,避免「preview 環境連到 production 後端」的慘劇。
雙模式:fetch* 函式同時支援 mock 與 live
資料層函式統一遵循這個 pattern:先看 API_MODE、mock 模式回 fixture、live 模式打 API:
// lib/api/services.ts
import { apiFetch } from "@/lib/api/client";
import { API_MODE } from "@/lib/api-mode";
import { mockServices } from "@/lib/mock/data";
import { withDelay } from "@/lib/mock/with-delay";
export async function fetchServices() {
if (API_MODE === "mock") {
return withDelay(mockServices.filter((s) => s.isActive));
}
return apiFetch("/services");
}
// 後台用:列出所有服務(含停用的)
export async function fetchServicesAdmin() {
if (API_MODE === "mock") {
return withDelay(mockServices);
}
return apiFetch("/services?includeInactive=true");
}
export async function fetchServiceById(id) {
if (API_MODE === "mock") {
const found = mockServices.find((s) => s.id === id);
if (!found) throw new Error("Service " + id + " not found");
return withDelay(found);
}
return apiFetch("/services/" + id);
}
// lib/api/bookings.ts
import { apiFetch } from "@/lib/api/client";
import { API_MODE } from "@/lib/api-mode";
import { mockBookings } from "@/lib/mock/data";
import { withDelay } from "@/lib/mock/with-delay";
// 客戶端:我的預約
export async function fetchMyBookings() {
if (API_MODE === "mock") {
return withDelay(mockBookings);
}
return apiFetch("/bookings/me");
}
// 後台:所有預約(admin scope)
export async function fetchAllBookings() {
if (API_MODE === "mock") {
return withDelay(mockBookings);
}
return apiFetch("/bookings");
}
export async function confirmBooking(id) {
if (API_MODE === "mock") {
const found = mockBookings.find((b) => b.id === id);
if (!found) throw new Error("Booking " + id + " not found");
found.status = "confirmed";
return withDelay(found);
}
return apiFetch("/bookings/" + id + "/confirm", {
method: "POST",
body: { action: "confirm" },
camelize: false,
});
}
export async function cancelBooking(id) {
if (API_MODE === "mock") {
const found = mockBookings.find((b) => b.id === id);
if (!found) throw new Error("Booking " + id + " not found");
found.status = "cancelled";
return withDelay(found);
}
return apiFetch("/bookings/" + id + "/cancel", {
method: "POST",
body: { action: "cancel" },
camelize: false,
});
}
幾個設計要點。第一,每個函式都是「同一個介面、兩種實作」,元件永遠呼叫 fetchMyBookings(),不需要知道底下是 mock 還是 live。第二,mock 模式用 withDelay 模擬網路延遲(Day 31 定義),確保 UI 的 loading state 跟真實 API 一樣會出現。第三,confirmBooking 與 cancelBooking 用 apiFetch 帶 method: "POST",body 用 snake_case 送出;camelize: false 因為我們通常不會拿 mutation 的 response 重渲染(用 TanStack Query 的 invalidate 重新拉清單)。第四,mock 模式直接 mutate mockBookings,讓多次操作的狀態保持一致;正式模式則完全交給後端。
認證:apiFetch 與 token 的整合點
Day 38 才會完整實作 AuthProvider,但今天的 apiFetch 必須預留 token 注入點。我們用一個「全域 getter」讓 AuthProvider 在掛載時註冊,apiFetch 呼叫時讀取。這個設計雖然是 mutable 全域變數,但它的生命週期只在 client 端("use client" 模組),且只被 apiFetch 與 AuthProvider 兩個模組存取,屬於「小範圍的全域狀態」,可以接受:
// lib/api/token-store.ts
// 一個極簡的 token store,讓 apiFetch 在不打 React Context 的情況下讀到 access token。
let currentAccessToken = null;
export function setAccessToken(token) {
currentAccessToken = token;
}
export function getAccessToken() {
return currentAccessToken;
}
接著在 apiFetch 內讀這個 store:
// 在 apiFetch 開頭加:
import { getAccessToken } from "@/lib/api/token-store";
export async function apiFetch(path, opts = {}) {
const accessToken = getAccessToken();
const authHeader = accessToken ? { Authorization: "Bearer " + accessToken } : {};
const { method = "GET", body, query, headers = {}, camelize = true } = opts;
const init = {
method,
credentials: "include",
headers: {
"Content-Type": "application/json",
...authHeader,
...headers,
},
};
// ... 其餘邏輯同上一段
}
AuthProvider 在登入成功或 refresh 成功後呼叫 setAccessToken,apiFetch 自動帶 Authorization header;登出時 setAccessToken(null),header 自動移除。這條設計把「認證」與「資料層」解耦:apiFetch 不需要 import AuthProvider、AuthProvider 也不需要 import apiFetch,兩者透過一個共享的 mutable store 串起來。這種「兩個模組之間的間接溝通」在大型應用會升級成 DI 容器或 event bus,但在 side project 規模下,一個 store 就夠了。Day 38 還會加 refresh 攔截器(401 時自動換新 token 再重試),屆時 apiFetch 只要呼叫 onUnauthorized callback 即可,這條 callback 也是 store 的一部份,避免 apiFetch 直接依賴 AuthProvider。
驗證:把 Day 32 的 Mock 改成可切換
所有 mock fixture 集中在 lib/mock/,維護成本低。我們刻意把 mock fixture 寫成「可變的」:mockBookings 是一個 export 的陣列,confirm / cancel 操作會直接 mutate 它,這樣多次操作後狀態保持一致;同時,元件的 TanStack Query cache 在 invalidate 之後會重新讀 mockBookings,看到最新的狀態。這種「mock 看起來就像真實後端」的設計,是無後端開發的關鍵:
// lib/mock/with-delay.ts
// 讓 mock 函式模擬網路延遲,方便在 UI 上驗證載入狀態。
export async function withDelay(value, ms = 150) {
await new Promise((resolve) => setTimeout(resolve, ms));
return value;
}
另一個值得注意的小細節是「mock 模式的型別要與 live 模式的型別一致」。如果 mock fixture 用 { customer_name: "王小明" } 而元件用 customerName,切換模式時就會出錯。我們刻意把 lib/mock/types.ts 全部用 camelCase,模擬「已經過 toCamel 轉換」的結果,這樣 mock 與 live 對元件來說完全等價。如果未來 mock 與 live 出現「同個欄位型別不同」的情況(例如一邊是字串、一邊是數字),元件就會出現「在 mock 能跑、切到 live 就壞」的靈異現象,這個時候就要回頭檢查 toCamel 的型別推斷是否完整。
驗證流程:pnpm dev 預設走 mock,首頁、服務清單、預約管理都能看到資料;改 .env.local 設 NEXT_PUBLIC_API_MODE=live 與 NEXT_PUBLIC_API_BASE_URL=http://localhost:8000,重啟 dev server 後所有資料改打 FastAPI(前提是 FastAPI 已經啟動)。兩種模式切換不應該改任何元件程式碼,只改環境變數。如果切換後某個畫面壞掉,問題 99% 出在「後端回的欄位跟我們以為的不一樣」,這時候打開 Network 面板看 response 就會找到。建議在 PR review 時也加上「兩種模式都點過」的檢查,避免「PR 在 mock 環境看起來正常、切到 staging 才壞掉」。
常見錯誤與踩雷
第一個踩雷是「忘記設定 credentials: "include"」。如果 fetch 沒帶這個選項,瀏覽器不會把 cookie 送出去,後端讀不到 refresh token,認證就會失敗。修法:所有打自家後端的 fetch 都加 credentials: "include"。我們在 apiClient 直接寫死,未來不會再忘。對應的症狀是「每次打 API 都被後端視為未登入、不斷回 401」,這時候先檢查 fetch 的 credentials 設定。
第二個踩雷是「snake_case 與 camelCase 混用」。如果元件裡同時出現 booking.customer_name 與 booking.customerName,TypeScript 不會報錯(dynamic property access 編譯通過),但實際跑起來 undefined 滿天飛。修法:所有欄位一律 camelCase、轉換層集中在 lib/transform.ts、code review 時把 _ 開頭的欄位視為可疑訊號。我們也建議在 tsconfig.json 開 noUncheckedIndexedAccess 進一步把這類 bug 提前到編譯期;這條設定會把所有 array / object 的 indexed access 變成 T | undefined,迫使開發者處理「欄位不存在」的情況。
第三個踩雷是「fetch 失敗時直接 console.error」。沒有結構化錯誤處理的元件會在生產環境看到一片 console 紅字,使用者也不知道發生什麼事。修法:用 apiFetch 的 ApiError 例外統一拋出,呼叫端用 try / catch 處理;Day 37 會把錯誤轉成 UI 上的 toast 與 retry 按鈕。常見的錯誤處理地雷還包括「把所有錯誤都當 500 處理」、「把 4xx 顯示成紅色 banner」等,這些都會在 Day 37 系統化拆解。
第四個踩雷是「mock 與 live 兩個分支在元件裡」。如果某個元件寫成「if (mock) 回 A else 回 B」,元件就會被綁死。修法:資料層函式統一介面、API_MODE 判斷放在 lib/api/*.ts 裡面、元件永遠只看到一種 API。我們今天的寫法就是這個原則的具體實踐。另一個等效的寫法是用 React Query 的 queryFn 注入 fake fetch,但對小型專案來說「資料層判斷模式」更直覺、也比較容易在 PR review 時一眼看出意圖。
效能與實務提醒
今天寫的 apiClient 是 production-ready 的最小可用版本,但有幾個地方可以在規模成長後再加強。第一,可以加 request deduplication(同一個 URL 同時間多個 fetch 合併成一個),這在 React 19 的 Suspense 與 useTransition 場景特別有用。第二,可以加 retry 機制(5xx 自動重試 3 次、4xx 不重試),搭配 TanStack Query 5.x 的 retry 設定一起寫,比寫在 apiClient 裡更靈活。第三,可以加 request timeout(30 秒沒回應自動取消),用 AbortController 實作,避免「使用者按鈕按下去、頁面卡 60 秒才顯示錯誤」的慘劇。這些在小型 side project 不急著加,等到流量或錯誤率變高再補;對規模還在「每天幾十個使用者」的專案來說,先求正確、不求花俏。
另一個實務提醒是「CORS 設定」。當前端在 http://localhost:3000、後端在 http://localhost:8000 時,瀏覽器會擋跨來源請求。FastAPI 後端必須設定 CORSMiddleware(allow_origins=["http://localhost:3000"], allow_credentials=True)(Web 系列 Day 36 已示範)。allow_credentials 是必要的,否則 cookie 不會被瀏覽器帶上。另一個常見 bug 是「allow_origins=["*"] 配 allow_credentials=True」會被瀏覽器拒絕(CSRF 防護),一定要明確列出來源;部署到 production 時把 localhost:3000 換成正式網域即可。
最後是「環境變數驗證」。我們用 process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000" 提供 fallback,部署時一定要明確設定。建議加一個 build-time check:當 NODE_ENV === "production" 且沒有設定 NEXT_PUBLIC_API_BASE_URL 時 throw error,讓「漏設環境變數」在 build 階段就被抓出來。Day 41 部署篇章會再展開環境變數的嚴謹做法,包含三層環境分離(Production / Preview / Development)與 secret 管理。對 production 環境來說,把 fallback 拿掉、強制 build 失敗,是最有效的「忘記設環境變數」防線,也是業界部署工程的最佳實踐之一。
小結
今天把貫穿專案從「前端 mock」升級到「可打 FastAPI」的資料層。我們寫了 apiClient(fetch + token + 錯誤格式)、transform.ts(snake_case 與 camelCase 雙向轉換)、lib/api/*.ts(services、bookings、token-store)三層結構。整個切換邏輯集中在 lib/api/,元件完全不感知底下是 mock 還是 live。預期效益:本地開發不需要後端也能跑完整流程;部署到 production 只要把 NEXT_PUBLIC_API_BASE_URL 與 NEXT_PUBLIC_API_MODE=live 設好即可。明天 Day 37 我們會把今天拋出的 ApiError 接上 UI:寫一個全域錯誤邊界、把不同 status code 對應到不同的使用者訊息、加 retry 機制與 fallback UI。
結語
資料層是前端工程師最容易低估、但回頭看最值得投資的部分。今天花一整篇把 mock / live 共用介面、轉換層、token store、錯誤格式一次處理完,明天寫錯誤處理時會非常順手。Day 37 會把今天的 apiFetch 接到一個全域錯誤邊界(ErrorBoundary),把 ApiError 轉成 toast 與 retry 按鈕;再針對 401 加上自動 refresh 流程。預期會用到 Day 9 的 useEffect、Day 14 的 TanStack Query retry、Day 11 的 Context(用來放 toast)。整個專案從今天起,正式進入「前後端可分離開發」的階段。
延伸資源
- FastAPI 0.116 CORS 設定官方文件:
https://fastapi.tiangolo.com/tutorial/cors/ - Fetch API 與 credentials 選項:
https://developer.mozilla.org/en-US/docs/Web/API/fetch#credentials - Web 系列 Day 36 的認證 API 契約:
https://blog.hao-code.com/2025/07/web-day-36.html - Web 系列 Day 37 的預約 API:
https://blog.hao-code.com/2025/07/web-day-37.html - TanStack Query 5.x
fetch抽象與 retry:https://tanstack.com/query/latest/docs/framework/react/guides/query-functions
留言
張貼留言