iT邦幫忙

2024 iThome 鐵人賽

DAY 19
2
Python

Django 忍法帖——Django Ninja 入門指南系列 第 19

卷 19:資料驗證(上)Pydantic 單一欄位驗證

  • 分享至 

  • xImage
  •  

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

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

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

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

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


第五章總論

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

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

  • 卷 19:資料驗證(上)Pydantic 單一欄位驗證(本文)
  • 卷 20:資料驗證(下)Pydantic 跨欄位驗證
  • 卷 21:錯誤處理(上)HttpError 與自定義 HTTP 回應
  • 卷 22:錯誤處理(下)全域錯誤處理——使用 Exception Handlers

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

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

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


API 修正

我們會以上一篇文章中新建立的 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}

主要改正了這兩處:

  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,能完成最基本的資料型別驗證

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來設定最小長度限制

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部分:

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裝飾器,它的位置必須最靠近驗證方法。

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


實際測試

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

{
    "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


上一篇
卷 18:API 文件(下)Pydantic Field 設定範例與預設值
下一篇
卷 20:資料驗證(下)Pydantic 跨欄位驗證
系列文
Django 忍法帖——Django Ninja 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
gbaian10
iT邦新手 5 級 ‧ 2024-10-01 19:16:37

在這個問題,因為剛好使用了正則來檢查密碼,而 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 的優勢了,這種情況下該如何處理呢?

還是統一回個格式不對,然後把所有格式規則丟給前端顯示然後讓使用者自己判斷

gbaian10 iT邦新手 5 級 ‧ 2024-10-01 19:28:00 檢舉

另外剛好這篇有寫到對密碼加密儲存到資料庫的行為,對於這些有安全性問題的字串,在 pydantic 可以考慮使用 SecretStr 來儲存

這樣 print 出這個物件或該屬性(或者任何要用這個物件讀取這個屬性的值)的時候,這些字串都會被上 * 來遮蔽真實值,如果 log 有列印這些值可能就要小心 log 印出了人家的密碼

gbaian10 iT邦新手 5 級 ‧ 2024-10-01 22:08:56 檢舉

我後來想了一下這個問題,應該是前端來看API文件,前端可以自己擋下這些請求並顯示對應的錯誤文字,當然硬是發送就會得到後端依然會得到422錯誤

如果真的要讓後端讓那些 pydantic 預設的錯誤訊息也能客制化,我不確定有什更好或更簡單的方法能做到

Kyo Huang iT邦新手 4 級 ‧ 2024-10-02 16:57:35 檢舉

但如果前端說需要一個中文錯誤訊息讓使用者知道,該如何做呢?

恩恩,想自定義錯誤訊息,當然就不能再透過 Field 來驗證

如果你的欄位很多,且很多需要檢查數字大小於多少,字串長度大小於多少的訊息,一個一個用 field_validator 寫顯然就浪費了使用 pydantic 的優勢了,這種情況下該如何處理呢?

我覺得還好呀,考慮到字串和數字,就定義兩個 field_validator,一個專門驗字串長度,一個專門驗數字大小

反正一個 field_validator 可以套用到複數個欄位,這樣應該不算很麻煩?

0
gbaian10
iT邦新手 5 級 ‧ 2024-10-01 19:31:51

另外一個小問題

目前呼叫新增使用者時候,一個資料如下

{
  "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."
}
Kyo Huang iT邦新手 4 級 ‧ 2024-10-02 17:02:39 檢舉

真的耶,哈哈哈!

看錯誤訊息,其實原因很簡單,那就是 username 欄位也是 UNIQUE 的🤣

django.db.utils.IntegrityError: UNIQUE constraint failed: user_user.username

專案中的 User 模型,是繼承 Django 內建模型類別——AbstractUser,所以沒看原始碼或沒出過錯,還真不清楚它的 username 有設定 UNIQUE

class User(AbstractUser):
    ...

我工作中都不是用內建的 AbstractUser,所以還真不清楚XD

Kyo Huang iT邦新手 4 級 ‧ 2024-10-02 17:10:40 檢舉

所以理論上,我們這專案應該要同時覆寫 username,把它改成「非 UNIQUE」,不然也太擾人了!哈哈哈

我要留言

立即登入留言