2024 iThome 鐵人賽2024 iThome 鐵人賽

這是 Django Ninja 系列教學的第 19 篇。進入第五章:資料驗證與錯誤處理

資料驗證是 API 開發中的關鍵需求之一,它負責確保從客戶端提交的資料是符合預期的,從而避免潛在的錯誤和安全問題。

有效的資料驗證可以在 API 接收到錯誤資料時,給出即時且友善的回應,提升系統的穩定性和使用者體驗

Django Ninja 中,資料驗證的核心工具是 Pydantic。它提供了強大的驗證功能,不僅能對資料型別進行檢查,還能輕鬆實現自定義驗證

本文將介紹如何在 Django Ninja 中使用 Pydantic 實作單一欄位的自定義驗證;下一篇則講述跨欄位的自定義驗證

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


第五章總論

資料驗證很重要,而驗證失敗時,程式往往會拋出驗證錯誤。如何有效處理這些錯誤,則是「錯誤處理」要討論的範疇。

本章將探討這兩個密切相關的主題,共計 4 篇文章:

前兩篇,我們會學習如何實現靈活的資料驗證,以確保輸入資料符合預期,並在必要時拋出錯誤。

後兩篇,我們將討論如何處理 API 流程中可能出現的各種錯誤(不限於驗證錯誤),以提供更好的使用者體驗。

Django Ninja 的資料驗證與錯誤處理機制,相較 Django REST framework 更加複雜,因此我們得用完整的篇幅來介紹,幫助你清楚地理解它們。


API 修正

我們會以上一篇文章中新建立的 API——新增使用者——為例。

繼續改善它,加上自定義驗證,讓客戶端傳來的資料更可靠。

不過我要先做一些錯誤修正,修正後的程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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}

主要改正了這兩處:

  1. router裝飾器新增response={201: dict}參數。本來沒有定義,實際使用這個 API 時會出現錯誤。因為預設只有 200 回應,想要 200 以外的回應,要透過response參數聲明才行。
  2. 使用set_password方法對用戶輸入的密碼進行加密。這是 Django 內建的功能,防止密碼直接儲存在 db 中。密碼不能明文存儲,無疑是現代開發的 ABC。

修正結束,我們正式進入主題。

不同「層次」的驗證

既然是驗證,主要當然是跟來自客戶端的請求有關——驗證請求內容。

Django Ninja 中,每個 API 可以透過定義 Schema,來描述 API 所接收的資料結構。這些 Schema 基於 Pydantic,能自動對請求中的資料進行驗證。

Schema 中的 type hints 可以驗證資料型別,這是最基本的驗證。

前一篇提到的 Pydantic Field,則可以對資料的長度、範圍等特性進行驗證。這部分在後面會示範。

這些都是偏「形式上」的驗證,而本文將聚焦於更複雜的「自定義驗證」——基於一定的規則

範例 API 的 Schema 現狀

以「新增使用者」為例,request body 接收usernameemailpasswordbio等欄位。透過我們定義的 Schema,能完成最基本的資料型別驗證

1
2
3
4
5
class CreateUserRequest(Schema):
username: str
email: str
password: str
bio: str | None = None

如上一篇所述,只有bio欄位是可選的,其餘則為必填——缺少就會得到 422 回應。所以 Schema 同時也驗證了資料的「存在性」。

目前看起來還不錯!但我們並不就此滿足。


新需求:密碼規則

我們要求使用者在設定密碼時,遵守以下兩個規則:

  1. 密碼長度至少 8 個字元。
  2. 必須包含至少一個數字。

這些規則有助於提高帳號的安全性,防止用戶設定過於簡單的密碼。

考慮到教學目的,我沒有讓規則過於複雜。這兩條規則都有其特定的教學意義

  1. 最小長度限制可以直接透過 Pydantic Field 實現,不必自行實作。
  2. 第二個規則是重頭戲,我們會使用 Pydantic 的@field_validator裝飾器,自行定義欄位的驗證規則。

實作密碼規則驗證:使用 field_validator

根據需求,我們可以先利用 Pydantic 的Field來設定最小長度限制

1
password: str = Field(min_length=8, examples=['password123'])

如上,我們只需要新增一個min_length=8參數即可。

至於「必須包含數字」的驗證,則要用@field_validator裝飾器來實作。

field_validator 裝飾器

在 Pydantic v1 中,這個裝飾器的名稱是validator,v2 才改為field_validator

Pydantic 從 v1 到 v2,有許多 breaking change,比如之前提過的example參數變成examples,即是一例。這部分值得留意。

以下是修改後的 Schema,我們只關注field_validator部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

重點解析

  1. field_validator裝飾器必須使用參數,合法值是欄位名稱,如password
  2. 雖然範例中沒有演示,但它可以套用在多個欄位
    1. 寫法為@field_validator('欄位1', '欄位2', ...),你甚至可以直接寫成@field_validator('*')——套用到全部欄位。
    2. 但請注意,這些欄位會執行同一個驗證邏輯,所以它們理論上是邏輯類似的欄位。
  3. 驗證方法的名稱可以自訂,你想怎麼命名都行,只要自己好懂即可。
    1. 因為 Pydantic 主要是看裝飾器上的欄位名稱。
    2. 這和 Django REST framework 的驗證方法是採用validate_<欄位名>的命名模式,有很大的不同
  4. Pydantic 驗證方法的參數名稱命名慣例是v,而 Django REST framework 則是value
  5. 慣例二:驗證方法在成功時會原封不動 return 輸入值;失敗時則會拋出錯誤。
  6. Pydantic 的驗證方法是一個「類別方法」,所以第一個參數是cls。特別的是,你可以省略@classmethod裝飾器,因為 Pydantic 已經在內部處理了。
    1. 不過官方文件仍建議你使用@classmethod,我們從善如流。
    2. 如果有聲明@classmethod裝飾器,它的位置必須最靠近驗證方法。

想不到吧?短短幾行,竟然有這麼多看點


實際測試

測試密碼長度不足的情況,結果為:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"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。

接下來,測試密碼未包含數字的情況:

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 決定,但錯誤訊息部分則是我們自己定義的。

對於錯誤回應的自定義還可以更靈活,不過這是下下篇「錯誤處理(上)HttpError 與自定義 HTTP 回應」的主題,到時再來詳細討論。


小結

這一篇,我們學習了如何透過 Pydantic,對單一欄位進行資料驗證,實作了密碼強度檢查規則。

下一篇,我們要繼續這個主題,實現更複雜的跨欄位驗證