2024 iThome 鐵人賽2024 iThome 鐵人賽

上一篇我們講完了單一欄位的自定義驗證,這篇則要來討論跨欄位之間的驗證。

跨欄位驗證同樣是 API 開發中十分常見的需求,例如註冊帳號時,要保證「密碼」與「確認密碼」兩個欄位內容相同;選擇日期期間時,開始日期不能晚於結束日期等。

這些驗證場景無法透過單一欄位驗證實現,因為它們需要同時檢查多個欄位之間的邏輯關聯,來確保整體資料的一致性和正確性

本文將介紹如何透過 Pydantic 來實現跨欄位驗證需求——以「確認密碼」為例,展示這個功能的實際應用。

本文所有的程式碼改動,可參考這個 PR


跨欄位驗證與關注點分離

其實,無論是單一欄位還是跨欄位的自定義驗證,都不一定要藉由 Pydantic 來完成。

理論上,資料驗證可以直接在 view 函式中進行,例如取出輸入的欄位值,手動驗證它的合法性。跨欄位驗證也是如此。

然而,這是一種方便但「粗糙」的做法——只適合用在驗證邏輯非常單純的情況。

透過 Pydantic 進行資料驗證,則能夠帶來一個明顯的好處:關注點分離

關注點分離

關注點分離(Separation of Concerns)是一種設計原則。主張將程式中不同功能的職責劃分到獨立的模組或層次中。

每個模組主要專注於一個具體的方向或目標,從而避免把多個不同的功能耦合在一起。這樣的劃分可以讓程式更易於測試、維護和擴充。

依照關注點分離,資料驗證的邏輯應該集中在 Schema,而不是在 view 函式中進行。

如此一來,view 可以專注於處理核心業務邏輯,而將資料驗證交由專門的元件負責。

透過 Pydantic 的驗證機制,我們可以實現關注點分離,讓資料驗證與業務邏輯分開,這不僅提升了程式碼的結構,也讓開發流程更加清楚、穩定。


新需求:確認密碼

我們要實作一個非常簡單,但足以充分說明跨欄位驗證價值的功能:確認密碼

先回顧上一篇結束時,「新增使用者」API 的請求 Schema 內容:

1
2
3
4
5
6
7
class CreateUserRequest(Schema):
username: str = Field(examples=['Alice'])
email: str = Field(examples=['alice@example.com'])
password: str = Field(min_length=8, examples=['password123'])
bio: str | None = Field(
default=None, examples=['Hello, I am Alice.'])
...

這個 Schema 的設計,顯然有所不足

因為用戶註冊時,密碼通常需要輸入兩次,第二次的作用是「確認」——重要的事情說兩次嘛!

所以,我們要新增一個confirm_password欄位,和password進行跨欄位驗證:確認兩者內容相同

儘管其中的驗證邏輯非常簡單,但這正是跨欄位驗證的絕佳舞台


實作跨欄位驗證:使用 model_validator

Pydantic v2 引入了@model_validator裝飾器來處理跨欄位驗證,這是對 Pydantic v1 中@root_validator的改進和替代。

這裡的 model,指的是 Pydantic 的 BaseModel——也就是我們的 Schema,而不是 Django 的 Models

我們透過@model_validator來強化「新增使用者」API,加上「確認密碼」功能。

直接看修改後的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
class CreateUserRequest(Schema):
...
password: str = Field(min_length=8, examples=['password123'])
confirm_password: str = Field(
min_length=8, examples=['password123'])
...

@model_validator(mode='after')
def check_passwords_match(self) -> Self:
if self.password != self.confirm_password:
raise ValueError('密碼和確認密碼必須相同')
return self

重點解析

  • 新增了一個confirm_password欄位。
  • 使用@model_validator(mode='after')裝飾器來定義跨欄位的驗證方法。
    • mode總共有三種:before、after 和 wrap。其中的細節頗多,限於篇幅,本文無法展開(可能等番外篇再行補充)。
    • 你只要知道,大部分時候是用 after 模式,此時的驗證方法是一個「實例方法」,self參數代表 Schema 實例本身(從 input 資料初始化而來)。
  • 驗證方法check_passwords_match比較passwordconfirm_password欄位,如果欄位內容不相同,則拋出ValueError
    • 如前所述,儘管邏輯非常簡單,但它確實現了兩個欄位之間的驗證。
  • 跨欄位驗證在所有單一欄位驗證完成後才會執行。

關注點分離的實際應用

你會發現,在這次新增「確認密碼」的功能實作中,view 函式完全沒有變動!——這正是關注點分離原則的體現。

相較於直接在 view 函式中實作驗證邏輯(需要同時修改 view 和 Schema),這樣的實作方式無疑更加乾淨、解耦。


驗證失敗時的 HTTP 回應

最後,讓我們來看看,當資料驗證失敗時,會得到什麼樣的 HTTP 回應。

前兩項是上一篇已經提過的,這裡再次列出,以便相互對照及複習。

違反密碼長度限制

回應結果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"payload",
"password"
],
"msg": "String should have at least 8 characters",
"ctx": {
"min_length": 8
}
}
]
}

這是 Django Ninja 捕捉 Pydantic 驗證錯誤所給出的「系統級」回應,狀態碼為 422。

違反「必須包含數字」規則

輸入的密碼中沒有數字,回應結果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload",
"password"
],
"msg": "Value error, 密碼必須包含至少一個數字",
"ctx": {
"error": "密碼必須包含至少一個數字"
}
}
]
}

好像差不多耶?沒錯,因為這也是 Django Ninja 的自動回應格式——除了錯誤訊息中包含我們自定義的內容。

拋出 ValueError 的回應

但事實上,這是因為我們在驗證方法中拋出的是ValueError,所以 Django Ninja 會自動幫你處理。

類似的回應也發生在確認密碼不一致時:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload"
],
"msg": "Value error, 密碼和確認密碼必須相同",
"ctx": {
"error": "密碼和確認密碼必須相同"
}
}
]
}

那如果拋出的別種錯誤,比如 Django 的ValidationError,甚至是我們自己定義的錯誤,Django Ninja 還會自動處理嗎?

答案是:不會

你會得到「500 Internal Server Error」——這將是我們下下篇的重點。


小結與下一步

本文中,我們介紹了如何透過@model_validator來實現跨欄位驗證的需求,同時落實關注點分離原則。

學習完這兩篇以後,你對 Django Ninja 資料驗證的了解,已經超越大部分人。

接下來,我們將深入探討,當資料驗證失敗時,要如何優雅地處理錯誤——並回應,以提升 API 的使用體驗。