本文 AI 產出,尚未審核

FastAPI 課程 – Pydantic 模型(Request / Response Models)

主題:Response Model 驗證


簡介

在使用 FastAPI 開發 Web API 時,最常被提及的優勢之一就是自動產生的資料驗證與文件(OpenAPI)說明。雖然大多數開發者在實作 Request Model 時已經相當熟悉,但 Response Model 的驗證同樣重要,因為它直接關係到:

  1. API 的正確性:保證回傳給前端或第三方服務的資料結構與型別符合預期。
  2. 安全性:避免不小心把內部敏感欄位洩漏給使用者。
  3. 文件一致性:FastAPI 會根據 Response Model 自動產生 Swagger UI,確保文件與實際回傳保持同步。

本篇文章將深入探討 Response Model 的概念、使用方式與常見陷阱,並提供多個實作範例,協助從初學者到中階開發者在專案中正確運用驗證機制。


核心概念

1. 為什麼需要 Response Model?

FastAPI 內部會在路由函式返回值前,先將資料 序列化(serialization)並 驗證(validation)一次。這個過程使用的就是 Pydantic 模型。若回傳的資料不符合模型定義,FastAPI 會拋出例外,讓開發者即時發現錯誤,而不是讓錯誤的 JSON 靜靜流向前端。

重點:Response Model 不僅是「文件」的來源,更是 執行時的防護網

2. 基本語法

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    is_active: bool = True   # 預設值

@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
    # 假設從資料庫取得的資料是一個 dict
    db_user = {"id": user_id, "username": "alice", "email": "alice@example.com"}
    return db_user          # FastAPI 會自動套用 UserResponse 進行驗證
  • response_model=UserResponse 告訴 FastAPI 回傳資料必須符合 UserResponse 的結構。
  • db_user 少了 is_active 欄位,Pydantic 會自動補上預設值 True

3. 只回傳子集 – response_model_exclude_unset

有時候資料庫模型包含很多欄位(例如密碼、內部狀態),但 API 僅需要回傳公開資訊。可以透過 excludeinclude 參數控制序列化行為。

class UserInDB(BaseModel):
    id: int
    username: str
    email: str
    hashed_password: str
    is_active: bool

class UserPublic(BaseModel):
    id: int
    username: str
    email: str

@app.get("/users/{user_id}", response_model=UserPublic)
async def get_user(user_id: int):
    user = UserInDB(
        id=user_id,
        username="bob",
        email="bob@example.com",
        hashed_password="s3cr3t",
        is_active=False,
    )
    # 直接回傳 UserInDB,FastAPI 會自動過濾掉未在 UserPublic 中的欄位
    return user

4. 動態欄位 – response_model_include / response_model_exclude

有時候根據使用者權限或查詢參數,需要動態決定回傳哪些欄位。FastAPI 允許在路由裝飾器中使用 response_model_includeresponse_model_exclude

@app.get(
    "/items/{item_id}",
    response_model=ItemResponse,
    response_model_exclude={"internal_note"},
)
async def get_item(item_id: int):
    item = {
        "id": item_id,
        "name": "Magic Wand",
        "price": 99.9,
        "internal_note": "Only for admin"
    }
    return item

5. 使用 list[Model]Dict[str, Model]

當 API 回傳集合時,直接在 response_model 裡使用 Python 的型別提示即可。

from typing import List

class Product(BaseModel):
    sku: str
    name: str
    price: float

@app.get("/products", response_model=List[Product])
async def list_products():
    return [
        {"sku": "A001", "name": "Notebook", "price": 3.5},
        {"sku": "B002", "name": "Pen", "price": 1.2},
    ]

6. 自訂 JSON Encoder – json_encoders

如果模型中包含非 JSON 原生類型(如 datetime, UUID),可以在模型內部設定 json_encoders,讓 Pydantic 了解如何序列化。

from datetime import datetime
from uuid import UUID
from pydantic import BaseModel

class OrderResponse(BaseModel):
    order_id: UUID
    created_at: datetime

    class Config:
        json_encoders = {
            datetime: lambda v: v.isoformat(),
            UUID: lambda v: str(v),
        }

程式碼範例

以下提供 5 個實用範例,從最簡單的回傳模型到進階的條件過濾與自訂序列化,幫助你快速上手。

範例 1:最基本的 Response Model

# file: main.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class SimpleResponse(BaseModel):
    message: str
    status: int = 200   # 預設值

