跳到主要內容

Web Day 26 用 Next.js 串接 FastAPI(二)寫入與表單

Web Day 26 用 Next.js 串接 FastAPI(二)寫入與表單

執行需求:CPU 可跑。昨天把「Next.js 15 讀取 FastAPI JSON」走完,今天進入「寫入」:在 Server Component 裡呼叫 Server Action、新增預約、刪除預約、處理表單驗證與錯誤狀態。我們延用 Day 25 的後端 JSON API,只在前端加上一個完整的寫入流程;後端在貫穿專案「預約管理系統」會用同一套端點。

引言

昨天我們建立了一個只會讀的 Next.js 介面:頁面渲染時從 FastAPI 拿資料,顯示清單與細節。但「使用者新增預約」「刪除預約」「取消預約」這類寫入操作,需要的是「使用者填表單、按送出、伺服器接收、處理、回到原本頁面或顯示錯誤」這個流程。React 19 提供一個全新的機制叫 Server Actions:你可以把一個 async function 直接掛在 <form action={...}> 上,Next.js 會自動把表單序列化、送到伺服器、執行函式、再把回傳的內容重新渲染頁面。整個過程不寫 API 端點、不寫 fetch、不寫 JSON 序列化——這對「表單→寫入→回到頁面」這個最常見的流程是巨大的簡化。

這篇文章要做四件事:第一,在 FastAPI 端加上 POST 與 DELETE 的 JSON 端點(含 Pydantic 驗證);第二,用 zod 在前端做表單驗證;第三,用 React 19 的 useActionState 與 useFormStatus 處理「送出中」「錯誤訊息」「欄位錯誤」;第四,用 pytest 為寫入端點寫測試。我們刻意不在前端做樂觀更新(optimistic update)或用 React Query,把範圍控制在「表單能送出、錯誤能顯示」。

後端:POST 與 DELETE 的 JSON 端點

延用 Day 25 的 main_api.py,加上寫入端點。FastAPI 用 Pydantic BaseModel 自動驗證請求主體,這對前端是極大的保護:使用者送來的 JSON 結構不對、後端會回 422 與清楚的錯誤訊息。

# 追加到 main_api.py(Day 25 已有 BookingOut,這裡加 BookingIn)
from typing import Annotated
import uuid
from datetime import datetime, timezone

from fastapi import Body, FastAPI, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator


class BookingIn(BaseModel):
    customer: str = Field(min_length=1, max_length=64)
    slot: str = Field(min_length=10, max_length=32)
    service: str = Field(default="教練課", min_length=1, max_length=32)

    @field_validator("slot")
    @classmethod
    def slot_must_be_iso8601(cls, v: str) -> str:
        # 簡單驗證:能不能用 datetime.fromisoformat 解析
        try:
            datetime.fromisoformat(v)
        except ValueError as e:
            raise ValueError("slot 必須是 ISO 8601 字串") from e
        return v


@app.post(
    "/api/bookings",
    response_model=BookingOut,
    status_code=status.HTTP_201_CREATED,
)
def create_booking(payload: BookingIn):
    booking = {
        "id": str(uuid.uuid4()),
        "customer": payload.customer,
        "slot": payload.slot,
        "service": payload.service,
        "status": "confirmed",
        "created_at": datetime.now(timezone.utc).timestamp(),
    }
    BOOKINGS.append(booking)
    return booking


