Learning Objectives
- 處理不是 JSON、不是物件的請求內容,並檢查必填、型別與長度
- 設計全站一致的錯誤格式,用 errorhandler 讓 404、405、500 也回 JSON
- 用 page、page_size、LIMIT、OFFSET 做分頁,設定上限並回傳 total
- 說明為什麼排序欄位不能參數化,要走白名單
呼叫者可能送來任何東西
上一課的 API 假設呼叫者都照規矩來。實際上呼叫 API 的是別人寫的程式,可能少了欄位、型別不對、內容根本不是 JSON,也可能有人故意亂送。表單至少還有瀏覽器的 required 擋一下,API 完全沒有,伺服器是唯一的防線。
另外兩個問題也會在實際使用時出現:
- 打錯網址時,Flask 回的是一頁 HTML。呼叫者的程式等著讀 JSON,只會得到解析錯誤
- 資料有十萬筆時,
GET /api/notes一次全部回傳,伺服器和呼叫者都受不了
這一課把這三件事補好。
檢查輸入,並統一錯誤格式
第一步是確定收到的是 JSON 物件。 request.get_json() 遇到三種壞內容的結果(都實際跑過):
| 送來的內容 | get_json() |
get_json(silent=True) |
|---|---|---|
Content-Type 不是 JSON |
回 415,HTML 錯誤頁 | None |
| JSON 語法錯誤 | 回 400,HTML 錯誤頁 | None |
| 合法的 JSON,但是串列 | 串列,接著寫 data.get 會變成 500 |
串列 |
所以 API 通常這樣寫:
data = request.get_json(silent=True)
if not isinstance(data, dict):
return api_error('not_json_object', '內容必須是 JSON 物件', 400)
第二步是檢查欄位。 把驗證寫成一個只看資料的函數,收集所有問題,一次回報,呼叫者才不用改一個、送一次:
def validate_note(data):
errors = {}
title = data.get('title')
if title is None:
errors['title'] = '必填'
elif not isinstance(title, str):
errors['title'] = '必須是字串'
elif len(title) > MAX_TITLE:
errors['title'] = f'最多 {MAX_TITLE} 個字元'
stars = data.get('stars', 0)
if not isinstance(stars, int) or isinstance(stars, bool):
errors['stars'] = '必須是整數'
return errors
最後一個檢查要排除 bool,因為 Python 的 bool 是 int 的子類別,isinstance(True, int) 是 True。
第三步是統一格式。 每個端點如果各寫各的(有的回 {"error": "..."},有的回 {"message": "..."},有的回純文字),呼叫者就要為每個端點寫不同的處理。全站用同一個形狀:
{
"error": {
"code": "invalid_fields",
"message": "欄位不正確",
"fields": {"title": "必填"}
}
}
code:給程式判斷,不會因為改文案而變message:給人看fields:選用,表單可以把訊息放在對應的欄位旁邊
形狀不一定要長這樣,全站一致才是重點。寫成一個小函數(下面練習裡的 api_error),就不會每次手寫。
自己寫的錯誤處理好了,還有 Flask 自動產生的:路由不存在的 404、方法不對的 405、程式出錯的 500。這些要靠 errorhandler。
逐步看:讓 Flask 的錯誤也回 JSON
四個請求都會走到 handle_http_error。注意最後一個:程式除以零,輸出區會出現 traceback,但回給呼叫者的只有一句通用訊息。
為什麼要從 e.get_response() 開始
你可能會想直接這樣寫:
@app.errorhandler(HTTPException)
def handle_http_error(e):
return jsonify(error={'code': e.code, 'message': e.description}), e.code
看起來也能跑,但這是一個全新的回應,原本錯誤帶的標頭都不見了。RFC 9110 要求 405 回應要有 Allow 標頭,這樣寫就少了。Flask 文件的範例用 e.get_response() 正是這個原因。
除錯模式與測試模式的差別: 除錯模式下,500 不會走你的處理函數,而是顯示互動式除錯頁;app.testing = True 時,例外會直接丟出來。寫 500 的處理時要在這兩者都關閉的情況下測試。
逐步看:分頁
資料庫裡有 23 筆。依序送出四個請求,注意 page_size=1000 和 page=abc 分別變成多少。第一次看完,可以用滑桿直接跳到後面的請求。
篩選和排序:值可以參數化,欄位名稱不行
篩選的值(WHERE stars = ?)用參數帶入就好。排序要讓使用者選欄位時就麻煩了:? 只能放值,不能放欄位名稱。下面的動畫會實際試一次 ORDER BY ?,再看直接接字串會發生什麼事。
接字串的問題不只是打錯欄位會 500。ORDER BY 後面可以放任何運算式,也就可以放子查詢,攻擊者能從排序的結果一點一點推出其他資料表的內容。只要是使用者送來的東西,就不能直接接進 SQL。 所以要走白名單:外面可以選的名稱,先對照成程式裡寫死的欄位名稱,對不到就拒絕。
逐步看:排序白名單
先看 ORDER BY ? 的結果,有照 id 由大到小排嗎?再比較 /bad 和 /good 怎麼處理 sort=-id。
動手做:書單的分頁與排序
完成 list_books:讀 page、page_size(預設 1 和 2,都不能小於 1,page_size 不能超過 MAX_PAGE_SIZE),sort 走白名單(預設 id),不在白名單裡回 400。程式最下面的測試不要改。
想一想
下面的程式會印出什麼?
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post('/a')
def a():
data = request.get_json(silent=True)
return jsonify(ok=data is not None)
r = app.test_client().post('/a', data='hello', content_type='text/plain')
print(r.status_code, r.get_json()['ok'])
每頁 20 筆,要取第 3 頁,LIMIT ? OFFSET ? 的值應該是?
為什麼不能把排序欄位寫成 ORDER BY ? 再用參數帶入?
寫 errorhandler 時,為什麼要從 e.get_response() 開始,而不是直接 return jsonify(...), e.code?
程式發生未預期的例外時,回給呼叫者的內容應該是?
重點整理
get_json(silent=True)解析失敗時回傳None;另外要檢查是不是物件,再檢查必填、型別、長度- 全站用同一種錯誤格式,把所有欄位的問題一次回報
@app.errorhandler(HTTPException)讓 404、405、500 也回 JSON,從e.get_response()開始才不會弄丟標頭;例外細節不要回給呼叫者- 分頁用
LIMIT ? OFFSET ?,page_size要有上限,回傳total - 值可以參數化,欄位名稱不行:排序欄位走白名單
還有一種保護這一課沒做:限流,也就是限制同一個來源短時間內能送多少請求,第 17 課會提到。
現在 API 已經比較完整了。下一課換到另一邊,看頁面裡的 JavaScript 怎麼呼叫它,只更新畫面的一部分。