5.1 實戰:建立待辦事項全端應用#
專案目標與範圍#
綜合前四章的理論,建構一個完整的 Todo 應用:
- 後端:Axum + SQLx + PostgreSQL + JWT
- 前端:Expo + expo-router + react-query + zod
- 端對端型別共享(4.4 節管道)
第一步:資料模型#
-- migrations/0001_init.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- Argon2
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
done BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_todos_user ON todos(user_id, created_at DESC);
設計決策的理論依據:
ON DELETE CASCADE:刪除使用者時待辦一併消失 —— 資料完整性的宣告式表達CHECK約束:最後一道防線(3.2 節三道防線的最內層)- 複合索引:查詢模式(某使用者的列表、按時間倒序)決定索引設計
第二步:領域層(Repository)#
// src/models/todo.rs
#[derive(Serialize, Deserialize, sqlx::FromRow, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct Todo {
pub id: Uuid,
pub user_id: Uuid,
pub title: String,
pub done: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Deserialize, Validate, ToSchema)]
pub struct CreateTodo {
#[validate(length(min = 1, max = 200))]
pub title: String,
}
// src/repo/todo.rs
pub async fn list(pool: &PgPool, user_id: Uuid) -> Result<Vec<Todo>, AppError> {
let todos = sqlx::query_as!(
Todo,
r#"SELECT id, user_id, title, done, created_at
FROM todos WHERE user_id = $1 ORDER BY created_at DESC"#,
user_id
).fetch_all(pool).await?;
Ok(todos)
}
pub async fn create(pool: &PgPool, user_id: Uuid, input: &CreateTodo)
-> Result<Todo, AppError> {
let todo = sqlx::query_as!(Todo,
r#"INSERT INTO todos (user_id, title) VALUES ($1, $2)
RETURNING id, user_id, title, done, created_at"#,
user_id, input.title
).fetch_one(pool).await?;
Ok(todo)
}
注意分層:repo 函數不知道 HTTP 的存在(不回 Response,不碰 status code)—— 層間依賴方向單一(1.4 節不變式)。
第三步:路由與授權整合#
pub fn router() -> Router<AppState> {
Router::new()
.route("/todos", get(list).post(create))
.route("/todos/:id", patch(toggle).delete(remove))
}
async fn list(
State(pool): State<PgPool>,
Extension(claims): Extension<Claims>, // 3.4 節中介層注入的身分
) -> Result<Json<Vec<Todo>>, AppError> {
Ok(Json(repo::list(&pool, claims.sub).await?))
}
async fn toggle(
State(pool): State<PgPool>,
Extension(claims): Extension<Claims>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, AppError> {
let todo = sqlx::query_as!(Todo,
"UPDATE todos SET done = NOT done
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, title, done, created_at",
id, claims.sub
).fetch_optional(pool).await?
.ok_or(AppError::NotFound)?; // 404 同時涵蓋「不存在」與「不是你的」
Ok(Json(todo))
}
WHERE ... AND user_id = $2 把授權下沉進查詢 —— 水平越權(IDOR,OWASP Top 10 常客)被資料層結構性防堵:不存在「先查再檢查」的時間差。
第四步:前端畫面#
// app/todos/index.tsx
import { View, FlatList } from 'react-native';
import { useTodos, useToggleTodo, useCreateTodo } from '../../hooks/useTodos';
export default function TodoListScreen() {
const { data: todos, isLoading } = useTodos();
const toggle = useToggleTodo();
const create = useCreateTodo();
if (isLoading) return <Spinner />;
return (
<View style={styles.container}>
<TodoInput onSubmit={(title) => create.mutate({ title })} />
<FlatList
data={todos}
keyExtractor={(t) => t.id}
renderItem={({ item }) => (
<TodoItem todo={item} onToggle={(id) => toggle.mutate(id)} />
)}
/>
</View>
);
}
// hooks/useTodos.ts — 資料層與畫面層分離(4.2 節)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useTodos() {
return useQuery({ queryKey: ['todos'], queryFn: api.listTodos });
}
export function useToggleTodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: api.toggleTodo,
onMutate: async (id) => { // 樂觀更新(4.4 節)
await qc.cancelQueries({ queryKey: ['todos'] });
const prev = qc.getQueryData<Todo[]>(['todos']);
qc.setQueryData<Todo[]>(['todos'],
(old) => old?.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
return { prev };
},
onError: (_e, _id, ctx) => qc.setQueryData(['todos'], ctx?.prev), // 回滾
onSettled: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});
}
第五步:登入流程#
sequenceDiagram
participant App as Expo App
participant API as Axum
participant DB as PostgreSQL
App->>API: POST /login {email, password}
API->>DB: SELECT password_hash
API->>API: Argon2 驗證
API-->>App: { accessToken, refreshToken }
App->>App: SecureStore 儲存 token
App->>API: GET /todos (Bearer)
API-->>App: 200 或 401 → 刷新/登出// 認證 Context(4.2 節:低頻全域狀態用 Context)
const AuthContext = createContext<{
signIn: (email: string, password: string) => Promise<void>;
signOut: () => void;
user: Claims | null;
} | null>(null);
全鏡重溫:理論到程式碼的映射#
| 理論(章節) | 落地程式碼 |
|---|---|
| 所有權/借用(2.1) | State 提取、body.name move 進查詢 |
| ADT 錯誤(2.4) | AppError enum → HTTP 狀態碼 |
| 非同步(2.3) | tokio::main、try_join_all |
| Tower 中介層(3.1) | require_login 認證層 |
| 提取器(3.2) | Path/Query/Json/Extension |
| 型別即規格(2.2/3.3/3.4) | SQLx 編譯期檢查、utoipa |
| UI=f(state)(4.1) | 函數元件、不可變更新 |
| 伺服器狀態(4.2) | react-query 快取/失效 |
| 型別管道(4.4) | openapi.json → TS + zod |
習題#
- 加入「到期日」欄位:寫出遷移、Rust 型別、OpenAPI、前端,體驗一次完整的型別管道。
- 為什麼
toggle用 404 而非 403 回應「他人的待辦」?這對安全有何意義? - 給登入 API 加上失敗次數限流(提示:tower_governor + email 維度)。