@app.delete("/api/bookings/{booking_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_booking_api(booking_id: str):
    before = len(BOOKINGS)
    BOOKINGS[:] = [b for b in BOOKINGS if b["id"] != booking_id]
    if len(BOOKINGS) == before:
        raise HTTPException(status_code=404, detail="找不到此預約")
    return None


@app.patch("/api/bookings/{booking_id}/cancel", response_model=BookingOut)
def cancel_booking_api(booking_id: str):
    for b in BOOKINGS:
        if b["id"] == booking_id:
            b["status"] = "cancelled"
            return b
    raise HTTPException(status_code=404, detail="找不到此預約")

這段展示了 Pydantic v2 的幾個重點。Field(min_length=, max_length=) 限制字串長度;@field_validator("slot") 自訂驗證(這裡要求 ISO 8601 格式)。FastAPI 會把這些驗證規則自動轉成 OpenAPI schema 與 422 錯誤訊息。status_code=status.HTTP_201_CREATED 明確告訴 API 消費者「新增成功」的標準 status code。204 No Content 是 DELETE 慣例:成功不回主體、避免 client 端以為有資料要解析。

三個端點:POST 新增、DELETE 刪除、PATCH 取消(只更新狀態,不刪除記錄)。實務上取消比刪除友善,使用者取消後資料還在,方便做審計、報表、回復。這跟預約管理系統的需求對應:客戶可能「取消」預約但不該徹底消失。

前端:Server Action 與表單

先建立 zod schema 在前端做表單驗證。zod 是 TypeScript 生態最常用的 schema 驗證函式庫,跟 Pydantic 風格相似:

// src/lib/booking-schema.ts
// 用 zod 定義前端表單驗證規則
import { z } from "zod";

export const bookingSchema = z.object({
  customer: z
    .string()
    .min(1, "請輸入客戶姓名")
    .max(64, "姓名最多 64 字"),
  slot: z
    .string()
    .min(10, "請選擇預約時段")
    .refine((v) => !Number.isNaN(Date.parse(v)), {
      message: "時段格式不正確",
    }),
  service: z.string().min(1, "請選擇服務").max(32),
});

export type BookingFormValues = z.infer<typeof bookingSchema>;

zod schema 的 min/max 對應到 Pydantic 的 min_length/max_length,refine 提供自訂驗證(這裡用 Date.parse() 驗證時段是否為合法日期字串)。z.infer<typeof bookingSchema> 自動推導 TypeScript 型別,讓前端程式碼享有完整的型別檢查。今天我們用手動處理錯誤訊息,明天(Web Day 27)會把認證整合進來。

接下來是 Server Action。它在伺服器端執行,但可以被 client component 透過 <form action={...}> 觸發:

// src/app/bookings/actions.ts
// Server Actions:直接執行在伺服器、不用寫 fetch
"use server";

import { revalidatePath } from "next/cache";

import { listBookings, getBooking } from "@/lib/api";
import { bookingSchema } from "@/lib/booking-schema";

const API_BASE = process.env.API_BASE ?? "http://127.0.0.1:8000";

export type ActionState = {
  ok: boolean;
  message?: string;
  fieldErrors?: Partial<Record<keyof BookingFormValues, string>>;
};

export async function createBookingAction(
  _prev: ActionState,
  formData: FormData,
): Promise<ActionState> {
  // 解析 FormData 為物件
  const raw = {
    customer: String(formData.get("customer") ?? ""),
    slot: String(formData.get("slot") ?? ""),
    service: String(formData.get("service") ?? "教練課"),
  };

  // 前端驗證
  const parsed = bookingSchema.safeParse(raw);
  if (!parsed.success) {
    const fieldErrors: ActionState["fieldErrors"] = {};
    for (const issue of parsed.error.issues) {
      fieldErrors[issue.path[0] as keyof BookingFormValues] = issue.message;
    }
    return { ok: false, fieldErrors };
  }

  // 打 FastAPI(注意:API_BASE 沒 NEXT_PUBLIC_ 前綴,只在伺服器端可見)
  const res = await fetch(`${API_BASE}/api/bookings`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(parsed.data),
  });

  if (!res.ok) {
    return { ok: false, message: `伺服器錯誤:${res.status}` };
  }

  // 重新整理 /bookings 頁面
  revalidatePath("/bookings");
  return { ok: true };
}

