本文 AI 產出,尚未審核

FastAPI

單元:資料驗證與轉換(Validation & Serialization)

主題:自訂驗證邏輯


簡介

在 FastAPI 中,資料驗證與序列化幾乎全靠 Pydantic 模型完成。內建的型別檢查、範圍限制、正則表達式等已能滿足大多數需求,但實務開發常會碰到 「這個欄位必須符合特定商業規則」、「兩個欄位之間需要相互驗證」 等更複雜的情況。

若僅依賴 Pydantic 提供的預設驗證,往往會讓 API 的錯誤訊息不夠精確,或是驗證邏輯散落在多個路由函式中,難以維護。透過 自訂驗證邏輯(custom validators),我們可以:

  1. 把業務規則集中在模型內,保持路由函式的簡潔。
  2. 讓錯誤回傳結構化且具可讀性,提升前端開發者的除錯效率。
  3. 充分利用型別提示(type hints),讓 IDE 能即時提示錯誤。

本篇文章將一步步說明如何在 FastAPI 中撰寫、測試與最佳化自訂驗證,並提供多個實用範例,幫助你快速上手。


核心概念

1. Pydantic 的 @validator 裝飾器

@validator 是 Pydantic 提供的主要切入點,允許在欄位賦值前執行任意 Python 程式碼。其基本語法如下:

from pydantic import BaseModel, validator

class User(BaseModel):
    username: str
    age: int

    @validator('age')
    def age_must_be_positive(cls, v):
        if v <= 0:
            raise ValueError('年齡必須是正數')
        return v
  • cls:模型類別本身,通常不需要使用。
  • v:欄位的原始值。
  • 回傳值:必須是驗證後的值,否則會拋出例外。

重點:若同時驗證多個欄位,可使用 @validator('*') 或 pre=True 參數,讓驗證在欄位解析前(pre)或後(post)執行。

2. 欄位間的相依驗證

有時驗證需要參考其他欄位,例如「密碼與確認密碼必須相同」或「開始日期必須早於結束日期」。這時可以使用 @root_validator,它會收到整個模型的資料字典。

from pydantic import BaseModel, root_validator

class PasswordReset(BaseModel):
    password: str
    confirm_password: str

    @root_validator
    def passwords_match(cls, values):
        pw, cpw = values.get('password'), values.get('confirm_password')
        if pw != cpw:
            raise ValueError('密碼與確認密碼不一致')
        return values

3. 使用 pre=True 進行前置驗證

pre=True 讓驗證在 Pydantic 解析欄位之前執行,適合處理 字串、JSON 文字或自訂格式 的前置清理。

class Item(BaseModel):
    tags: list[str]

    @validator('tags', pre=True)
    def split_tags(cls, v):
        # 前端可能傳入 "tag1,tag2, tag3"
        if isinstance(v, str):
            return [t.strip() for t in v.split(',')]
        return v

4. 自訂資料型別(Custom Types)

若驗證邏輯非常複雜,或需要在多個模型間重複使用,可以定義 自訂資料型別,並實作 __get_validators__ 方法。

class PhoneNumber(str):
    @classmethod
    def __get_validators__(cls):
        yield cls.validate

    @classmethod
    def validate(cls, v):
        import re
        if not re.fullmatch(r'\+?\d{10,15}', v):
            raise ValueError('電話號碼格式不正確')
        return cls(v)

在模型中直接使用:

class Contact(BaseModel):
    name: str
    phone: PhoneNumber

5. 例外類型與錯誤訊息客製化

Pydantic 允許拋出 ValueError、TypeError,或自訂的例外類別。若想要 回傳結構化的錯誤訊息(例如包含錯誤碼),可繼承 pydantic.errors.PydanticErrorMixin:

from pydantic.errors import PydanticErrorMixin

class InvalidCurrencyError(PydanticErrorMixin, ValueError):
    code = 'currency.invalid'
    msg_template = '不支援的貨幣代碼:{currency}'

在驗證器中拋出:

if currency not in ('USD', 'TWD', 'EUR'):
    raise InvalidCurrencyError(currency=currency)

FastAPI 會自動把 code 轉成 JSON 回傳,前端只要根據 code 做對應處理即可。


程式碼範例

以下提供 五個 常見且實用的自訂驗證範例,涵蓋欄位驗證、相依驗證、前置清理、共用型別與錯誤客製化。

範例 1:Email 與 Domain 限制

from pydantic import BaseModel, EmailStr, validator

class RegisterUser(BaseModel):
    email: EmailStr
    allowed_domains: list[str] = ['example.com', 'mycompany.org']

    @validator('email')
    def email_must_be_allowed_domain(cls, v, values):
        domain = v.split('@')[-1]
        if domain not in values.get('allowed_domains', []):
            raise ValueError(f'不允許使用 {domain} 的郵件域名')
        return v

說明:先用 EmailStr 確保格式正確,再檢查是否屬於允許的 domain。values 參數讓我們存取同一模型的其他欄位(此例為 allowed_domains)。


範例 2:日期範圍驗證(開始 < 結束)

from datetime import datetime
from pydantic import BaseModel, root_validator

class Event(BaseModel):
    name: str
    start_at: datetime
    end_at: datetime

    @root_validator
    def check_time_order(cls, values):
        if values['start_at'] >= values['end_at']:
            raise ValueError('活動開始時間必須早於結束時間')
        return values

說明:使用 root_validator 同時檢查兩個欄位的相對關係,避免在路由內寫冗長條件式。


