本文 AI 產出,尚未審核

FastAPI 教學:資料驗證與轉換 – Union 型別驗證


簡介

FastAPI 中,資料驗證與序列化是由 Pydantic 完成的。當 API 接收的參數可能有多種形態(例如同時接受 intstr、或是兩種不同的模型)時,使用 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" 會被先當成字串,導致失去數值轉型的機會。 將較嚴格或較具體的型別放前(如 intstr 前)。
模糊的結構 兩個模型欄位幾乎相同,缺少辨識欄位時,Pydantic 可能無法正確分派。 使用 discriminated union,在每個模型加入唯一的 Literal 欄位作為類型標記。
OpenAPI 文件不完整 若 Union 包含自訂型別或 Any,自動產生的文件可能缺少說明。 為每個模型加入 descriptionexample,或使用 ResponseJSONResponse 手動描述。
預設值與 Optional 混用 `field: int None = Nonefield: Optional[int] = None` 效果相同,但在 Swagger UI 中顯示會有所差異。
驗證錯誤訊息不友好 複雜 Union 錯誤時,Pydantic 只回傳「value is not a valid union」的訊息。 使用 自訂 validatorroot_validator,提供更具體的錯誤資訊。

最佳實踐

  1. 明確的辨識欄位:若 Union 包含多個模型,務必加入 Literal 欄位作為辨識標記。
  2. 先放具體型別:在 int|strfloat|Decimal 等情況下,把較具體的型別寫在前面。
  3. 搭配 Field 提供範例:在模型欄位加上 example=,讓 Swagger UI 更直觀。
  4. 測試每個分支:使用 TestClient 撰寫測試,確保每個 Union 成員都能正確驗證。
  5. 避免過度寬鬆:雖然 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 查詢、檔案來源切換、付款方式多樣化 等。
  • 透過 自訂 validatorField 描述完整測試,可以把 Union 的彈性與安全性結合,寫出既友好又可靠的 API。

掌握了 Union 的驗證與序列化技巧後,你的 FastAPI 專案將能更靈活地應對各種輸入需求,同時維持型別安全與良好的自動文件。祝開發順利,快去試試看吧!