@app.get("/ping", response_model=SimpleResponse)
async def ping():
    return {"message": "pong"}   # FastAPI 會自動補上 status=200

說明

  • 只回傳一個簡單的字典,Pydantic 會自動填補 status 欄位的預設值。
  • 若回傳缺少 message,FastAPI 會回傳 500 錯誤,提醒開發者模型不匹配。

範例 2:隱藏敏感欄位(使用兩個模型)

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class UserDB(BaseModel):
    id: int
    username: str
    email: str
    hashed_password: str   # 敏感資訊

class UserOut(BaseModel):
    id: int
    username: str
    email: str

@app.get("/users/{uid}", response_model=UserOut)
async def get_user(uid: int):
    # 模擬從資料庫取得完整資料
    db_user = UserDB(
        id=uid,
        username="charlie",
        email="charlie@example.com",
        hashed_password="super_secret_hash"
    )
    return db_user   # FastAPI 只會回傳 UserOut 定義的欄位

說明

  • UserDB 包含密碼欄位,但 response_model=UserOut 只會序列化公開欄位。
  • 這樣的設計避免了 資訊外洩,同時保持程式碼的可讀性。

範例 3:條件排除欄位(依使用者權限)

from fastapi import FastAPI, Depends, HTTPException, status
from pydantic import BaseModel
from typing import Set

app = FastAPI()

class Article(BaseModel):
    id: int
    title: str
    content: str
    author_id: int
    is_private: bool = False

def get_current_user():
    # 假設回傳使用者 ID 與角色集合
    return {"user_id": 42, "roles": {"reader"}}

@app.get(
    "/articles/{article_id}",
    response_model=Article,
    response_model_exclude={"author_id"}   # 預設排除作者 ID
)
async def read_article(article_id: int, user=Depends(get_current_user)):
    article = Article(
        id=article_id,
        title="FastAPI 入門",
        content="Lorem ipsum ...",
        author_id=1,
        is_private=False,
    )
    # 若使用者是作者或具有 admin 權限,回傳完整資料
    if user["user_id"] == article.author_id or "admin" in user["roles"]:
        return article   # 此時不會被排除 author_id
    # 否則只回傳已排除的欄位
    return article

說明

  • 使用 response_model_exclude 排除 author_id,除非符合特定條件再返回完整資訊。
  • 透過 Depends 取得當前使用者,展示 動態欄位控制 的實作方式。

範例 4:回傳列表與分頁資訊

from fastapi import FastAPI, Query
from pydantic import BaseModel
from typing import List

app = FastAPI()

class Product(BaseModel):
    sku: str
    name: str
    price: float

class PaginatedResponse(BaseModel):
    total: int
    page: int
    size: int
    items: List[Product]

@app.get("/store/products", response_model=PaginatedResponse)
async def list_products(
    page: int = Query(1, ge=1),
    size: int = Query(10, ge=1, le=100)
):
    # 假設有 250 筆商品
    total_items = 250
    start = (page - 1) * size
    end = start + size
    sample_items = [
        {"sku": f"P{i:04d}", "name": f"商品{i}", "price": round(i * 1.23, 2)}
        for i in range(start + 1, min(end + 1, total_items + 1))
    ]
    return {
        "total": total_items,
        "page": page,
        "size": size,
        "items": sample_items,
    }

說明

  • PaginatedResponse 包含 items 欄位,其型別是 List[Product],示範 巢狀模型 的使用。
  • 這種寫法讓 Swagger UI 自動產生清楚的回傳結構,前端開發者可以直接對照。

範例 5:自訂 JSON 編碼(datetime & UUID)

from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime
from uuid import uuid4, UUID
from typing import List

app = FastAPI()

class Event(BaseModel):
    id: UUID
    name: str
    start_time: datetime

    class Config:
        json_encoders = {
            datetime: lambda v: v.strftime("%Y-%m-%d %H:%M:%S"),
            UUID: lambda v: str(v),
        }

@app.get("/events", response_model=List[Event])
async def get_events():
    return [
        Event(id=uuid4(), name="開幕式", start_time=datetime.utcnow()),
        Event(id=uuid4(), name="技術講座", start_time=datetime.utcnow()),
    ]

說明

  • json_encodersdatetime 以自訂格式輸出,UUID 直接轉成字串。
  • 若不設定編碼器,FastAPI 仍會自動處理,但輸出形式會是 ISO8601,這裡示範如何客製化

常見陷阱與最佳實踐