export async function deleteBookingAction(bookingId: string) {
  const res = await fetch(`${API_BASE}/api/bookings/${bookingId}`, {
    method: "DELETE",
  });
  if (!res.ok && res.status !== 404) {
    throw new Error(`刪除失敗:${res.status}`);
  }
  revalidatePath("/bookings");
}

這個檔案做了幾件關鍵事。第一,"use server" 標記讓整支檔案都是 Server Action,async function 可以被前端 <form> 直接呼叫。第二,revalidatePath("/bookings") 告訴 Next.js「/bookings 頁面的快取失效,下次請求重新抓資料」——這是 Server Component 與 Server Action 整合的關鍵設計:寫入後不需要手動 refetch,Next.js 自動處理。第三,API_BASE 沒有 NEXT_PUBLIC_ 前綴,只在伺服器端可見,這避免把內部 URL 暴露到瀏覽器。

表單元件是用 useActionState 與 useFormStatus 的 client component:

// src/app/bookings/new-form.tsx
// 新增預約表單(client component,含送出狀態與錯誤訊息)
"use client";

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

import {
  createBookingAction,
  type ActionState,
} from "./actions";

const initial: ActionState = { ok: false };

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "送出中..." : "新增預約"}
    </button>
  );
}

export function NewBookingForm() {
  const [state, formAction] = useActionState(createBookingAction, initial);

  return (
    <form action={formAction} style={{ display: "grid", gap: 8 }}>
      <label>
        客戶姓名
        <input name="customer" required maxLength={64} />
        {state.fieldErrors?.customer && (
          <span style={{ color: "red" }}>{state.fieldErrors.customer}</span>
        )}
      </label>
      <label>
        預約時段
        <input name="slot" type="datetime-local" required />
        {state.fieldErrors?.slot && (
          <span style={{ color: "red" }}>{state.fieldErrors.slot}</span>
        )}
      </label>
      <label>
        服務
        <input name="service" defaultValue="教練課" required maxLength={32} />
        {state.fieldErrors?.service && (
          <span style={{ color: "red" }}>{state.fieldErrors.service}</span>
        )}
      </label>

      {state.message && !state.ok && (
        <p style={{ color: "red" }}>{state.message}</p>
      )}
      {state.ok && state.message && (
        <p style={{ color: "green" }}>{state.message}</p>
      )}

      <SubmitButton />
    </form>
  );
}

useActionState(action, initial) 回傳 [state, formAction]:state 是上次 action 回傳的值,formAction 是包裝過的函式,掛在 <form action> 上即可。useFormStatus() 給 submit 按鈕用,回傳 pending: boolean,讓我們在送出時禁用按鈕並顯示「送出中」。

注意 fieldErrors 的顯示方式:每個欄位下方單獨顯示錯誤,全域錯誤(如伺服器 500)用 state.message 在表單底部顯示。這樣區分「這個欄位填錯了」與「整個操作失敗」,使用者能立刻知道怎麼修正。

最後在清單頁嵌入這個表單:

// src/app/bookings/page.tsx(部分)
import { NewBookingForm } from "./new-form";

export default async function BookingsPage() {
  const data = await listBookings();
  return (
    <main style={{ padding: 24 }}>
      <h1>預約清單</h1>
      <NewBookingForm />
      <p>共 {data.total} 筆</p>
      {/* ... list ... */}
    </main>
  );
}

Server Component 頁面嵌入 client component 表單,是 Next.js 15 最自然的混搭方式:頁面本身是 server(fetch 資料、組 HTML),表單是 client(處理使用者互動)。bundle 維持小體積,SEO 友善,互動流暢。

用 pytest 驗證寫入端點

寫入端點的測試比讀取重要:寫入錯誤可能造成資料污染或遺失。我們用「測試建立→測試驗證失敗→測試刪除→測試刪除不存在的東西」的順序寫:

# test_writes.py
# FastAPI 寫入端點測試(沿用 Day 25 的 conftest)
import pytest
from fastapi.testclient import TestClient

from main_api import app, BOOKINGS

client = TestClient(app)


