2024 iThome 鐵人賽2024 iThome 鐵人賽

經過前幾篇的介紹,我們已經學習了如何處理路徑與查詢參數。但在現實世界中,我們往往還需要處理更複雜的請求資料

比如用戶提交的表單、上傳的檔案等等。對於 API 而言,最常見的就是 JSON 格式的 request body

這一篇將探討 Django Ninja 如何處理 request body,並介紹如何透過 Schema 來定義與驗證資料。

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


一、什麼是 Request Body?

Request body 指的是隨著 HTTP 請求一同傳送的資料,通常用於POSTPUT需要建立或更新「資源」的請求。

這些資料不會出現在 URL 中,而是以 JSON 或其他格式(如 XML、form-data)作為請求的主體。

例如,當用戶要發表一篇新文章時,可能會傳送以下 JSON 格式的 request body:

1
2
3
4
{
"title": "我的第一篇文章",
"content": "這是我在忍者論壇的第一篇文章,希望大家喜歡!"
}

這個 request body 包含了titlecontent兩個欄位,Django Ninja 將協助我們處理這些資料並進行驗證。


二、範例專案改動

我們要在範例專案中建立一個接收 request body 的 API——「新增文章」。

此外,還要在 Django post app 目錄下,新增一個 Python 模組:schemas.py。這是用來放置 API 中所有用到的 Schema 的地方。

1
2
3
4
5
6
├── NinjaForum
│ ├── ...
├── post
│ ├── api.py
│ ├── schemas.py # 新增這個模組
│ ├── ...

具體程式碼,我們會在接下來的說明中介紹。

從本篇開始,分支名稱不再使用中文,因為中文分支名稱會一直被 GitHub 提醒:

The head ref may contain hidden characters: …

而且應該也很少人使用中文來命名 git 分支!當初用中文是為了讀者比較好讀🥹

所以從這個分支開始,改成數字+英文,比如本篇的「12-request-body」。但 PR 的標題仍維持中文。


三、使用 Schema 定義與驗證 Request Body

與 FastAPI 相同,Django Ninja 使用 Pydantic BaseModel 來處理請求 body。

不過因為 BaseModel 這個名稱容易和 Django 的 Models 混淆,所以 Django Ninja 將其重新命名為 Schema。

Schema 繼承自 BaseModel,因此兩者的實際內涵非常接近(Django Ninja 有自己加一點料):

1
2
3
# Django Ninja 原始碼
class Schema(BaseModel, metaclass=ResolverMetaclass):
...

回到專案,讓我們來看專案中的例子,這是定義「新增文章」API 的 request body 的 Schema:

1
2
3
4
5
6
7
# post/schemas.py
from ninja import Schema

class CreatePostRequest(Schema):
title: str
content: str
user_id: int

這個 Schema 要求 body 資料必須包含這三個欄位:titlecontentuser_id,而且資料的型別也要相符

在 View 函式中使用 Schema

定義好了「請求」Schema,就可以在 view 函式中以「函式參數」的形式使用它:

1
2
3
4
5
6
from post.schemas import CreatePostRequest
...

@router.post(path='/posts/')
def create_post(..., payload: CreatePostRequest): # 這裡
...

我們將函式payload參數的 type hint 設定為我們剛剛定義的CreatePostRequest

當請求發送到這個 API 時,Django Ninja 會透過CreatePostRequest這個 Schema 來解析parsing)並驗證 body 中的資料。

驗證成功後,再將資料傳入 view 函式的payload。此時函式內部payload參數,本質上是一個 Schema(即 Pydantic BaseModel)物件。

自動資料驗證與錯誤處理

如果請求 body 中有欄位缺少,或者資料的型別不對,Django Ninja 會自動返回 422 回應,並提供具體的錯誤資訊

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"detail": [
{
"type": "missing",
"loc": [
"body",
"payload",
"content"
],
"msg": "Field required"
}
]
}

錯誤訊息表示:body 中缺少了content這個 field。


四、可選的(Optional)欄位與預設值

在實際 API 開發中,並不是所有請求欄位都是必須的。

我們可以透過 Pydantic 與 type hints 來定義可選欄位。假設,現在文章的內容是完全可選的:(留意content欄位)

1
2
3
4
class CreatePostRequest(Schema):
title: str
content: str | None = None
user_id: int

使用=運算子,將content欄位的預設值設定為None,該欄位就會變成可選欄位。此時content的 type hints 也要改為str | None

值得一提的是,如果 Schema 用在請求,這樣設定即使能通過驗證,你也要注意後續對應的 Django Model 欄位(也就是 db 欄位)是否允許 NULL。不然還是會出錯:

django.db.utils.IntegrityError: NOT NULL constraint failed: post_post.content

除了將欄位設為可選,也可以直接給定預設值,比如這裡的空字串。在使用者未輸入時,就會直接填入預設值

1
2
3
4
class CreatePostRequest(Schema):
title: str
content: str = ''
user_id: int

然而,除了預設值為None,在 Schema 中給定預設值的行為要「非常慎用」。這部分我們在〈卷 18:Pydantic Field 設定範例與預設值〉還會再次討論。


五、Django Ninja 判斷參數的順序

你是否想過,一個 view 函式參數這麼多種,Django Ninja 怎麼知道誰要對應誰

事實上,Django Ninja 確實會根據 view 函式的參數簽名,自動判斷參數的來源(究竟是路徑參數、查詢參數或請求 body)。其判斷順序如下:

  1. 路徑參數:任何定義在 URL path 中的變數(比如/items/{id}中的id)會優先被識別為路徑參數
  2. 查詢參數:函式中的其他單數類型參數(比如intfloatboolstr,而不是listdict),若未標註為路徑參數,則會被識別為查詢參數。
  3. Request body:Schema 型別參數,才會被視為請求 body。

原則上,view 函式只能有一個 Schema 參數。畢竟一個請求就只有一個 body 而已。


第二節尾聲

本節的內容已差不多結束。

在這一節中,我們學習了如何使用 Django Ninja 處理 HTTP 請求,並介紹了 Schema 的基本用法。

Schema 的用法與變化還很多,這裡只是「牛刀小試」而已。在第三節「HTTP 回應」中,你將看到更多關於 Schema 的設定。

進入下一節之前,我們先進行中場休息——和一些準備


中場休息與準備

下一節,我們要讓專案的 API 真正運作起來,還記得前面提到為何目前無法使用嗎?

  1. 沒有 db 資料。
  2. 沒有建立 Schema。

我們已經學到怎麼使用 Schema 了——雖然還不全面。那「db 資料」問題也需要獲得解決。

Django Fixtures

我們固然可以透過呼叫 POST API 去手動新增用戶與文章資料,但太麻煩了!更別說,專案目前還沒有「新增使用者」這個 API。

所以,不用麻煩了。

我們直接透過 Django fixtures 來匯入由我預先定義好的假資料

有關 Django fixtures 的介紹,可參考文章〈用 Django Fixture 匯入與導出資料〉。

在下一篇13-response分支進度底下,你已經可以看到我導出的 fixtures 資料:

  1. users.json
  2. posts.json

想要使用它們,直接依序匯入即可:

1
2
python manage.py loaddata users.json
python manage.py loaddata posts.json

一定要先匯入 users,否則文章沒有作者會關聯失敗

匯入完成後,你會獲得 2 個使用者——Alice 和 Bob,還有他們各發表的 30 篇文章。

呃,夾雜了第一篇我的測試文章,請多包涵😅

成功匯入後,我們就可以繼續了。