範例 3:前置清理多重標籤(CSV → List)

from pydantic import BaseModel, validator

class BlogPost(BaseModel):
    title: str
    tags: list[str]

    @validator('tags', pre=True)
    def parse_tags(cls, v):
        # 前端可能傳入 "python, fastapi ,web"
        if isinstance(v, str):
            return [tag.strip() for tag in v.split(',') if tag.strip()]
        return v

說明:pre=True 讓我們在 Pydantic 解析 list[str] 前先把字串切割、去空白,提升 API 的彈性。


範例 4:共用自訂型別 – 台灣身分證字號

import re
from pydantic import BaseModel, errors

class TaiwanID(str):
    @classmethod
    def __get_validators__(cls):
        yield cls.validate

    @classmethod
    def validate(cls, v):
        if not isinstance(v, str):
            raise TypeError('身分證必須是字串')
        pattern = r'^[A-Z][12]\d{8}$'
        if not re.fullmatch(pattern, v):
            raise ValueError('身分證格式不正確')
        return cls(v)

class Citizen(BaseModel):
    name: str
    nid: TaiwanID

說明:將驗證邏輯封裝成自訂型別後,任何模型只要使用 TaiwanID 就自動得到相同的驗證與錯誤訊息。


範例 5:客製化錯誤碼 – 貨幣代碼驗證

from pydantic import BaseModel, validator
from pydantic.errors import PydanticErrorMixin

class CurrencyError(PydanticErrorMixin, ValueError):
    code = 'currency.invalid'
    msg_template = '不支援的貨幣代碼:{currency}'

class Payment(BaseModel):
    amount: float
    currency: str

    @validator('currency')
    def validate_currency(cls, v):
        allowed = {'USD', 'TWD', 'JPY'}
        if v not in allowed:
            raise CurrencyError(currency=v)
        return v

說明:拋出 CurrencyError 後,FastAPI 回傳的 JSON 會包含 code 欄位,前端可依此顯示對應的多語系錯誤訊息。


常見陷阱與最佳實踐

陷阱 可能的後果 解決方案 / 最佳實踐
在 @validator 中直接修改其他欄位 造成驗證順序不確定,可能導致資料不一致 使用 @root_validator 或 pre=True 只做「讀取」與「返回」的工作,避免副作用
忘記回傳驗證後的值 Pydantic 會回傳 None,導致欄位變成 null 每個 validator 必須 return v(或處理後的值)
在模型外部重複寫相同驗證 程式碼難以維護,錯誤訊息不一致 把驗證抽成 自訂型別 或 共用函式,在多個模型間重用
使用過於寬鬆的正則表達式 讓不合法資料通過,安全風險上升 先寫單元測試驗證正則表達式的邊界情況,必要時加上額外的 Python 檢查
拋出非 Pydantic 例外 FastAPI 會把例外轉成 500 錯誤,失去結構化錯誤訊息 盡量拋出 ValueError、TypeError 或自訂的 PydanticErrorMixin 例外

最佳實踐總結

  1. 驗證層級:欄位驗證 → 相依驗證 → 前置清理。依序使用 @validator(pre=True) → @validator → @root_validator。
  2. 錯誤訊息:保持訊息簡潔、具體,必要時加入 code 讓前端快速對應。
  3. 測試:使用 pytest 為每個 validator 撰寫單元測試,確保邊界條件不會被遺漏。
  4. 文件:在模型的 docstring 中說明每個自訂驗證的商業規則,方便團隊成員快速了解。

實際應用場景

1. 電子商務 – 商品上架規則

  • 必須保證 price 大於 0 且小於 max_price(由系統設定)。
  • sku 必須符合特定格式(英數混合、長度 8)。
  • available_from 必須早於 available_to,且兩者皆不能早於今天。

透過 @validator 與 @root_validator,所有規則可集中在 Product 模型內,API 路由只負責接收與回傳。

2. 金融系統 – 交易驗證

  • 交易金額必須在每日上限內。
  • currency 必須是白名單內的代碼,且不同幣別的最小交易單位不同。
  • 交易時間必須在營業時間(09:00–17:00)內。

使用 自訂型別 (CurrencyAmount) 結合 客製化錯誤碼,讓前端 UI 能即時顯示「金額過高」或「不支援的幣別」等訊息。

3. 會員系統 – 密碼政策

  • 密碼必須同時包含大小寫、數字與特殊字元,且長度至少 12。
  • 新密碼不能與最近三次使用過的密碼相同(需要查資料庫)。

@validator('password') 只負責格式檢查,@root_validator 再呼叫服務層的資料庫檢查,保持模型的純粹性。


總結

  • 自訂驗證 是 FastAPI 與 Pydantic 強大功能的核心,讓開發者可以把複雜的商業規則寫在模型層,保持路由乾淨、可測試。
  • 透過 @validator、@root_validator、pre=True、自訂型別與錯誤碼,我們能夠 精準控制資料的有效性、提供結構化錯誤回應,並在多個模型間 重用驗證邏輯。
  • 避免常見陷阱(副作用、忘記回傳值、錯誤訊息不一致),遵守最佳實踐(層級分明、單元測試、文件化),即可建構 安全、易維護且使用者友好的 API。

掌握這些技巧後,你的 FastAPI 專案將能在 資料驗證 這一關鍵環節上更上一層樓,為後續功能擴充奠定堅實基礎。祝開發順利!