@pytest.fixture(autouse=True)
def reset():
    BOOKINGS.clear()
    yield
    BOOKINGS.clear()


def test_create_booking_success():
    r = client.post(
        "/api/bookings",
        json={"customer": "王小明", "slot": "2025-08-10T10:00", "service": "重訓"},
    )
    assert r.status_code == 201
    body = r.json()
    assert body["customer"] == "王小明"
    assert body["status"] == "confirmed"
    assert "id" in body
    assert len(BOOKINGS) == 1


def test_create_booking_validation_failure():
    # 缺 customer → 422
    r = client.post("/api/bookings", json={"slot": "2025-08-10T10:00"})
    assert r.status_code == 422
    assert "detail" in r.json()

    # slot 不是 ISO 8601 → 422
    r = client.post(
        "/api/bookings",
        json={"customer": "test", "slot": "明天"},
    )
    assert r.status_code == 422


def test_create_booking_too_long_customer():
    r = client.post(
        "/api/bookings",
        json={"customer": "x" * 100, "slot": "2025-08-10T10:00"},
    )
    assert r.status_code == 422


def test_delete_booking_success():
    # 先建一筆
    r = client.post(
        "/api/bookings",
        json={"customer": "test", "slot": "2025-08-10T10:00"},
    )
    booking_id = r.json()["id"]

    r = client.delete(f"/api/bookings/{booking_id}")
    assert r.status_code == 204
    assert r.text == ""  # 204 沒有主體
    assert len(BOOKINGS) == 0


def test_delete_missing_booking():
    r = client.delete("/api/bookings/does-not-exist")
    assert r.status_code == 404


def test_cancel_booking():
    r = client.post(
        "/api/bookings",
        json={"customer": "test", "slot": "2025-08-10T10:00"},
    )
    booking_id = r.json()["id"]

    r = client.patch(f"/api/bookings/{booking_id}/cancel")
    assert r.status_code == 200
    assert r.json()["status"] == "cancelled"

    # 原本的 BOOKINGS 還在,只是狀態變了
    assert len(BOOKINGS) == 1
    assert BOOKINGS[0]["status"] == "cancelled"

六個測試覆蓋了寫入端點的三個面向:成功路徑(建立成功)、驗證失敗路徑(缺欄位、格式錯、長度錯)、錯誤處理(刪除不存在的東西 404)。status_code == 422 是 FastAPI/Pydantic 預設的驗證失敗 status code,比 400 更精準地表達「請求格式正確但內容不通過驗證」。204 No Content 刪除慣例,r.text == "" 驗證沒有主體。

把業務邏輯從 endpoint 抽出來

上面的 create_booking 把「驗證、寫入、產生 id、設狀態」全做在一個函式裡;當業務規則變複雜(例如預約衝突檢查、通知排程、稽核日誌)就會爆掉。我們用 service layer 把這些規則集中:

# services.py
# 預約業務邏輯:把「規則」與「傳輸」分開
import uuid
from datetime import datetime, timezone

from main_api import BOOKINGS, BookingIn, BookingOut


class BookingConflict(Exception):
    # 自訂例外,給 endpoint 轉成 409 Conflict
    pass


def create_booking_service(payload: BookingIn) -> BookingOut:
    """新增預約,含衝突檢查。"""
    # 規則 1:同一時段不能有兩個 confirmed 預約
    for existing in BOOKINGS:
        if (
            existing["slot"] == payload.slot
            and existing["status"] == "confirmed"
        ):
            raise BookingConflict(f"時段 {payload.slot} 已被預約")

    booking = {
        "id": str(uuid.uuid4()),
        "customer": payload.customer,
        "slot": payload.slot,
        "service": payload.service,
        "status": "confirmed",
        "created_at": datetime.now(timezone.utc).timestamp(),
    }
    BOOKINGS.append(booking)
    return BookingOut(**booking)


