在軟體開發中,錯誤處理是一個不容忽視——但常常被忽視——的環節。
不誇張地說,錯誤處理是一個「做得好沒人誇,做不好系統就慘兮兮」的議題。
沒關係,我們還是盡可能把自己做好。
Django Ninja 使用 Pydantic 進行資料驗證,失敗時,預設回應「422 Unprocessable Entity」。
然而,我們有時候需要回應「400 Bad Request」或別的狀態碼,以符合現實業務需求或團隊開發習慣。
總之,無論出於何種原因,我們想自訂錯誤訊息、格式,以及回應的狀態碼,而不使用 Django Ninja 預設的 422 回應——不得不說,這個制式回應的資訊有點多、結構有點複雜,因為它要兼容各種情況。
本文將介紹如何自定義錯誤處理與回應——使用 Django Ninja 內建的HttpError
。
所有的程式碼改動,可參考這個 PR。
上一篇我們提到,如果你在 Schema 的驗證方法中,拋出ValueError
錯誤,Django Ninja 將會自動捕捉並回應。
事實上,不止ValueError
,Django Ninja 還會替你處理以下這幾種錯誤:
pydantic.ValidationError
,來自 Pydantic 的驗證錯誤,這是為何當 Schema 欄位有問題時,我們會直接收到 422 回應。ninja.errors.ValidationError
,這些錯誤同樣會返回 422。ninja.errors.HttpError
:這是本文的重點,下面會介紹。這些都是 Django Ninja 會自動捕捉的錯誤,但不是每一種都給出制式的 422 回應——第三種就不是。
同樣以「新增使用者」API 為例,我們要實現一個新需求:確認密碼不一致時,要回應「400 Bad Request」而不是 422。
怎麼做最簡單?
答:使用 Django Ninja 的HttpError
。
以下是 Schema 的程式碼改動,只改了兩行!
...
from ninja.errors import HttpError # 第一行
class CreateUserRequest(Schema):
...
@model_validator(mode='after')
def check_passwords_match(self) -> Self:
if self.password != self.confirm_password:
raise HttpError(400, '密碼和確認密碼必須相同') # 第二行
return self
沒錯,就這麼簡單!
只是把驗證方法拋出的錯誤,從ValueError
替換為HttpError
即可。
值得留意的是,HttpError
實例的初始化,需要兩個參數,第一個是 HTTP 狀態碼,第二個才是錯誤訊息。
看看同樣的驗證失敗,回應有何不同:
// 400 Bad Request
{
"detail": "密碼和確認密碼必須相同"
}
變成我們熟悉的格式——只有錯誤訊息。
對比上一篇,我們拋出ValueError
時的回應:
// 422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload",
"confirm_password"
],
"msg": "Value error, 密碼和確認密碼必須相同",
"ctx": {
"error": "密碼和確認密碼必須相同"
}
}
]
}
差距很大吧?
直接在 Schema 的驗證方法中拋出HttpError
,是一種便捷的方式,因為它能夠簡化回應處理。
我們無需額外捕捉錯誤或手動指定回應格式。驗證失敗時,API 會直接回應我們定義的狀態碼和錯誤訊息,既簡單又方便。
然而,這麼做其實並不妥當,主要有幾個問題,比如降低可測試性、限制回應的靈活性等等。但其中最關鍵的,還是是我們上一篇提到的——「關注點分離」。
這個做法違反了「關注點分離」原則。
驗證邏輯的職責是檢查資料的正確性,而回應應該由 view 函式負責。
將回應邏輯混入驗證過程中,會讓驗證和回應這兩個本應獨立的部分耦合在一起,導致職責混亂,不利於程式碼維護。
因此,雖然在驗證方法內使用HttpError
,看似能夠方便地實現需求,但從架構設計的角度考慮,將回應處理放在 view 函式中,才是一個更合理的選擇。
別擔心,下一篇我們會換個做法,但本文的主角還是HttpError
。
相比於在 Schema 中使用HttpError
,把它放在 view 函式裡執行,方為正道。
以下就是一個經典場景。
儘管資料驗證邏輯應盡可能放在 Schema 中,但也不是所有的驗證都適合丟給 Schema 做。
比如,使用者的 email 欄位具有「唯一性」——不能重複。所以我們希望先確認使用者輸入的 email 是否和 db 中的資料重複,是的話,直接回應409 Conflict
。
這無疑也是一種驗證,但它涉及了「資料庫查詢」。
這種涉及資料庫查詢的驗證,更適合在 view 函式中進行,而不是在 Schema 裡。因為資料庫查詢屬於比較重的動態操作,與 Schema 的靜態資料檢查有著本質的不同。
因此,我們更常在 view 函式中使用HttpError
處理這類需求。
新增程式碼如下:
@router.post(...)
def create_user(..., payload: CreateUserRequest):
"""
新增使用者
"""
if User.objects.filter(email=payload.email).exists():
raise HttpError(409, '使用者 email 已存在')
...
上面是「預先查詢」,和下面這個寫法,在結果上是類似的:
try:
user.save()
except IntegrityError: # Django ORM 唯一性錯誤
raise HttpError(409, '使用者 email 已存在')
只不過一個是事前驗證並拋出錯誤,一個是事後捕捉錯誤(然後再拋出)。
驗證失敗的回應:
// 409 Conflict
{
"detail": "使用者 email 已存在"
}
確實還不錯!
聰明的你可能會想到:
咦,那我何不直接 return 一個帶有錯誤訊息的 Python 字典就好了?為什麼非得在 view 函式中
raise HttpError
?
這個想法,大概的程式碼如下:
@router.post('/users/', response={201: dict, 409: dict}, ...)
def (...) -> tuple[int, dict]:
"""
新增使用者
"""
if User.objects.filter(email=payload.email).exists():
return 409, {"detail": "使用者 email 已存在"}
...
這樣不是更加直觀嗎?
這是一個好問題。
我們還是先來看一下,這段程式碼中有哪些重點:
response={201: dict, 409: dict}
:第 13 篇提過的「多重狀態碼回應」,這不就派上用場了!return
取代raise
。看起來確實不錯,也很符合直覺,其實我以前寫 Django REST framework,都是這樣寫的。
可是,這個寫法在 Django Ninja 中,使用「分頁裝飾器」時,就會踢到鐵板了。
目前時機未到,在後續的〈卷 25:分頁(下)自定義分頁類別〉中,我們再把這件事說清楚。
總之,現階段我們只要知道,類似情況還是raise HttpError
會比較妥當。
在這篇文章中,我們學習了如何使用 Django Ninja 內建的HttpError
來自定義錯誤回應,以避免預設的 422。
並解釋了為何HttpError
不適合用在 Schema 中(雖然我們暫時這麼做了😅),而是應該放到 view 函式裡。
下一篇,我們將改善 Schema 拋出的錯誤、探討全域錯誤處理機制,並且使用 Django Ninja 所提供的exception_handler
裝飾器,進一步提升 API 的錯誤處理能力。
本文同步發表於我的部落格——Code and Me