資料驗證是 API 開發中的關鍵需求之一,它負責確保從客戶端提交的資料是符合預期的,從而避免潛在的錯誤和安全問題。
有效的資料驗證可以在 API 接收到錯誤資料時,給出即時且友善的回應,提升系統的穩定性和使用者體驗。
Django Ninja 中,資料驗證的核心工具是 Pydantic。它提供了強大的驗證功能,不僅能對資料型別進行檢查,還能輕鬆實現自定義驗證。
本文將介紹如何在 Django Ninja 中使用 Pydantic 實作單一欄位的自定義驗證;下一篇則講述跨欄位的自定義驗證。
本文所有的程式碼變動,可參考這個 PR。
資料驗證很重要,而驗證失敗時,程式往往會拋出驗證錯誤。如何有效處理這些錯誤,則是「錯誤處理」要討論的範疇。
本章將探討這兩個密切相關的主題,共計 4 篇文章:
前兩篇,我們會學習如何實現靈活的資料驗證,以確保輸入資料符合預期,並在必要時拋出錯誤。
後兩篇,我們將討論如何處理 API 流程中可能出現的各種錯誤(不限於驗證錯誤),以提供更好的使用者體驗。
Django Ninja 的資料驗證與錯誤處理機制,相較 Django REST framework 更加複雜,因此我們得用完整的篇幅來介紹,幫助你清楚地理解它們。
我們會以上一篇文章中新建立的 API——新增使用者——為例。
繼續改善它,加上自定義驗證,讓客戶端傳來的資料更可靠。
不過我要先做一些錯誤修正,修正後的程式碼如下:
@router.post('/users/', summary='新增使用者', response={201: dict})
def create_user(...) -> tuple[int, dict]:
"""
新增使用者
"""
user = User(
username=payload.username,
email=payload.email,
bio=payload.bio,
)
# 使用 set_password 方法加密密碼
user.set_password(raw_password=payload.password)
user.save()
return 201, {'id': user.id, 'username': user.username}
主要改正了這兩處:
router
裝飾器新增了response={201: dict}
參數。本來沒有定義,實際使用這個 API 時會出現錯誤。因為預設只有 200 回應,想要 200 以外的回應,要透過response
參數聲明才行。set_password
方法對用戶輸入的密碼進行加密。這是 Django 內建的功能,防止密碼直接儲存在 db 中。密碼不能明文存儲,無疑是現代開發的 ABC。修正結束,我們正式進入主題。
既然是驗證,主要當然是跟來自客戶端的請求有關——驗證請求內容。
Django Ninja 中,每個 API 可以透過定義 Schema,來描述 API 所接收的資料結構。這些 Schema 基於 Pydantic,能自動對請求中的資料進行驗證。
Schema 中的 type hints 可以驗證資料型別,這是最基本的驗證。
前一篇提到的 Pydantic Field,則可以對資料的長度、範圍等特性進行驗證。這部分在後面會示範。
這些都是偏「形式上」的驗證,而本文將聚焦於更複雜的「自定義驗證」——基於一定的規則。
以「新增使用者」為例,request body 接收username
、email
、password
和bio
等欄位。透過我們定義的 Schema,能完成最基本的資料型別驗證。
class CreateUserRequest(Schema):
username: str
email: str
password: str
bio: str | None = None
如上一篇所述,只有bio
欄位是可選的,其餘則為必填——缺少就會得到 422 回應。所以 Schema 同時也驗證了資料的「存在性」。
目前看起來還不錯!但我們並不就此滿足。
我們要求使用者在設定密碼時,遵守以下兩個規則:
這些規則有助於提高帳號的安全性,防止用戶設定過於簡單的密碼。
考慮到教學目的,我沒有讓規則過於複雜。這兩條規則都有其特定的教學意義:
@field_validator
裝飾器,自行定義欄位的驗證規則。根據需求,我們可以先利用 Pydantic 的Field
來設定最小長度限制:
password: str = Field(min_length=8, examples=['password123'])
如上,我們只需要新增一個min_length=8
參數即可。
至於「必須包含數字」的驗證,則要用@field_validator
裝飾器來實作。
在 Pydantic v1 中,這個裝飾器的名稱是validator
,v2 才改為field_validator
。
Pydantic 從 v1 到 v2,有許多 breaking change,比如之前提過的example
參數變成examples
,即是一例。這部分值得留意。
以下是修改後的 Schema,我們只關注field_validator
部分:
class CreateUserRequest(Schema):
...
password: str
...
@field_validator('password')
@classmethod
def validate_password_contains_number(cls, v: str) -> str:
"""
驗證密碼至少包含一個數字
"""
if not re.search(r'\d', v):
raise ValueError('密碼必須包含至少一個數字')
return v
field_validator
裝飾器必須使用參數,合法值是欄位名稱,如password
。@field_validator('欄位1', '欄位2', ...)
,你甚至可以直接寫成@field_validator('*')
——套用到全部欄位。validate_<欄位名>
的命名模式,有很大的不同。v
,而 Django REST framework 則是value
。cls
。特別的是,你可以省略@classmethod
裝飾器,因為 Pydantic 已經在內部處理了。
@classmethod
,我們從善如流。@classmethod
裝飾器,它的位置必須最靠近驗證方法。想不到吧?短短幾行,竟然有這麼多看點!
測試密碼長度不足的情況,結果為:
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"payload",
"password"
],
"msg": "String should have at least 8 characters",
"ctx": {
"min_length": 8
}
},
{
"type": "string_too_short",
"loc": [
"body",
"payload",
"confirm_password"
],
"msg": "String should have at least 8 characters",
"ctx": {
"min_length": 8
}
}
]
}
這是 Field 檢查時自行拋出的錯誤,回應狀態碼為 422。
接下來,測試密碼未包含數字的情況:
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload",
"password"
],
"msg": "Value error, 密碼必須包含至少一個數字",
"ctx": {
"error": "密碼必須包含至少一個數字"
}
}
]
}
這算是由我們「半自定義」的錯誤類回應,因為結構仍是 Django Ninja 決定,但錯誤訊息部分則是我們自己定義的。
對於錯誤回應的自定義還可以更靈活,不過這是下下篇「錯誤處理(上)HttpError 與自定義 HTTP 回應」的主題,到時再來詳細討論。
這一篇,我們學習了如何透過 Pydantic,對單一欄位進行資料驗證,實作了密碼強度檢查規則。
下一篇,我們要繼續這個主題,實現更複雜的跨欄位驗證。
本文同步發表於我的部落格——Code and Me
在這個問題,因為剛好使用了正則來檢查密碼,而 pydantic 剛好有一個欄位可以幫忙檢查字串是否符合正則
class CreateUserRequest(Schema):
username: str = Field(examples=['Alice'])
email: str = Field(examples=['alice@example.com'])
password: str = Field(
description='密碼必須至少8個字,且至少包含1個數字',
min_length=8,
examples=['password123'],
pattern=r'\d', # 加這個
)
confirm_password: str = Field(min_length=8, examples=['password123'])
bio: str | None = Field(default=None, examples=['Hello, I am Alice.'])
當然複雜的正則不是人看的,所以我自己的話通常會加在描述說明這個的限制
然而用這樣寫法有個缺點,這樣的錯誤被 pydantic 捕獲會得到以下回應
{
"detail": [
{
"type": "string_pattern_mismatch",
"loc": [
"body",
"payload",
"password"
],
"msg": "String should match pattern '\\d'",
"ctx": {
"pattern": "\\d"
}
}
]
}
以前端角度來說,即使讀取了 msg 欄位依然不好理解,在這情況使用 field_validator
來自定義錯誤訊息就能得到更好理解的結果(我使用了 Day23 的 commit),得到的輸出長這樣
{
"detail": "密碼必須包含至少一個數字"
}
確實好理解了許多,但如果此時你的 魔鬼上司 突然說一句,阿我丟這個密碼長度不夠的請求
{
"username": "Claire",
"email": "alice2@example.com",
"password": "pass",
"confirm_password": "password",
"bio": "Hello, I am Alice."
}
得到的錯誤訊息長這樣
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"payload",
"password"
],
"msg": "String should have at least 8 characters",
"ctx": {
"min_length": 8
}
}
]
}
恩亨,這是個 pydantic 自動生成的錯誤訊息,且讀 msg 確實也知道是密碼長度不夠了,但如果前端說需要一個中文錯誤訊息讓使用者知道,該如何做呢?
如果你的欄位很多,且很多需要檢查數字大小於多少,字串長度大小於多少的訊息,一個一個用 field_validator
寫顯然就浪費了使用 pydantic 的優勢了,這種情況下該如何處理呢?
還是統一回個格式不對,然後把所有格式規則丟給前端顯示然後讓使用者自己判斷
另外剛好這篇有寫到對密碼加密儲存到資料庫的行為,對於這些有安全性問題的字串,在 pydantic 可以考慮使用 SecretStr
來儲存
這樣 print 出這個物件或該屬性(或者任何要用這個物件讀取這個屬性的值)的時候,這些字串都會被上 * 來遮蔽真實值,如果 log 有列印這些值可能就要小心 log 印出了人家的密碼
我後來想了一下這個問題,應該是前端來看API文件,前端可以自己擋下這些請求並顯示對應的錯誤文字,當然硬是發送就會得到後端依然會得到422錯誤
如果真的要讓後端讓那些 pydantic 預設的錯誤訊息也能客制化,我不確定有什更好或更簡單的方法能做到
但如果前端說需要一個中文錯誤訊息讓使用者知道,該如何做呢?
恩恩,想自定義錯誤訊息,當然就不能再透過 Field 來驗證
如果你的欄位很多,且很多需要檢查數字大小於多少,字串長度大小於多少的訊息,一個一個用 field_validator 寫顯然就浪費了使用 pydantic 的優勢了,這種情況下該如何處理呢?
我覺得還好呀,考慮到字串和數字,就定義兩個 field_validator,一個專門驗字串長度,一個專門驗數字大小
反正一個 field_validator 可以套用到複數個欄位,這樣應該不算很麻煩?
另外一個小問題
目前呼叫新增使用者時候,一個資料如下
{
"username": "Alice",
"email": "alice@example.com",
"password": "password123",
"confirm_password": "password123",
"bio": "Hello, I am Alice."
}
當再次呼叫並故意修改 email 時候就會出現500錯誤(Day 23 的 commit 進度)
{
"username": "Alice",
"email": "bob@example.com",
"password": "password123",
"confirm_password": "password123",
"bio": "Hello, I am Alice."
}
真的耶,哈哈哈!
看錯誤訊息,其實原因很簡單,那就是 username 欄位也是 UNIQUE 的🤣
django.db.utils.IntegrityError: UNIQUE constraint failed: user_user.username
專案中的 User 模型,是繼承 Django 內建模型類別——AbstractUser,所以沒看原始碼或沒出過錯,還真不清楚它的 username 有設定 UNIQUE
class User(AbstractUser):
...
我工作中都不是用內建的 AbstractUser,所以還真不清楚XD
所以理論上,我們這專案應該要同時覆寫 username,把它改成「非 UNIQUE」,不然也太擾人了!哈哈哈