def cancel_booking_service(booking_id: str) -> BookingOut:
    """取消預約,找不到拋 ValueError。"""
    for b in BOOKINGS:
        if b["id"] == booking_id:
            b["status"] = "cancelled"
            return BookingOut(**b)
    raise ValueError(f"找不到預約 {booking_id}")


def delete_booking_service(booking_id: str) -> bool:
    """刪除預約,回傳是否真的有刪除。"""
    before = len(BOOKINGS)
    BOOKINGS[:] = [b for b in BOOKINGS if b["id"] != booking_id]
    return len(BOOKINGS) < before

這個 service 層做了三件事:第一,把「同時段不能有兩個 confirmed 預約」這種商業規則集中起來,未來加更多規則(至少提前 X 小時預約、最多 N 筆預約)都在這裡加。第二,自訂 BookingConflict 例外讓 endpoint 可以明確轉成 409。第三,每個函式都回傳 BookingOut 而不是 dict,避免 endpoint 額外轉型。

endpoint 改為呼叫 service 層,自訂例外用 @app.exception_handler 統一處理:

# 改寫 main_api.py 的 endpoint
from fastapi.requests import Request
from fastapi.responses import JSONResponse

from services import (
    BookingConflict,
    create_booking_service,
    cancel_booking_service,
    delete_booking_service,
)


@app.exception_handler(BookingConflict)
async def conflict_handler(request: Request, exc: BookingConflict):
    return JSONResponse(status_code=409, content={"detail": str(exc)})


@app.post("/api/bookings", response_model=BookingOut, status_code=201)
def create_booking(payload: BookingIn):
    return create_booking_service(payload)


@app.delete("/api/bookings/{booking_id}", status_code=204)
def delete_booking_api(booking_id: str):
    if not delete_booking_service(booking_id):
        raise HTTPException(status_code=404, detail="找不到此預約")
    return None


@app.patch("/api/bookings/{booking_id}/cancel", response_model=BookingOut)
def cancel_booking_api(booking_id: str):
    try:
        return cancel_booking_service(booking_id)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

把 endpoint 縮短後,每個函式只負責「接 HTTP、丟 service、回應」。未來 service 加上快取(Web Day 20)、日誌(Web Day 22)、權限檢查(Web Day 13)都不會影響 endpoint 的形狀。Web Day 35 之後的「預約管理系統」就是用這套模式擴充。

為 service 層加單元測試

service 層可以用純 Python 測試,不需要 HTTP。這對商業規則(例如衝突檢查)特別有價值:

# test_services.py
# service 層單元測試:純 Python,不走 HTTP
import pytest

from services import (
    BookingConflict,
    create_booking_service,
    cancel_booking_service,
    delete_booking_service,
)
from main_api import BOOKINGS, BookingIn


@pytest.fixture(autouse=True)
def reset():
    BOOKINGS.clear()
    yield
    BOOKINGS.clear()


def _make_in(**overrides) -> BookingIn:
    defaults = {"customer": "王小明", "slot": "2025-08-10T10:00"}
    defaults.update(overrides)
    return BookingIn(**defaults)


def test_create_booking_adds_to_list():
    booking = create_booking_service(_make_in())
    assert booking.customer == "王小明"
    assert booking.status == "confirmed"
    assert len(BOOKINGS) == 1


def test_create_booking_detects_conflict():
    create_booking_service(_make_in(slot="2025-08-10T10:00"))
    with pytest.raises(BookingConflict):
        create_booking_service(_make_in(slot="2025-08-10T10:00"))


def test_create_booking_cancelled_slot_can_be_reused():
    # 取消的預約釋出時段
        create_booking_service(_make_in(slot="2025-08-10T10:00"))
        cancel_booking_service(BOOKINGS[0]["id"])
        # 同時段可以再預約(因為舊的是 cancelled)
        booking = create_booking_service(_make_in(slot="2025-08-10T10:00"))
        assert booking.status == "confirmed"
        assert len(BOOKINGS) == 2


