National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 12

API 的驗證、分頁與錯誤格式

檢查輸入、統一的錯誤格式、分頁與篩選
Interaction實作 30 minute(s)

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 的處理時要在這兩者都關閉的情況下測試。

互動練習:壞請求與 JSON 錯誤

用上面的按鈕送出各種壞請求。除了回應內容,也切到「HTTP 請求與回應」面板看 Content-Type。

逐步看:分頁

資料庫裡有 23 筆。依序送出四個請求,注意 page_size=1000 和 page=abc 分別變成多少。第一次看完,可以用滑桿直接跳到後面的請求。

篩選和排序:值可以參數化,欄位名稱不行

篩選的值(WHERE stars = ?)用參數帶入就好。排序要讓使用者選欄位時就麻煩了:? 只能放值,不能放欄位名稱。下面的動畫會實際試一次 ORDER BY ?,再看直接接字串會發生什麼事。

接字串的問題不只是打錯欄位會 500。ORDER BY 後面可以放任何運算式,也就可以放子查詢,攻擊者能從排序的結果一點一點推出其他資料表的內容。只要是使用者送來的東西,就不能直接接進 SQL。 所以要走白名單:外面可以選的名稱,先對照成程式裡寫死的欄位名稱,對不到就拒絕。

逐步看:排序白名單

先看 ORDER BY ? 的結果,有照 id 由大到小排嗎?再比較 /bad 和 /good 怎麼處理 sort=-id。

互動練習:分頁,並修掉排序的漏洞

資料庫裡已經有 23 筆筆記,到「資料庫」面板可以看到。這份程式的 sort 直接接進 SQL,先實際看它出問題,再改掉。

動手做:書單的分頁與排序

完成 list_books:讀 page、page_size(預設 1 和 2,都不能小於 1,page_size 不能超過 MAX_PAGE_SIZE),sort 走白名單(預設 id),不在白名單裡回 400。程式最下面的測試不要改。

你的輸出
預期輸出

想一想

1.

下面的程式會印出什麼?

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'])
2.

每頁 20 筆,要取第 3 頁,LIMIT ? OFFSET ? 的值應該是?

3.

為什麼不能把排序欄位寫成 ORDER BY ? 再用參數帶入?

4.

寫 errorhandler 時,為什麼要從 e.get_response() 開始,而不是直接 return jsonify(...), e.code?

5.

程式發生未預期的例外時,回給呼叫者的內容應該是?

第 1 次作答

重點整理

  • get_json(silent=True) 解析失敗時回傳 None;另外要檢查是不是物件,再檢查必填、型別、長度
  • 全站用同一種錯誤格式,把所有欄位的問題一次回報
  • @app.errorhandler(HTTPException) 讓 404、405、500 也回 JSON,從 e.get_response() 開始才不會弄丟標頭;例外細節不要回給呼叫者
  • 分頁用 LIMIT ? OFFSET ?,page_size 要有上限,回傳 total
  • 值可以參數化,欄位名稱不行:排序欄位走白名單

還有一種保護這一課沒做:限流,也就是限制同一個來源短時間內能送多少請求,第 17 課會提到。

現在 API 已經比較完整了。下一課換到另一邊,看頁面裡的 JavaScript 怎麼呼叫它,只更新畫面的一部分。

這一課完成了!
Next lesson:前端呼叫 API
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。