Nitro Server API 設計模式

handler 裡的步驟順序不是風格問題——權限檢查之前做的任何事,都是未授權的人可以觸發的。 這篇記固定順序的理由,以及三個會回 200 的錯:路由撞成 HTML、query 全是字串、軟刪除漏過濾。

系列・Nuxt 4 全棧實戰筆記・第 9/12 篇

Server API 這一層的錯誤有個共同性格:它們大多不會讓請求失敗。回 200、回 HTML、回一份包含已刪除資料的列表——前端拿到東西了,所以沒有人會去看伺服器端。

這篇是四件在寫第一支 handler 之前就該定下來的事。

一、handler 裡的順序是安全順序

每一支 handler 的骨架都一樣,而且順序不能換:

export default defineEventHandler(async (event) => {
  // 1. 權限——必須是第一件事
  const { user } = await requireUserSession(event, {
    user: { role: ['admin', 'manager', 'staff'] },
  })

  // 2. 驗證輸入
  const body = await readValidatedBody(event, createResourceSchema.parse)

  // 3. 取得帶著這次請求身分的 client
  const db = await getSupabaseWithContext(event)

  // 4. 執行
  const { data, error } = await db.from('resources').insert(body).select().single()
  if (error) throw createError({ statusCode: 500, message: '操作失敗' })

  // 4. 回傳統一格式
  setResponseStatus(event, 201)
  return { data }
})

(第 3 步的 client 取得方式各專案不同,重點是它帶著這次請求的身分,不是一個全域共用的連線。)

把權限放第一位不是為了好看。在權限檢查之前執行的任何一行,都是未授權的人有辦法觸發的——解析請求內容會消耗記憶體、查資料庫會產生負載、寫任何東西都會留下痕跡。順序寫錯時功能完全正常,所以它不會被測出來,只會在有人開始亂打你的 API 時變成問題。

同理,驗證要在業務邏輯之前。用 readValidatedBody / getValidatedQuery 讓驗證與取值變成同一個動作,就不會出現「先取了、後面某個分支才驗」的漏洞。

二、API 回 HTML 的意思是「沒有配到任何路由」

症狀非常誤導:呼叫 API 拿回來的是一份 HTML,而不是 JSON。前端的 JSON 解析當場炸掉,錯誤訊息指向前端。

但這個症狀只有一個意思:這個請求沒有配到任何 API 路由,於是掉進了頁面的 fallback。 該查的是路徑本身——檔名的 HTTP 方法後綴對不對(.get.ts / .post.ts)、目錄層級有沒有少一層、動態片段的括號有沒有打錯。把力氣花在 handler 內部是白費的,因為那支 handler 根本沒有被執行。

順帶一提,動態片段建議一開始就用目錄形式:

server/api/v1/users/
└── [id]/
    ├── index.get.ts       # GET    /api/v1/users/:id
    ├── index.patch.ts     # PATCH  /api/v1/users/:id
    ├── index.delete.ts    # DELETE /api/v1/users/:id
    └── roles.get.ts       # GET    /api/v1/users/:id/roles

這樣寫在只有一支端點時看起來囉嗦,換到的是「之後要加子路由不必搬既有檔案」。搬檔案本身沒什麼難度,難的是搬完之後你得重新確認每一條舊路徑都還在——而確認的方式只有一條一條打打看。

順帶回答一個常見的疑問:前面那個 v1 是不是過度設計?如果你的 API 只有自己的前端在用,那確實可以不要——版本號的價值在你無法要求所有呼叫端同時更新的時候才出現。但要注意這件事的方向是單向的:沒有版本號的 API 想加上去,等於所有既有路徑都得改;有了卻用不到,成本只是網址多五個字元。

三、Query 參數永遠是字串

?page=2 進到 handler 裡是 '2',不是 2。用一般的數字驗證會直接失敗,而失敗訊息是「預期數字,收到字串」——看起來像前端傳錯了。

export const paginationQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  pageSize: z.coerce.number().int().positive().max(1000).default(10),
  sortDir: z.enum(['asc', 'desc']).default('desc'),
})

coerce 負責轉型。但要注意 default 的覆蓋範圍比直覺窄:它只在值是 undefined 時生效。?page=(帶了參數但值是空字串)不會走到預設值——空字串會先被 coerce 轉成 0,再被 .positive() 擋下來,使用者拿到的是驗證失敗而不是第一頁。要讓兩者行為一致,得自己把空字串正規化掉:

page: z.preprocess((v) => (v === '' ? undefined : v), z.coerce.number().int().positive().default(1))

pageSize 則一定要設上限——沒設的話,?pageSize=999999 就是一個任何人都能觸發的全表掃描。

把這份 schema 放在前後端都拿得到的地方,前端組查詢參數時可以用同一份定義,分頁參數的名稱與預設值就不會兩邊各有一套。

四、軟刪除要靠機制,不能靠記得

用 deleted_at 做軟刪除的話,每一支查詢都得加上過濾:

const { data } = await db.from('resources').select('*').is('deleted_at', null)

問題是這條規則只能靠人記得,而漏掉時的症狀是「列表裡出現已刪除的資料」——不會報錯,而且通常由使用者發現。

比較可靠的做法是讓機制承擔:建一個已經過濾好的視圖給讀取用,或把查詢包成一個共用函式,讓「沒過濾的查詢」在程式碼裡根本寫不出來。凡是需要每次記得的規則,遲早會有人不記得——這件事在權限、在型別、在這裡都一樣。

回應格式統一的實際好處

列表回 { data, pagination }、單筆回 { data }、錯誤走 createError 帶正確的狀態碼。

這件事的價值不在整齊,而在前端不必為每一支 API 各寫一次解析。格式不統一時,每個呼叫點都會長出自己的一小段適配邏輯,而那些邏輯會在後端某次調整回應結構時全部一起壞掉,且各壞各的。