def test_cancel_missing_booking_raises():
    with pytest.raises(ValueError):
        cancel_booking_service("does-not-exist")


def test_delete_existing_booking():
    booking = create_booking_service(_make_in())
    assert delete_booking_service(booking.id) is True
    assert len(BOOKINGS) == 0


def test_delete_missing_booking():
    assert delete_booking_service("does-not-exist") is False

這份測試直接 import service 函式,不需要 FastAPI 的 TestClient,執行速度快(毫秒級)。其中 test_create_booking_detects_conflict 與 test_create_booking_cancelled_slot_can_be_reused 證明了「取消的時段可以重新被預約」這種業務規則。這類規則如果只靠 endpoint 測試覆蓋,容易在重構時漏掉;service 層測試把它們鎖住,未來改程式碼時立刻看到失敗。

日誌與審計

寫入操作應該留紀錄,方便事後追查「誰在什麼時候改了什麼」。Web Day 22 已介紹過 Python logging,這裡把 audit log 直接做進 service 層:

# audit.py
# 預約寫入的稽核日誌
import logging
import os
from datetime import datetime, timezone

audit = logging.getLogger("booking.audit")


def log_action(action: str, booking_id: str, customer: str, **details):
    audit.info(
        "action=%s booking_id=%s customer=%s %s",
        action,
        booking_id,
        customer,
        " ".join(f"{k}={v}" for k, v in details.items()),
    )


# 在 services.py 裡的呼叫範例:
#   log_action("create", booking["id"], booking["customer"], slot=booking["slot"])
#   log_action("cancel", booking_id, BOOKINGS_BY_ID[booking_id]["customer"])
#   log_action("delete", booking_id, "-")

在 services.py 的關鍵節點加 log_action(...),所有寫入都會留下 action / booking_id / customer / 時間戳記 的記錄。Web Day 22 會教如何把這些紀錄送到檔案、Elasticsearch、或 Loki。今天先建立結構,日誌輸出由 Day 22 的 logging 設定統一處理。

實務上 audit log 跟一般 log 應該分開:audit log 寫進不可竄改的儲存(append-only file、資料庫的 audit table)、保留期長(至少一年)、只記錄「誰做了什麼」而非系統除錯訊息。今天先建函式,後續章節會補上儲存與查詢介面。

表單使用者體驗的細節

前面專注在「功能面」,但表單的使用者體驗同樣重要。我們整理出三個常被忽略的細節。第一,送出後自動重置欄位。使用者新增一筆預約後,預期看到表單清空、可以繼續新增下一筆。我們在 Server Action 成功後用 formRef.current?.reset() 處理(client component 才能存取 DOM),這需要把 useRef 與 useEffect 串起來。簡化版的替代做法是 redirect("/bookings") 把頁面整個重新整理。

第二,顯示剛剛送出的內容。使用者最常問「我剛剛送的那筆呢?」。我們在 Server Action 成功後把 booking id 存進 state.message,前端顯示「已新增預約 #abc123」,使用者能立刻確認。

第三,送出中禁用整個表單。今天的 SubmitButton 用了 disabled={pending},但其他欄位仍可編輯。實務上建議把整個表單套上一層 aria-busy={pending},讓螢幕閱讀器與鍵盤使用者知道「現在不能動」。useFormStatus 只給 submit 按鈕用,要禁用其他欄位可以用另一個 useFormStatus 放在 form 內的 wrapper 元件。

第四,錯誤訊息要可行動。今天的錯誤訊息(「請輸入客戶姓名」)明確指出要修正的欄位;如果改成「驗證失敗」就完全不夠。對應原則:錯誤訊息要回答「哪裡錯了」與「該怎麼修正」,例如「客戶姓名至少 1 個字」比「姓名不能空白」更直接。

取消預約的 Server Action

新增的 Server Action 只示範了 POST。實務上取消預約也是常用操作,我們用同一套模式寫:

