FastAPI 教學:資料驗證與轉換 – Union 型別驗證
簡介
在 FastAPI 中,資料驗證與序列化是由 Pydantic 完成的。當 API 接收的參數可能有多種形態(例如同時接受 int 與 str、或是兩種不同的模型)時,使用 Union(聯合型別)就能讓開發者用簡潔的方式表達「接受任一型別」的需求。
為什麼需要 Union?
- 前端或第三方系統可能會以不同格式傳送相同的概念(例如 ID 可能是數字或字串)。
- 允許 API 在保持型別安全的同時,提供更彈性的介面。
Union 也是 FastAPI 自動產生 OpenAPI 文件的關鍵,正確使用能讓文件更清晰、測試更容易。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你掌握在 FastAPI 中使用 Union 進行資料驗證與轉換的技巧。
核心概念
1. Python 中的 Union
在 Python 3.10 以前,我們使用 typing.Union:
from typing import Union
Number = Union[int, float]
從 Python 3.10 起,PEP 604 允許使用直觀的 | 符號:
Number = int | float
FastAPI 完全支援兩種寫法,且在生成 OpenAPI 時會自動轉換為 oneOf 結構。
2. Pydantic 與 Union 的互動
Pydantic 會依序嘗試 每個候選型別,直到有一個成功驗證為止。
from pydantic import BaseModel
class Item(BaseModel):
value: int | str # 同時接受 int 或 str
⚠️ 注意:Pydantic 會依照 型別宣告的順序 進行嘗試,順序不當可能導致意外的解析結果。
3. 基本範例:接受字串或整數
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class QueryParam(BaseModel):
identifier: int | str # 允許傳入 123 或 "abc123"
@app.get("/items/")
def read_item(param: QueryParam):
return {"type": type(param.identifier).__name__, "value": param.identifier}
- 請求
GET /items/?identifier=42→ 回傳{"type":"int","value":42} - 請求
GET /items/?identifier=abc123→ 回傳{"type":"str","value":"abc123"}
4. Union 與 BaseModel 的結合
當需要接受 兩種不同的資料結構 時,可以把模型本身放入 Union:
from typing import Literal
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
kind: Literal["user"] = Field(..., description="類型標記")
username: str
password: str
class AdminCreate(BaseModel):
kind: Literal["admin"] = Field(..., description="類型標記")
username: str
secret_key: str
CreatePayload = UserCreate | AdminCreate
@app.post("/create/")
def create(payload: CreatePayload):
# payload 會自動被轉型成對應的模型
return {"model": payload.__class__.__name__, "data": payload.dict()}
- 只要 JSON 中的
kind欄位正確對應,就能分辨是UserCreate還是AdminCreate。 - 這種寫法在產生 OpenAPI 時會變成
oneOf,文件會顯示兩種可能的結構。
5. 使用 discriminated unions(具辨識型別的 Union)
Pydantic v2(以及 v1.10+ 的 typing_extensions) 提供 discriminated unions,讓解析更可靠:
from pydantic import BaseModel, Field
from typing import Literal, Union
class Cat(BaseModel):
type: Literal["cat"] = Field(..., description="辨識欄位")
name: str
lives: int = 9
class Dog(BaseModel):
type: Literal["dog"] = Field(..., description="辨識欄位")
name: str
breed: str
Pet = Union[Cat, Dog] # Python 3.10+ 也可寫成 Cat | Dog
@app.post("/pet/")
def add_pet(pet: Pet):
return {"animal": pet.type, "detail": pet.dict()}
- 只要 JSON 中的
type欄位與模型的Literal匹配,即可正確分派。 - 優點:避免因欄位重疊而產生的歧義,特別適合 複雜的多模型 API。
6. Union 與 Optional(可為 None)
Optional[T] 本質上是 Union[T, None],因此在宣告時可以直接使用 | None:
from typing import Optional
class SearchParams(BaseModel):
q: str
page: int | None = None # 同等於 Optional[int]
@app.get("/search/")
def search(params: SearchParams):
return {"query": params.q, "page": params.page or 1}
7. 自訂驗證:在 Union 中加入額外邏輯
有時候單純的型別檢查不足,需要自訂驗證。可以在模型內使用 @validator:
from pydantic import validator, ValidationError
class IDOrSlug(BaseModel):
identifier: int | str
@validator("identifier")
def check_int_or_slug(cls, v):
if isinstance(v, int) and v <= 0:
raise ValueError("int identifier 必須大於 0")
if isinstance(v, str) and not v.isalnum():
raise ValueError("slug 必須為字母或數字")
return v
此時即使傳入的型別符合 int|str,仍會受到額外規則的限制。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 型別順序不當 | Pydantic 依序嘗試 Union 中的型別,若 str 放在 int 前,"123" 會被先當成字串,導致失去數值轉型的機會。 |
將較嚴格或較具體的型別放前(如 int 在 str 前)。 |
| 模糊的結構 | 兩個模型欄位幾乎相同,缺少辨識欄位時,Pydantic 可能無法正確分派。 | 使用 discriminated union,在每個模型加入唯一的 Literal 欄位作為類型標記。 |
| OpenAPI 文件不完整 | 若 Union 包含自訂型別或 Any,自動產生的文件可能缺少說明。 |
為每個模型加入 description、example,或使用 Response、JSONResponse 手動描述。 |
| 預設值與 Optional 混用 | `field: int | None = None與field: Optional[int] = None` 效果相同,但在 Swagger UI 中顯示會有所差異。 |
| 驗證錯誤訊息不友好 | 複雜 Union 錯誤時,Pydantic 只回傳「value is not a valid union」的訊息。 | 使用 自訂 validator 或 root_validator,提供更具體的錯誤資訊。 |
最佳實踐
- 明確的辨識欄位:若 Union 包含多個模型,務必加入
Literal欄位作為辨識標記。 - 先放具體型別:在
int|str、float|Decimal等情況下,把較具體的型別寫在前面。 - 搭配
Field提供範例:在模型欄位加上example=,讓 Swagger UI 更直觀。 - 測試每個分支:使用
TestClient撰寫測試,確保每個 Union 成員都能正確驗證。 - 避免過度寬鬆:雖然 Union 提供彈性,但過度使用會讓 API 難以維護,盡量在需求確定時才使用。
實際應用場景
1. ID 或 Slug 的查詢
許多系統允許使用 數字 ID 或 文字 slug 來定位資源:
class ResourceLookup(BaseModel):
identifier: int | str # 例如 123 或 "my-article"
@app.get("/resource/")
def get_resource(lookup: ResourceLookup):
# 假設有兩個查詢函式
if isinstance(lookup.identifier, int):
obj = get_by_id(lookup.identifier)
else:
obj = get_by_slug(lookup.identifier)
return obj
2. 多種檔案來源
上傳檔案時,使用者可能提供本機路徑、URL,或直接的 Base64 字串:
class FileSource(BaseModel):
source: Literal["path", "url", "base64"]
value: str
@app.post("/upload/")
def upload(file: FileSource):
if file.source == "path":
# 讀取本機檔案
data = Path(file.value).read_bytes()
elif file.source == "url":
data = httpx.get(file.value).content
else:
data = base64.b64decode(file.value)
# 處理 data...
return {"size": len(data)}
3. 多種付款方式
電商平台常見「信用卡」與「行動支付」等不同結構的付款資訊:
class CreditCard(BaseModel):
method: Literal["credit_card"]
number: str
exp_month: int
exp_year: int
cvv: str
class MobilePay(BaseModel):
method: Literal["mobile_pay"]
provider: str
token: str
PaymentInfo = CreditCard | MobilePay
@app.post("/checkout/")
def checkout(info: PaymentInfo):
if isinstance(info, CreditCard):
process_credit_card(info)
else:
process_mobile_pay(info)
return {"status": "ok"}
以上範例皆展示了 Union 型別 為 API 帶來的彈性與可讀性。
總結
- Union 讓 FastAPI 能在單一端點接受多種型別或模型,提升 API 的彈性。
- 使用
|(Python 3.10+)或typing.Union,並注意 型別順序 及 辨識欄位,可避免驗證歧義。 - Discriminated unions(具辨識型別的 Union)是處理多模型情境的最佳方案,能自動產生正確的 OpenAPI
oneOf描述。 - 在實務開發中,常見的應用包括 ID/Slug 查詢、檔案來源切換、付款方式多樣化 等。
- 透過 自訂 validator、Field 描述、完整測試,可以把 Union 的彈性與安全性結合,寫出既友好又可靠的 API。
掌握了 Union 的驗證與序列化技巧後,你的 FastAPI 專案將能更靈活地應對各種輸入需求,同時維持型別安全與良好的自動文件。祝開發順利,快去試試看吧!