陷阱 可能的後果 解決方式 / 建議
回傳的物件不是 Pydantic 模型 FastAPI 仍會嘗試套用 response_model,但若結構不符會拋 ValidationError,導致 500 錯誤。 確保返回值是 dictlistPydantic 實例,或使用 jsonable_encoder 手動轉換。
使用 response_model_exclude_unset=True 卻忘記預設值 欄位在模型中有預設值,但因未在回傳資料中提供,會被排除,前端收到缺欄位的 JSON。 若欄位必須 always present,直接在模型裡設 default,或在回傳前手動加入。
過度使用 Any 失去型別檢查與自動文件產生的好處。 盡量使用具體型別,若真的需要彈性,可使用 UnionLiteral
忘記排除敏感欄位 密碼、金鑰等資訊可能外洩。 使用 兩層模型(DB Model + Public Model)或 response_model_exclude
大型回傳資料未分頁 造成效能瓶頸與記憶體問題。 加入分頁、限制 size、或使用 StreamingResponse
自訂 Encoder 沒有考慮 None None 會被編碼成字串 "None",造成前端解析錯誤。 在 encoder 中加入 if v is None: return None 的檢查。

最佳實踐

  1. 模型分層

    • ModelInDB(完整資料)
    • ModelCreate / ModelUpdate(請求資料)
    • ModelOut(回傳資料)
  2. 使用 jsonable_encoder:在需要手動處理非 JSON 類型時,先呼叫 jsonable_encoder,再回傳。

    from fastapi.encoders import jsonable_encoder
    
    @app.post("/items", response_model=ItemOut)
    async def create_item(item: ItemCreate):
        db_item = save_to_db(item)
        return jsonable_encoder(db_item)
    
  3. 統一錯誤回傳結構:定義一個全域的 ErrorResponse,讓前端可以預期錯誤格式。

    class ErrorResponse(BaseModel):
        detail: str
        code: int
    
    @app.exception_handler(ValidationError)
    async def validation_exception_handler(request, exc):
        return JSONResponse(
            status_code=422,
            content=ErrorResponse(detail=str(exc), code=1001).dict(),
        )
    
  4. 開啟 response_model_exclude_none=True:自動剔除值為 None 的欄位,減少不必要的欄位傳輸。

    @app.get("/profile", response_model=UserOut, response_model_exclude_none=True)
    async def get_profile():
        ...
    
  5. 測試 Response Model:使用 TestClient 直接驗證回傳結構,確保模型不會在未來的程式碼變更中被破壞。

    from fastapi.testclient import TestClient
    
    client = TestClient(app)
    
    def test_get_user():
        r = client.get("/users/1")
        assert r.status_code == 200
        data = r.json()
        assert "hashed_password" not in data
        assert data["id"] == 1
    

實際應用場景

場景 為何需要 Response Model 驗證 示例
金融 API(返回交易明細) 必須保證金額、時間戳記、交易狀態的正確型別,避免金額誤差或時間格式錯誤。 使用 Decimaldatetime,並自訂 json_encoders
社交平台(使用者資料) 隱私保護:不允許回傳密碼、email 驗證碼等敏感欄位。 兩層模型 + response_model_exclude
電商系統(商品列表) 需要分頁、價格格式統一、庫存狀態可選。 PaginatedResponse 搭配 List[Product],使用 response_model_exclude_unset
IoT 後端(設備狀態) 大量時間序列資料,必須確保每筆資料都有正確的 timestampvalue 型別。 使用 List[Reading],並在模型內設定 json_encodersdatetime 以 ISO8601 輸出。
多語系內容平台(文章內容) 回傳時根據請求語系過濾不同欄位(如 title_en, title_zh),且必須保證欄位一致性。 在路由中使用 response_model_include 動態決定回傳欄位。

總結

  • Response Model 不僅是文件生成的依據,更是 資料安全與一致性的最後防線
  • 透過 Pydantic 的驗證機制,FastAPI 能在回傳前自動校正、補齊預設值、排除敏感欄位,讓 API 更可靠。
  • 掌握 response_model, response_model_include/exclude, response_model_exclude_none, 以及自訂 json_encoders,可以應對絕大多數實務需求。
  • 記得在專案中採用 模型分層、統一錯誤回傳、測試驗證 等最佳實踐,避免常見陷阱,提升開發與維運效率。

希望本篇文章能幫助你在 FastAPI 專案裡建立安全、可維護且文件完整的 API 回傳機制。祝開發順利,持續寫出高品質的服務! 🚀