// 在 src/app/bookings/actions.ts 加上
export async function cancelBookingAction(bookingId: string) {
  const res = await fetch(`${API_BASE}/api/bookings/${bookingId}/cancel`, {
    method: "PATCH",
  });
  if (!res.ok) {
    throw new Error(`取消失敗:${res.status}`);
  }
  revalidatePath("/bookings");
  revalidatePath(`/bookings/${bookingId}`);
}

取消動作沒有表單欄位,所以這個 action 只接收 bookingId 一個參數。revalidatePath 同時刷新清單頁與細節頁,確保兩個頁面都看到最新狀態。PATCH 用來表達「部分更新」語意(這裡只改 status 欄位),跟 PUT(整個資源替換)做區分。

在細節頁用一個「取消」按鈕觸發這個 action:

// src/app/bookings/[id]/cancel-button.tsx
// 取消按鈕(client component)
"use client";

import { cancelBookingAction } from "../actions";

export function CancelButton({ bookingId }: { bookingId: string }) {
  return (
    <form
      action={async () => {
        await cancelBookingAction(bookingId);
      }}
    >
      <button type="submit">取消預約</button>
    </form>
  );
}

這個範例把 cancelBookingAction 包進 <form action={...}> 的 inline async 函式,是 React 19 的新寫法。送出後自動跳轉(如果 action 內 redirect())或停留在原頁(如果只有 revalidatePath),由 action 內部決定。

用 CLI 端到端驗證寫入

pytest 跑完後,我們會用一支 CLI 腳本做「完整端到端測試」:從「沒資料」開始、建立預約、列出確認、刪除、再次列出確認。這比單元測試更貼近真實使用情境:

# scripts/e2e_write.py
# 端到端測試:新增 → 列出 → 刪除 → 再次列出
import httpx

BASE = "http://127.0.0.1:8000"


def main() -> int:
    with httpx.Client(base_url=BASE, timeout=5.0) as client:
        # 新增
        r = client.post(
            "/api/bookings",
            json={"customer": "E2E 測試", "slot": "2025-08-10T10:00"},
        )
        r.raise_for_status()
        booking_id = r.json()["id"]
        print(f"已建立:{booking_id}")

        # 列出確認
        r = client.get("/api/bookings")
        total = r.json()["total"]
        assert total >= 1
        print(f"清單共 {total} 筆")

        # 取消(用 PATCH)
        r = client.patch(f"/api/bookings/{booking_id}/cancel")
        r.raise_for_status()
        assert r.json()["status"] == "cancelled"
        print(f"已取消:{booking_id}")

        # 刪除
        r = client.delete(f"/api/bookings/{booking_id}")
        assert r.status_code == 204
        print(f"已刪除:{booking_id}")

    print("端到端測試通過")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

這支腳本可以在 CI 跑(先啟動 FastAPI、跑 pytest、跑 e2e、關 FastAPI)。它驗證的不是「單元邏輯」而是「整個 API 串起來能不能運作」,跟整合測試互補。

常見錯誤與踩雷

第一個常見踩雷:Server Action 忘了加 "use server"。如果檔案頂端少了這行,函式會被當成普通函式(client 端執行),找不到 FastAPI 的 URL、找不到環境變數,瀏覽器 console 會出現「process is not defined」。對應排查:每個 actions.ts 檔案第一行都應該有 "use server"。

第二個常見踩雷:把 "use server" 加在 client component 裡。React 19 看到 client component 裡有 "use server" 會編譯錯誤,因為 client 端不能直接呼叫 server function。對應排查:"use server" 只放在專門的 actions 檔案,每個函式都會被序列化送到伺服器執行。

第三個常見踩雷:revalidatePath 沒生效。Server Action 寫入後頁面沒更新,多半是因為忘了 revalidatePath("/bookings"),Next.js 繼續用快取的舊資料。對應排查:每次寫入都呼叫對應的 revalidatePath;如果想更精準(只重抓特定頁面區塊),改用 revalidateTag("bookings")。

