在 Nuxt 中建立安全、可維護的 Server API
本專案採用「Client 讀、Server 寫」的架構:
- 讀取操作:Client 端直接查詢 Supabase(RLS 保護)
- 寫入操作:透過 Server API(集中管理邏輯)
這章說明如何設計 Server API。
server/
├── api/
│ ├── v1/ # 版本化業務 API
│ │ └── [resource]/
│ │ ├── index.get.ts # GET /api/v1/[resource]
│ │ ├── index.post.ts # POST /api/v1/[resource]
│ │ └── [id]/
│ │ ├── index.get.ts # GET /api/v1/[resource]/:id
│ │ ├── index.patch.ts # PATCH /api/v1/[resource]/:id
│ │ └── index.delete.ts # DELETE /api/v1/[resource]/:id
│ ├── auth/ # 認證 API
│ └── admin/ # 管理員 API
├── middleware/ # Server Middleware
├── routes/auth/ # OAuth Routes
├── types/ # Server Types
└── utils/ # 工具函式
└── supabase.ts # Supabase 相關
// server/api/v1/todos/index.get.ts
import { getSupabaseWithContext, requireAuth } from '~~/server/utils/supabase'
export default defineEventHandler(async (event) => {
// 確認使用者已登入
await requireAuth(event)
// 取得查詢參數
const query = getQuery(event)
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 20
const sortBy = (query.sortBy as string) || 'created_at'
const sortOrder = query.sortOrder === 'asc' ? true : false
// 取得 Supabase client
const { client } = await getSupabaseWithContext(event)
// 計算 offset
const from = (page - 1) * pageSize
const to = from + pageSize - 1
// 查詢
const { data, error, count } = await client
.schema('app')
.from('todos')
.select('*', { count: 'exact' })
.order(sortBy, { ascending: sortOrder })
.range(from, to)
if (error) {
throw createError({
statusCode: 500,
message: '查詢失敗',
})
}
return {
data,
pagination: {
page,
pageSize,
total: count || 0,
totalPages: Math.ceil((count || 0) / pageSize),
},
}
})// server/api/v1/todos/[id]/index.get.ts
import { getSupabaseWithContext, requireAuth } from '~~/server/utils/supabase'
export default defineEventHandler(async (event) => {
await requireAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
message: '缺少 ID',
})
}
const { client } = await getSupabaseWithContext(event)
const { data, error } = await client.schema('app').from('todos').select('*').eq('id', id).single()
if (error) {
throw createError({
statusCode: 404,
message: '找不到資料',
})
}
return { data }
})// server/api/v1/todos/index.post.ts
import { z } from 'zod'
import { getSupabaseWithContext, requireAuth } from '~~/server/utils/supabase'
// 定義驗證 schema
const createTodoSchema = z.object({
title: z.string().min(1, '標題不能為空').max(200, '標題不能超過 200 字'),
description: z.string().max(2000, '描述不能超過 2000 字').optional(),
due_date: z.string().datetime().optional(),
priority: z.enum(['high', 'medium', 'low']).default('medium'),
})
export default defineEventHandler(async (event) => {
// 1. 驗證使用者
const user = await requireAuth(event)
// 2. 驗證請求資料
const body = await readValidatedBody(event, createTodoSchema.parse)
// 3. 取得 Supabase client
const { client } = await getSupabaseWithContext(event)
// 4. 新增資料
const { data, error } = await client
.schema('app')
.from('todos')
.insert({
...body,
user_id: user.id,
})
.select()
.single()
if (error) {
console.error('Create todo error:', error)
throw createError({
statusCode: 500,
message: '新增失敗',
})
}
// 5. 回應 201 Created
setResponseStatus(event, 201)
return { data }
})// server/api/v1/todos/[id]/index.patch.ts
import { z } from 'zod'
import { getSupabaseWithContext, requireAuth } from '~~/server/utils/supabase'
const updateTodoSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
due_date: z.string().datetime().nullable().optional(),
priority: z.enum(['high', 'medium', 'low']).optional(),
completed: z.boolean().optional(),
})
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
message: '缺少 ID',
})
}
const body = await readValidatedBody(event, updateTodoSchema.parse)
// 至少要有一個欄位要更新
if (Object.keys(body).length === 0) {
throw createError({
statusCode: 400,
message: '沒有要更新的欄位',
})
}
const { client } = await getSupabaseWithContext(event)
// 如果是標記完成,順便記錄完成時間
const updateData = {
...body,
...(body.completed === true && { completed_at: new Date().toISOString() }),
...(body.completed === false && { completed_at: null }),
}
const { data, error } = await client
.schema('app')
.from('todos')
.update(updateData)
.eq('id', id)
.select()
.single()
if (error) {
throw createError({
statusCode: 500,
message: '更新失敗',
})
}
return { data }
})// server/api/v1/todos/[id]/index.delete.ts
import { getSupabaseWithContext, requireAuth } from '~~/server/utils/supabase'
export default defineEventHandler(async (event) => {
await requireAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
message: '缺少 ID',
})
}
const { client } = await getSupabaseWithContext(event)
const { error } = await client.schema('app').from('todos').delete().eq('id', id)
if (error) {
throw createError({
statusCode: 500,
message: '刪除失敗',
})
}
// 回應 204 No Content
setResponseStatus(event, 204)
return null
})import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import type { Database } from '~~/app/types/database.types'
// 取得特權 Service Role Client(僅系統任務使用)
export function getServerSupabaseClient(): SupabaseClient<Database> {
const config = useRuntimeConfig()
return createClient<Database>(config.public.supabaseUrl, config.supabaseServiceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
})
}
// 取得 request-scoped Client
export async function getSupabaseWithContext(event: H3Event): Promise<{
client: SupabaseClient<Database>
user: Awaited<ReturnType<typeof requireAuth>>
}> {
const user = await requireAuth(event)
const client = getServerSupabaseClient()
await client.rpc('set_app_context', {
p_user_id: user.id,
p_user_role: user.role,
} as never)
return { client, user }
}
// 要求使用者已登入
export async function requireAuth(event: H3Event) {
const session = await getUserSession(event)
if (!session?.user) {
throw createError({
statusCode: 401,
message: '請先登入',
})
}
return session.user
}
// 要求使用者有特定角色
export async function requireRole(event: H3Event, allowedRoles: string[]) {
const user = await requireAuth(event)
if (!allowedRoles.includes(user.role)) {
throw createError({
statusCode: 403,
message: '權限不足',
})
}
return user
}// server/api/v1/todos/batch.post.ts
import { z } from 'zod'
import { getSupabaseWithContext, requireAuth } from '~~/server/utils/supabase'
const batchCreateSchema = z.object({
items: z
.array(
z.object({
title: z.string().min(1).max(200),
priority: z.enum(['high', 'medium', 'low']).default('medium'),
})
)
.min(1)
.max(100),
})
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const body = await readValidatedBody(event, batchCreateSchema.parse)
const { client } = await getSupabaseWithContext(event)
const itemsWithUserId = body.items.map((item) => ({
...item,
user_id: user.id,
}))
const { data, error } = await client.schema('app').from('todos').insert(itemsWithUserId).select()
if (error) {
throw createError({
statusCode: 500,
message: '批次新增失敗',
})
}
setResponseStatus(event, 201)
return { data, count: data.length }
})// server/api/v1/todos/search.get.ts
export default defineEventHandler(async (event) => {
await requireAuth(event)
const query = getQuery(event)
const keyword = query.q as string
if (!keyword || keyword.length < 2) {
throw createError({
statusCode: 400,
message: '搜尋關鍵字至少 2 個字',
})
}
const { client } = await getSupabaseWithContext(event)
const { data, error } = await client
.schema('app')
.from('todos')
.select('*')
.or(`title.ilike.%${keyword}%,description.ilike.%${keyword}%`)
.order('created_at', { ascending: false })
.limit(50)
if (error) {
throw createError({
statusCode: 500,
message: '搜尋失敗',
})
}
return { data }
})// server/api/v1/todos/index.post.ts(加入日誌記錄)
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const body = await readValidatedBody(event, createTodoSchema.parse)
const { client } = await getSupabaseWithContext(event)
// 新增資料
const { data, error } = await client
.schema('app')
.from('todos')
.insert({ ...body, user_id: user.id })
.select()
.single()
if (error) {
throw createError({ statusCode: 500, message: '新增失敗' })
}
// 記錄操作日誌
await client
.schema('core')
.from('operation_logs')
.insert({
user_id: user.id,
action: 'create',
target_type: 'todo',
target_id: data.id,
details: { title: body.title },
ip_address: getRequestIP(event),
})
setResponseStatus(event, 201)
return { data }
})// server/api/v1/todos/[id]/comments/index.get.ts
export default defineEventHandler(async (event) => {
await requireAuth(event)
const todoId = getRouterParam(event, 'id')
if (!todoId) {
throw createError({ statusCode: 400, message: '缺少 Todo ID' })
}
const { client } = await getSupabaseWithContext(event)
// 先確認 todo 存在
const { data: todo, error: todoError } = await client
.schema('app')
.from('todos')
.select('id')
.eq('id', todoId)
.single()
if (todoError || !todo) {
throw createError({ statusCode: 404, message: '找不到 Todo' })
}
// 取得留言
const { data, error } = await client
.schema('app')
.from('todo_comments')
.select(
`
*,
user:core.user_roles(name, avatar_url)
`
)
.eq('todo_id', todoId)
.order('created_at', { ascending: true })
if (error) {
throw createError({ statusCode: 500, message: '查詢失敗' })
}
return { data }
})throw createError({
statusCode: 400, // HTTP 狀態碼
statusMessage: 'Bad Request', // HTTP 狀態訊息(可選)
message: '具體錯誤訊息', // 給開發者/使用者看的訊息
})| 狀態碼 | 說明 | 使用場景 |
|---|---|---|
| 200 | OK | 成功的 GET/PATCH |
| 201 | Created | 成功的 POST |
| 204 | No Content | 成功的 DELETE |
| 400 | Bad Request | 請求格式錯誤、驗證失敗 |
| 401 | Unauthorized | 未登入 |
| 403 | Forbidden | 權限不足 |
| 404 | Not Found | 資源不存在 |
| 409 | Conflict | 資源衝突(如重複建立) |
| 500 | Internal Server Error | 伺服器錯誤 |
import { z } from 'zod'
export default defineEventHandler(async (event) => {
try {
const body = await readValidatedBody(event, schema.parse)
// ...
} catch (error) {
if (error instanceof z.ZodError) {
throw createError({
statusCode: 400,
message: error.errors.map((e) => e.message).join(', '),
})
}
throw error
}
})// 新增
const { data } = await $fetch('/api/v1/todos', {
method: 'POST',
body: { title: '買牛奶' },
})
// 更新
await $fetch(`/api/v1/todos/${id}`, {
method: 'PATCH',
body: { completed: true },
})
// 刪除
await $fetch(`/api/v1/todos/${id}`, {
method: 'DELETE',
})try {
await $fetch('/api/v1/todos', {
method: 'POST',
body: { title: '' }, // 會觸發驗證錯誤
})
} catch (error) {
if (error.statusCode === 400) {
toast.add({
title: '驗證失敗',
description: error.data?.message || '請檢查輸入',
color: 'red',
})
} else if (error.statusCode === 401) {
navigateTo('/login')
} else {
toast.add({
title: '操作失敗',
description: '請稍後再試',
color: 'red',
})
}
}// app/queries/todos.ts
import { useMutation, useQueryCache } from '@pinia/colada'
export function useCreateTodo() {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: { title: string }) =>
$fetch('/api/v1/todos', {
method: 'POST',
body: data,
}),
onSuccess: () => {
// 重新載入列表
queryCache.invalidateQueries({ key: ['todos'] })
},
})
}// ❌ 危險:直接使用使用者輸入
const body = await readBody(event)
await supabase.from('todos').insert(body)
// ✅ 安全:使用 Zod 驗證
const body = await readValidatedBody(event, schema.parse)// ❌ 危險:沒有檢查使用者
export default defineEventHandler(async (event) => {
const supabase = getServerSupabaseClient()
// ...
})
// ✅ 安全:確認使用者已登入,並使用 request-scoped client
export default defineEventHandler(async (event) => {
await requireAuth(event)
const { client } = await getSupabaseWithContext(event)
// ...
})// ❌ 危險:回傳原始錯誤
if (error) {
throw createError({
statusCode: 500,
message: error.message, // 可能包含 SQL 細節
})
}
// ✅ 安全:回傳通用訊息,詳細錯誤記錄到日誌
if (error) {
console.error('Database error:', error)
throw createError({
statusCode: 500,
message: '操作失敗',
})
}Supabase SDK 已經處理了 SQL injection,但如果你需要使用 raw SQL:
// ❌ 危險:字串拼接
await supabase.rpc('my_function', {
query: `SELECT * FROM todos WHERE title = '${userInput}'`,
})
// ✅ 安全:使用參數
await supabase.rpc('my_function', {
title: userInput,
})如果你部署到 Cloudflare Workers,有幾個限制需要注意:
// ❌ 這會失敗
const body1 = await readBody(event)
const body2 = await readBody(event) // 第二次讀取會失敗
// ✅ 讀取一次,重複使用
const body = await readBody(event)
// 之後都用這個 body- 免費方案:10ms CPU time
- 付費方案:30s
複雜操作考慮使用 Supabase Edge Functions 或拆分成多個請求。
某些環境的 DELETE 請求不支援 body,優先使用 Query Parameter:
// ✅ 推薦
DELETE /api/v1/todos/123
// 或者用 query parameter
DELETE /api/v1/todos?ids=123,456