FastAPI
單元:資料驗證與轉換(Validation & Serialization)
主題:自訂驗證邏輯
簡介
在 FastAPI 中,資料驗證與序列化幾乎全靠 Pydantic 模型完成。內建的型別檢查、範圍限制、正則表達式等已能滿足大多數需求,但實務開發常會碰到 「這個欄位必須符合特定商業規則」、「兩個欄位之間需要相互驗證」 等更複雜的情況。
若僅依賴 Pydantic 提供的預設驗證,往往會讓 API 的錯誤訊息不夠精確,或是驗證邏輯散落在多個路由函式中,難以維護。透過 自訂驗證邏輯(custom validators),我們可以:
- 把業務規則集中在模型內,保持路由函式的簡潔。
- 讓錯誤回傳結構化且具可讀性,提升前端開發者的除錯效率。
- 充分利用型別提示(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 例外 |
最佳實踐總結
- 驗證層級:欄位驗證 → 相依驗證 → 前置清理。依序使用
@validator(pre=True)→@validator→@root_validator。 - 錯誤訊息:保持訊息簡潔、具體,必要時加入
code讓前端快速對應。 - 測試:使用
pytest為每個 validator 撰寫單元測試,確保邊界條件不會被遺漏。 - 文件:在模型的 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 專案將能在 資料驗證 這一關鍵環節上更上一層樓,為後續功能擴充奠定堅實基礎。祝開發順利!