第四個常見踩雷:表單送出後 URL 沒變。使用者按重新整理瀏覽器會跳出「確認重新送出表單」對話框,因為瀏覽器以為你要重送 POST。對應策略:Server Action 成功後用 redirect() 把使用者導到 GET 頁面。今天的範例省略了,但實務上建議加上。

第五個常見踩雷:useFormStatus 用錯位置。useFormStatus 只能在 <form> 內部使用,必須是 <form> 的後代元件。今天把 SubmitButton 拆成獨立元件、放在 <form> 內,正是為了讓 useFormStatus() 找得到它。如果把 SubmitButton 放在 <form> 外面,pending 永遠是 false。

效能與實務提醒

Server Action 在主網路架構下走 POST 表單機制,不是 JSON API。每次送出都會把整個 form data 序列化後送到伺服器。對小表單沒問題,但對大資料(例如 50 個欄位)就要考慮:是否改用 client component 直接呼叫 fetch,把資料用 JSON 傳遞。Web Day 36 進入「認證與多角色」時,會處理「表單內容包含敏感資料怎麼加密」的問題。

另一個提醒是「前端驗證不能取代後端驗證」。今天的 zod 驗證能擋掉使用者輸入錯,但擋不掉有人用 curl 直接打 FastAPI(繞過前端)。後端的 Pydantic 驗證才是真正的最後防線。對應原則:前端驗證做 UX、後端驗證做安全,兩者都要寫。

最後一個細節:useActionState 是 React 19 才有的 hook。如果你用舊版 React 或忘了升級,會出現 useActionState is not a function 的錯誤。本系列固定在 React 19,記得檢查 package.json 的 react 與 react-dom 版本。

小結

今天把「Next.js 15 寫入 FastAPI」的最小流程走完。後端加了 POST / DELETE / PATCH 三個端點(含 Pydantic 驗證),前端用 zod 做表單驗證、用 Server Action 串接後端、用 useActionState 與 useFormStatus 處理狀態、用 pytest 與 e2e 腳本驗證。整個寫入流程對使用者來說是「填表單→按送出→看到結果」,對開發者來說是「定義 schema→寫 action→接 form→完成」。明天我們要進入認證:token 怎麼存、自動登出怎麼做、CSRF 怎麼擋,這是「前端整合」這個區塊的關鍵安全章節。

結語

今天的重點是「React 19 Server Actions + FastAPI 寫入端點」。我們用 zod 在前端做表單驗證,用 Server Action 把 fetch 包起來,用 useActionState 與 useFormStatus 處理送出狀態,用 revalidatePath 重抓清單頁。讀完這篇你應該能回答:React 19 的 Server Action 怎麼寫?useActionState 與 useFormStatus 的差別?zod 與 Pydantic 在表單驗證上如何分工?FastAPI 的 Pydantic 怎麼回傳 422 與 201?

明天,我們進入「前端認證」。Web Day 12 已教過後端 JWT(簽發與驗證 token),今天要把這套機制整合到 Next.js:token 存哪裡(localStorage vs httpOnly cookie 的取捨要講清楚)、怎麼自動帶上每個請求、token 過期怎麼自動登出。這是「對外頁」能不能正式上線的關卡,請預留一段完整的時間跟著做。

延伸資源

  • React 19 Server Actions(2025):react.dev/reference/rsc/server-actions,"use server"、useActionState、revalidatePath 的現行用法。
  • Next.js 15 Server Actions 與 revalidatePath(2025-07):https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations,完整的 cache 失效策略。
  • Pydantic v2 field_validator(2.11,2025):https://docs.pydantic.dev/latest/concepts/validators/,自訂驗證、Field 約束、422 錯誤結構。
  • zod 4 官方 Docs(2025):https://zod.dev/,safeParse、refine、z.infer 的現行用法。
  • FastAPI 0.116 status code 慣例(2025):https://fastapi.tiangolo.com/tutorial/response-status-code/,201、204、404、422 的使用時機。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...