上一篇我們處理了「出錯之後怎麼收拾」,讓錯誤變成看得懂的訊息和正確的狀態碼。
不過那時候也說了,有些問題其實可以更早擋下來。
例如這些請求,現在都還會被我們的 API 接受:
{"name": " ", "completed": false}
{"name": "(貼了一篇 5000 字的文章)", "completed": false}
{"name": "洗碗", "completed": false, "priority": -1}
今天就來做 Input Validation(輸入驗證),在資料進來的時候就先檢查。
「前端不是已經有檢查了嗎?」
這是很常見的想法,但答案是:前端的檢查不算數。
因為前端的檢查只是為了讓使用者操作順一點,它擋不住:
有人直接用 Swagger、Postman 或程式送 Request(我們前面幾天就一直這樣做)
有人把前端的 JavaScript 改掉
有人故意送奇怪的資料來試探
後端才是真正的守門員。
有一句話蠻適合記起來的:
永遠不要相信前端傳進來的資料。
如果不檢查,可能會發生:
資料庫裡面存了一堆奇怪的資料(空白的作業名稱、型別不對的值)
程式在某個地方突然爆掉,變成 500
更嚴重的是資安問題,例如 Day 27 要講的 SQL Injection
我自己整理下來,大概可以分成三層:
第一層:格式驗證
有沒有給必填欄位?型別對不對?
→ 交給 Flask-RESTX 的 validate=True
第二層:內容(規則)驗證
名稱是不是只有空白?會不會太長?優先度可不可以是負的?
→ 自己寫檢查
第三層:資料庫的限制
NOT NULL、欄位長度
→ 最後一道防線,Day 24 已經用 errorhandler 接住了
三層都要有,因為每一層擋的東西不一樣。
Day 23 我們在 task_response 補上了 due_date 和 priority,
但 task_input 還是只有 name 和 completed,所以其實沒辦法透過 API 設定那兩個欄位。
既然今天要做驗證,順便一起補上:
task_input = api.model('TaskInput', {
'name': fields.String(required=True, description='作業名稱', example='洗碗'),
'completed': fields.Boolean(required=True, description='是否完成', example=False),
'due_date': fields.String(description='截止日期 YYYY-MM-DD', example='2026-10-15'),
'priority': fields.Integer(description='優先度,數字越大越優先', example=0),
})
只有 name 和 completed 寫了 required=True,另外兩個是選填。
Day 13 有提過一個很容易誤會的地方:
@api.expect(task_input)
預設只是「文件上的說明」,不會真的擋。
只要加上 validate=True:
@api.expect(task_input, validate=True)
Flask-RESTX 就會依照 task_input 的定義去檢查 Request Body:
name 有沒有傳、是不是字串
completed 有沒有傳、是不是布林值
due_date 如果有傳,是不是字串
priority 如果有傳,是不是整數
測試看看,送出:
{
"completed": false
}
這次會直接回 400,而且告訴我們哪裡不對:
{
"errors": {
"name": "'name' is a required property"
},
"message": "Input payload validation failed"
}
少傳 name 時,回傳 400 和 errors 說明
注意這裡的錯誤訊息是英文的,因為那是底層 JSON Schema 產生的。
如果想要中文訊息,就要自己在第二層處理。
(另外,如果想讓整個 API 都預設開啟驗證,可以加上設定 app.config['RESTX_VALIDATE'] = True,
這樣就不用每個 @api.expect 都寫 validate=True。)
來試試看送一個型別錯的:
{
"name": "洗碗",
"completed": "是"
}
按下 Execute 之後,畫面不是我們預期的 400,而是跳出:
Please correct the following validation errors and try again.
propKey: completed error: Value must be a boolean
Swagger 自己先擋下來,Execute 根本沒有送出去
而且 Server response 那一區完全沒有更新。
因為這是 Swagger UI 自己的檢查,它發現我們填的不符合 model,就直接不送了。
也就是說,這個請求根本沒有到後端。
那後端到底有沒有擋住?這就是今天最重要的地方。
Swagger 只是一個網頁,它的檢查跟前端的檢查是同一回事,只要不透過它,就完全不會經過這道檢查。
我們可以直接用程式送同一包資料試試看:
import json, urllib.request, urllib.error
req = urllib.request.Request(
'http://127.0.0.1:5000/tasks',
data=json.dumps({"name": "洗碗", "completed": "是"}).encode(),
headers={'Content-Type': 'application/json'},
)
try:
urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
body = json.loads(e.read())
print(e.code, json.dumps(body, ensure_ascii=False))
執行後會看到:
400 {"errors": {"completed": "'是' is not of type 'boolean'"}, "message": "Input payload validation failed"}
Swagger 擋不住的時候,validate=True 擋住了。
這剛好證明了前面說的那句話:
前端的檢查只是讓使用者操作順一點,真正的守門員是後端。
(順帶一提,這也是為什麼測試 API 的時候不能只用 Swagger,
用 Postman 或直接寫程式送,才測得到後端真正的行為。)
validate=True 幫我們擋掉了「沒填」和「型別錯」。
但它擋不住這種:
{
"name": " ",
"completed": false
}
因為 ” ” 確實是一個字串,型別完全正確。
可是對我們的作業來說,這是一個沒有意義的名稱。
這種屬於「業務規則」,要自己檢查:
def validate_task_data(data):
"""檢查輸入資料,回傳整理過的資料"""
name = data.get("name")
if not isinstance(name, str) or not name.strip():
api.abort(400, "name 不可以是空白")
name = name.strip()
if len(name) > 100:
api.abort(400, "name 最多只能 100 個字")
completed = data.get("completed", False)
if not isinstance(completed, bool):
api.abort(400, "completed 必須是 true 或 false")
due_date = data.get("due_date")
if due_date is not None and not isinstance(due_date, str):
api.abort(400, "due_date 必須是字串,格式為 YYYY-MM-DD")
priority = data.get("priority", 0)
if not isinstance(priority, int) or isinstance(priority, bool):
api.abort(400, "priority 必須是整數")
if priority < 0:
api.abort(400, "priority 不可以小於 0")
return {
"name": name,
"completed": completed,
"due_date": due_date,
"priority": priority,
}
這個函式做了幾件事:
擋掉空白的名稱
順手做 strip(),把前後的空白去掉
限制長度(跟 Model 的 String(100) 對應)
擋掉負數的優先度
最後回傳「整理乾淨」的資料
有一個小地方要注意:
if not isinstance(priority, int) or isinstance(priority, bool):
為什麼要多檢查一次 bool?
因為在 Python 裡 True 其實就是 1、False 就是 0,isinstance(True, int) 會回傳 True。
如果不多擋一次,priority 傳 true 就會被當成 1 存進去。
注意這個函式最後是回傳一個新的字典,而不是直接用 api.payload。
然後在 post() 裡面是這樣用:
data = validate_task_data(api.payload)
task = Task(
name=data["name"],
completed=data["completed"],
due_date=data["due_date"],
priority=data["priority"],
)
為什麼不直接寫成這樣?
task = Task(**api.payload) # 不建議
因為這樣等於「使用者傳什麼欄位,我就照單全收」。
如果之後 Task 多了像是 is_admin、owner_id 這種欄位,
使用者只要在 JSON 裡多塞一個欄位,就可能改到不該改的東西。
所以比較安全的做法是白名單:只取我們明確需要的欄位,其他一律忽略。
只列出有改的部分(PUT 也要一起改):
@api.route('/tasks')
class TaskList(Resource):
@api.expect(task_input, validate=True)
@api.marshal_with(task_response, code=201)
@api.response(400, '輸入資料有問題')
def post(self):
"""新增一筆作業"""
data = validate_task_data(api.payload)
task = Task(
name=data["name"],
completed=data["completed"],
due_date=data["due_date"],
priority=data["priority"],
)
try:
db.session.add(task)
db.session.commit()
except IntegrityError:
db.session.rollback()
api.abort(400, "資料不符合規則,請檢查輸入的內容")
return task, 201
@api.route('/tasks/<int:task_id>')
class TaskDetail(Resource):
@api.expect(task_input, validate=True)
@api.marshal_with(task_response)
def put(self, task_id):
"""修改指定編號的作業"""
task = find_task(task_id)
data = validate_task_data(api.payload)
task.name = data["name"]
task.completed = data["completed"]
task.due_date = data["due_date"]
task.priority = data["priority"]
db.session.commit()
return task
PUT 也要驗證,而且因為 task_input 的 name 和 completed 都是必填,所以修改的時候要把完整的資料傳進來。
這其實就是 Day 15 說的 PUT 語意:整筆取代。
如果想做成「只傳想改的欄位」,就要另外做一個 PATCH,並且用另一個所有欄位都不必填的 model。
我把每一種都實際送了一次,結果整理如下:
{"name": "洗碗", "completed": false, "due_date": "2026-10-15", "priority": 2}
→ 201,新增成功
{"completed": false}
→ 400 {"errors": {"name": "'name' is a required property"}, …}
{"name": 12345, "completed": false}
→ 400 {"errors": {"name": "12345 is not of type 'string'"}, …}
{"name": " ", "completed": false}
→ 400 {"message": "name 不可以是空白"}
空白名稱被擋下來,而且是中文訊息
{"name": "洗碗", "completed": "是"}
→ 400 {"errors": {"completed": "'是' is not of type 'boolean'"}, …}
(用 Swagger 測的話會被 Swagger 自己擋掉,要直接用程式送才看得到這個)
{"name": "洗碗", "completed": false, "priority": -1}
→ 400 {"message": "priority 不可以小於 0"}
{"name": "洗碗", "completed": false, "priority": "abc"}
→ 400 {"errors": {"priority": "'abc' is not of type 'integer'"}, …}
測完之後會發現一件事:
第 2、3、6、8 的訊息是英文、而且有 errors 欄位 → 那是 validate=True 擋的
第 4、5、7 的訊息是中文 → 那是我們自己寫的 validate_task_data() 擋的
兩層剛好各擋各的,少一層都會有漏網之魚。
最後再用 GET /tasks 確認一次:只有第 1 筆進到資料庫,其他七種都被擋在外面了。
知道為什麼後端一定要驗證(前端的檢查擋不住直接打 API)
把 task_input 補上 due_date 和 priority
第一層:@api.expect(model, validate=True) 擋必填和型別
第二層:自己寫 validate_task_data() 擋空白、長度、負數等規則
第三層:資料庫的限制當最後防線
用白名單的方式只取需要的欄位,不要整包照收
發現 Swagger 自己也有一層檢查,但那不能算數
現在我們的 API 已經有基本的防護了。
不過還有一件事完全沒做:
任何人只要知道網址,就可以新增、修改、刪除我們的資料。
下一篇就來認識 API 安全的入門:Authentication 和 Authorization。