iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Rust

Rust 實戰專案集:30 個漸進式專案從工具到服務系列 第 28

個人任務管理 API - 完整的 RESTful 待辦事項服務

  • 分享至 

  • xImage
  •  

前言

幾乎是最後的應用了所以回到我的老本行,網頁應用的部分
今天直接來實作 CRUD 去完成我們常見的網頁服務

專案概述

  • 包含用戶認證
  • 任務 CRUD
  • 標籤管理
  • 優先級設定等功能

Web 框架、資料庫操作、JWT 認證、錯誤處理

專案目標

  • 實作完整的 RESTful API 架構
  • 使用 JWT 進行用戶認證與授權
  • 實現任務的 CRUD 操作
  • 支援任務分類、標籤、優先級
  • 實作任務篩選與排序功能
  • 使用 PostgreSQL 作為持久化儲存
  • 實現資料庫遷移管理

建立 postgres

docker-compose.yml

services:
  db:
    image: postgres
    restart: always
    ports:
      - 5432:5432
    environment:
      POSTGRES_USER: task_user
      POSTGRES_PASSWORD: your_password
      POSTGRES_DB: task_manager
    volumes:
      - ./data:/var/lib/postgresql/data
# 連接到 PostgreSQL
psql -U postgres

# 在 psql 中執行
CREATE DATABASE task_manager;
CREATE USER task_user WITH PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE task_manager TO task_user;
\q

依賴

cargo.toml

[package]
name = "task-manager-api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "migrate"] }
jsonwebtoken = "9.2"
bcrypt = "0.15"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
thiserror = "1.0"
validator = { version = "0.18", features = ["derive"] }

專案結構

task-manager-api/
├── Cargo.toml
├── .env
├── migrations/
│   ├── 20250101000001_create_users.sql
│   ├── 20250101000002_create_tasks.sql
│   └── 20250101000003_create_indexes.sql
└── src/
    ├── main.rs
    ├── models.rs
    ├── db.rs
    ├── auth.rs
    ├── error.rs
    ├── handlers.rs
    └── config.rs

.env

DATABASE_URL=postgresql://task_user:your_password@localhost:5432/task_manager
JWT_SECRET=your-super-secret-jwt-key-change-in-production
RUST_LOG=info

先搞好配置

src/config.rs

use std::env;

#[derive(Clone)]
pub struct Config {
    pub database_url: String,
    pub jwt_secret: String,
    pub server_addr: String,
}

impl Config {
    pub fn from_env() -> Self {
        dotenvy::dotenv().ok();

        Self {
            database_url: env::var("DATABASE_URL")
                .expect("DATABASE_URL must be set"),
            jwt_secret: env::var("JWT_SECRET")
                .expect("JWT_SECRET must be set"),
            server_addr: env::var("SERVER_ADDR")
                .unwrap_or_else(|_| "127.0.0.1:3000".to_string()),
        }
    }
}

這裏我們先搞好 sql migration 的部分

migrations/20250101000001_create_users.sql

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 建立更新時間觸發器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

migrations/20250101000002_create_tasks.sql

CREATE TYPE task_status AS ENUM ('Todo', 'InProgress', 'Done');
CREATE TYPE task_priority AS ENUM ('Low', 'Medium', 'High', 'Urgent');

CREATE TABLE tasks (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title VARCHAR(200) NOT NULL,
    description TEXT,
    status task_status NOT NULL DEFAULT 'Todo',
    priority task_priority NOT NULL DEFAULT 'Medium',
    tags TEXT[], -- PostgreSQL 陣列類型
    due_date TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TRIGGER update_tasks_updated_at BEFORE UPDATE ON tasks
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

-- 當任務狀態改為 Done 時自動設定完成時間
CREATE OR REPLACE FUNCTION set_completed_at()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.status = 'Done' AND OLD.status != 'Done' THEN
        NEW.completed_at = NOW();
    ELSIF NEW.status != 'Done' THEN
        NEW.completed_at = NULL;
    END IF;
    RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER task_completion_trigger BEFORE UPDATE ON tasks
    FOR EACH ROW EXECUTE FUNCTION set_completed_at();

migrations/20250101000003_create_indexes.sql

CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_priority ON tasks(priority);
CREATE INDEX idx_tasks_due_date ON tasks(due_date);
CREATE INDEX idx_tasks_created_at ON tasks(created_at);
CREATE INDEX idx_tasks_tags ON tasks USING GIN(tags);

-- 複合索引用於常見查詢
CREATE INDEX idx_tasks_user_status ON tasks(user_id, status);
CREATE INDEX idx_tasks_user_priority ON tasks(user_id, priority);

資料結構

src/models.rs

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use validator::Validate;

#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct User {
    pub id: Uuid,
    pub username: String,
    pub email: String,
    #[serde(skip_serializing)]
    pub password_hash: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct Task {
    pub id: Uuid,
    pub user_id: Uuid,
    pub title: String,
    pub description: Option<String>,
    pub status: TaskStatus,
    pub priority: TaskPriority,
    pub tags: Option<Vec<String>>,
    pub due_date: Option<DateTime<Utc>>,
    pub completed_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)]
#[sqlx(type_name = "task_status", rename_all = "PascalCase")]
pub enum TaskStatus {
    Todo,
    InProgress,
    Done,
}

#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)]
#[sqlx(type_name = "task_priority", rename_all = "PascalCase")]
pub enum TaskPriority {
    Low,
    Medium,
    High,
    Urgent,
}

// DTO (Data Transfer Objects)
#[derive(Debug, Deserialize, Validate)]
pub struct RegisterRequest {
    #[validate(length(min = 3, max = 50))]
    pub username: String,
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 6))]
    pub password: String,
}

#[derive(Debug, Deserialize)]
pub struct LoginRequest {
    pub email: String,
    pub password: String,
}

#[derive(Debug, Serialize)]
pub struct AuthResponse {
    pub token: String,
    pub user: UserResponse,
}

#[derive(Debug, Serialize)]
pub struct UserResponse {
    pub id: Uuid,
    pub username: String,
    pub email: String,
    pub created_at: DateTime<Utc>,
}

impl From<User> for UserResponse {
    fn from(user: User) -> Self {
        Self {
            id: user.id,
            username: user.username,
            email: user.email,
            created_at: user.created_at,
        }
    }
}

#[derive(Debug, Deserialize, Validate)]
pub struct CreateTaskRequest {
    #[validate(length(min = 1, max = 200))]
    pub title: String,
    #[validate(length(max = 2000))]
    pub description: Option<String>,
    pub priority: Option<TaskPriority>,
    pub tags: Option<Vec<String>>,
    pub due_date: Option<DateTime<Utc>>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct UpdateTaskRequest {
    #[validate(length(min = 1, max = 200))]
    pub title: Option<String>,
    #[validate(length(max = 2000))]
    pub description: Option<String>,
    pub status: Option<TaskStatus>,
    pub priority: Option<TaskPriority>,
    pub tags: Option<Vec<String>>,
    pub due_date: Option<DateTime<Utc>>,
}

#[derive(Debug, Deserialize)]
pub struct TaskQuery {
    pub status: Option<TaskStatus>,
    pub priority: Option<TaskPriority>,
    pub tag: Option<String>,
    pub sort_by: Option<String>,
    pub order: Option<String>,
    pub page: Option<i64>,
    pub limit: Option<i64>,
}

#[derive(Debug, Serialize)]
pub struct PaginatedTasks {
    pub tasks: Vec<Task>,
    pub total: i64,
    pub page: i64,
    pub limit: i64,
    pub total_pages: i64,
}

#[derive(Debug, Serialize)]
pub struct TaskStats {
    pub total: i64,
    pub todo: i64,
    pub in_progress: i64,
    pub done: i64,
    pub overdue: i64,
    pub by_priority: PriorityStats,
}

#[derive(Debug, Serialize)]
pub struct PriorityStats {
    pub low: i64,
    pub medium: i64,
    pub high: i64,
    pub urgent: i64,
}

資料庫

src/db.rs

use sqlx::{PgPool, postgres::PgPoolOptions};
use uuid::Uuid;

use crate::models::*;
use crate::error::AppError;

pub async fn init_db(database_url: &str) -> Result<PgPool, sqlx::Error> {
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(database_url)
        .await?;

    // 執行資料庫遷移
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await?;

    tracing::info!("Database migrations completed successfully");

    Ok(pool)
}

// User operations
pub async fn create_user(
    pool: &PgPool,
    username: &str,
    email: &str,
    password_hash: &str,
) -> Result<User, AppError> {
    let user = sqlx::query_as::<_, User>(
        r#"
        INSERT INTO users (username, email, password_hash)
        VALUES ($1, $2, $3)
        RETURNING *
        "#,
    )
    .bind(username)
    .bind(email)
    .bind(password_hash)
    .fetch_one(pool)
    .await
    .map_err(|e| {
        if let sqlx::Error::Database(db_err) = &e {
            if db_err.is_unique_violation() {
                return AppError::Conflict("Email or username already exists".to_string());
            }
        }
        AppError::Database(e)
    })?;

    Ok(user)
}

pub async fn find_user_by_email(
    pool: &PgPool,
    email: &str,
) -> Result<Option<User>, AppError> {
    let user = sqlx::query_as::<_, User>(
        "SELECT * FROM users WHERE email = $1"
    )
    .bind(email)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}

pub async fn find_user_by_id(
    pool: &PgPool,
    user_id: Uuid,
) -> Result<Option<User>, AppError> {
    let user = sqlx::query_as::<_, User>(
        "SELECT * FROM users WHERE id = $1"
    )
    .bind(user_id)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}

// Task operations
pub async fn create_task(
    pool: &PgPool,
    user_id: Uuid,
    req: CreateTaskRequest,
) -> Result<Task, AppError> {
    let task = sqlx::query_as::<_, Task>(
        r#"
        INSERT INTO tasks (user_id, title, description, priority, tags, due_date)
        VALUES ($1, $2, $3, $4, $5, $6)
        RETURNING *
        "#,
    )
    .bind(user_id)
    .bind(&req.title)
    .bind(&req.description)
    .bind(req.priority.unwrap_or(TaskPriority::Medium))
    .bind(&req.tags)
    .bind(req.due_date)
    .fetch_one(pool)
    .await?;

    Ok(task)
}

pub async fn get_user_tasks(
    pool: &PgPool,
    user_id: Uuid,
    query: TaskQuery,
) -> Result<PaginatedTasks, AppError> {
    let page = query.page.unwrap_or(1).max(1);
    let limit = query.limit.unwrap_or(10).min(100);
    let offset = (page - 1) * limit;

    // 建立基礎查詢
    let mut where_clauses = vec!["user_id = $1".to_string()];
    let mut param_count = 1;

    if query.status.is_some() {
        param_count += 1;
        where_clauses.push(format!("status = ${}", param_count));
    }

    if query.priority.is_some() {
        param_count += 1;
        where_clauses.push(format!("priority = ${}", param_count));
    }

    if query.tag.is_some() {
        param_count += 1;
        where_clauses.push(format!("${} = ANY(tags)", param_count));
    }

    let where_clause = where_clauses.join(" AND ");
    
    // 排序
    let sort_by = match query.sort_by.as_deref() {
        Some("title") => "title",
        Some("priority") => "priority",
        Some("due_date") => "due_date",
        Some("status") => "status",
        _ => "created_at",
    };
    
    let order = match query.order.as_deref() {
        Some("asc") => "ASC",
        _ => "DESC",
    };

    // 計算總數
    let total_query = format!("SELECT COUNT(*) FROM tasks WHERE {}", where_clause);
    let mut total_q = sqlx::query_scalar::<_, i64>(&total_query).bind(user_id);
    
    if let Some(status) = &query.status {
        total_q = total_q.bind(status);
    }
    if let Some(priority) = &query.priority {
        total_q = total_q.bind(priority);
    }
    if let Some(tag) = &query.tag {
        total_q = total_q.bind(tag);
    }
    
    let total = total_q.fetch_one(pool).await?;

    // 查詢資料
    let tasks_query = format!(
        "SELECT * FROM tasks WHERE {} ORDER BY {} {} LIMIT ${} OFFSET ${}",
        where_clause,
        sort_by,
        order,
        param_count + 1,
        param_count + 2
    );

    let mut tasks_q = sqlx::query_as::<_, Task>(&tasks_query).bind(user_id);
    
    if let Some(status) = &query.status {
        tasks_q = tasks_q.bind(status);
    }
    if let Some(priority) = &query.priority {
        tasks_q = tasks_q.bind(priority);
    }
    if let Some(tag) = &query.tag {
        tasks_q = tasks_q.bind(tag);
    }
    
    tasks_q = tasks_q.bind(limit).bind(offset);
    
    let tasks = tasks_q.fetch_all(pool).await?;

    let total_pages = (total as f64 / limit as f64).ceil() as i64;

    Ok(PaginatedTasks {
        tasks,
        total,
        page,
        limit,
        total_pages,
    })
}

pub async fn get_task_by_id(
    pool: &PgPool,
    user_id: Uuid,
    task_id: Uuid,
) -> Result<Option<Task>, AppError> {
    let task = sqlx::query_as::<_, Task>(
        "SELECT * FROM tasks WHERE id = $1 AND user_id = $2",
    )
    .bind(task_id)
    .bind(user_id)
    .fetch_optional(pool)
    .await?;

    Ok(task)
}

pub async fn update_task(
    pool: &PgPool,
    user_id: Uuid,
    task_id: Uuid,
    req: UpdateTaskRequest,
) -> Result<Task, AppError> {
    // 先檢查任務是否存在且屬於該用戶
    let existing = get_task_by_id(pool, user_id, task_id).await?;
    if existing.is_none() {
        return Err(AppError::NotFound("Task not found".to_string()));
    }

    let mut updates = vec![];
    let mut param_count = 1;

    if req.title.is_some() {
        param_count += 1;
        updates.push(format!("title = ${}", param_count));
    }

    if req.description.is_some() {
        param_count += 1;
        updates.push(format!("description = ${}", param_count));
    }

    if req.status.is_some() {
        param_count += 1;
        updates.push(format!("status = ${}", param_count));
    }

    if req.priority.is_some() {
        param_count += 1;
        updates.push(format!("priority = ${}", param_count));
    }

    if req.tags.is_some() {
        param_count += 1;
        updates.push(format!("tags = ${}", param_count));
    }

    if req.due_date.is_some() {
        param_count += 1;
        updates.push(format!("due_date = ${}", param_count));
    }

    if updates.is_empty() {
        return get_task_by_id(pool, user_id, task_id)
            .await?
            .ok_or_else(|| AppError::NotFound("Task not found".to_string()));
    }

    let update_clause = updates.join(", ");
    let sql = format!(
        "UPDATE tasks SET {} WHERE id = $1 RETURNING *",
        update_clause
    );

    let mut query = sqlx::query_as::<_, Task>(&sql).bind(task_id);

    if let Some(title) = req.title {
        query = query.bind(title);
    }
    if let Some(description) = req.description {
        query = query.bind(description);
    }
    if let Some(status) = req.status {
        query = query.bind(status);
    }
    if let Some(priority) = req.priority {
        query = query.bind(priority);
    }
    if let Some(tags) = req.tags {
        query = query.bind(tags);
    }
    if let Some(due_date) = req.due_date {
        query = query.bind(due_date);
    }

    let task = query.fetch_one(pool).await?;

    Ok(task)
}

pub async fn delete_task(
    pool: &PgPool,
    user_id: Uuid,
    task_id: Uuid,
) -> Result<(), AppError> {
    let result = sqlx::query(
        "DELETE FROM tasks WHERE id = $1 AND user_id = $2"
    )
    .bind(task_id)
    .bind(user_id)
    .execute(pool)
    .await?;

    if result.rows_affected() == 0 {
        return Err(AppError::NotFound("Task not found".to_string()));
    }

    Ok(())
}

pub async fn get_task_stats(
    pool: &PgPool,
    user_id: Uuid,
) -> Result<TaskStats, AppError> {
    let total: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks WHERE user_id = $1"
    )
    .bind(user_id)
    .fetch_one(pool)
    .await?;

    let todo: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status = 'Todo'"
    )
    .bind(user_id)
    .fetch_one(pool)
    .await?;

    let in_progress: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status = 'InProgress'"
    )
    .bind(user_id)
    .fetch_one(pool)
    .await?;

    let done: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status = 'Done'"
    )
    .bind(user_id)
    .fetch_one(pool)
    .await?;

    let now = chrono::Utc::now();
    let overdue: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks 
         WHERE user_id = $1 
         AND status != 'Done' 
         AND due_date < $2"
    )
    .bind(user_id)
    .bind(now)
    .fetch_one(pool)
    .await?;

    let low: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND priority = 'Low'"
    )
    .bind(user_id)
    .fetch_one(pool)
    .await?;

    let medium: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND priority = 'Medium'"
    )
    .bind(user_id)
    .fetch_one(pool)
    .await?;

    let high: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND priority = 'High'"
    )
    .bind(user_id)
    .fetch_one(pool)
    .await?;

    let urgent: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND priority = 'Urgent'"
    )
    .bind(user_id)
    .fetch_one(pool)
    .await?;

    Ok(TaskStats {
        total,
        todo,
        in_progress,
        done,
        overdue,
        by_priority: PriorityStats {
            low,
            medium,
            high,
            urgent,
        },
    })
}

Jwt 實現

src/auth.rs

use axum::{
    extract::{Request, State},
    http::StatusCode,
    middleware::Next,
    response::Response,
};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;

use crate::error::AppError;

const TOKEN_EXPIRATION: u64 = 24 * 60 * 60; // 24 hours

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    pub sub: String, // user_id
    pub exp: u64,    // expiration time
    pub iat: u64,    // issued at
}

pub fn create_token(user_id: Uuid, secret: &str) -> Result<String, AppError> {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();

    let claims = Claims {
        sub: user_id.to_string(),
        exp: now + TOKEN_EXPIRATION,
        iat: now,
    };

    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .map_err(|_| AppError::InternalError("Failed to create token".to_string()))?;

    Ok(token)
}

pub fn verify_token(token: &str, secret: &str) -> Result<Claims, AppError> {
    let token_data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::default(),
    )
    .map_err(|e| {
        tracing::warn!("Token verification failed: {:?}", e);
        AppError::Unauthorized("Invalid or expired token".to_string())
    })?;

    Ok(token_data.claims)
}

#[derive(Clone)]
pub struct AuthState {
    pub jwt_secret: String,
    pub pool: PgPool,
}

pub async fn auth_middleware(
    State(state): State<AuthState>,
    mut req: Request,
    next: Next,
) -> Result<Response, AppError> {
    let auth_header = req
        .headers()
        .get("Authorization")
        .and_then(|h| h.to_str().ok())
        .ok_or_else(|| AppError::Unauthorized("Missing authorization header".to_string()))?;

    let token = auth_header
        .strip_prefix("Bearer ")
        .ok_or_else(|| AppError::Unauthorized("Invalid authorization format".to_string()))?;

    let claims = verify_token(token, &state.jwt_secret)?;
    
    // 驗證用戶是否存在
    let user_id = Uuid::parse_str(&claims.sub)
        .map_err(|_| AppError::Unauthorized("Invalid user ID in token".to_string()))?;
    
    let user = crate::db::find_user_by_id(&state.pool, user_id)
        .await?
        .ok_or_else(|| AppError::Unauthorized("User not found".to_string()))?;

    req.extensions_mut().insert(claims);
    req.extensions_mut().insert(user);

    Ok(next.run(req).await)
}

錯誤處理

src/error.rs

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
    
    #[error("Validation error: {0}")]
    Validation(String),
    
    #[error("Unauthorized: {0}")]
    Unauthorized(String),
    
    #[error("Not found: {0}")]
    NotFound(String),
    
    #[error("Conflict: {0}")]
    Conflict(String),
    
    #[error("Internal error: {0}")]
    InternalError(String),

    #[error("Bad request: {0}")]
    BadRequest(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::Database(ref e) => {
                tracing::error!("Database error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Database error occurred".to_string())
            }
            AppError::Validation(ref msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            AppError::Unauthorized(ref msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
            AppError::NotFound(ref msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::Conflict(ref msg) => (StatusCode::CONFLICT, msg.clone()),
            AppError::BadRequest(ref msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            AppError::InternalError(ref msg) => {
                tracing::error!("Internal error: {}", msg);
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
            }
        };

        let body = Json(json({
            "error": message,
            "status": status.as_u16(),
        }));

        (status, body).into_response()
    }
}

路由

src/handler.rs

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Extension, Json,
};
use sqlx::PgPool;
use uuid::Uuid;
use validator::Validate;

use crate::{
    auth::{create_token, Claims},
    config::Config,
    db,
    error::AppError,
    models::*,
};

// 健康檢查
pub async fn health_check() -> &'static str {
    "OK"
}

// 用戶註冊
pub async fn register(
    State(pool): State<PgPool>,
    State(config): State<Config>,
    Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<AuthResponse>), AppError> {
    // 驗證輸入
    req.validate()
        .map_err(|e| AppError::Validation(e.to_string()))?;

    // 檢查用戶是否已存在
    if db::find_user_by_email(&pool, &req.email).await?.is_some() {
        return Err(AppError::Conflict("Email already registered".to_string()));
    }

    // 雜湊密碼
    let password_hash = bcrypt::hash(&req.password, bcrypt::DEFAULT_COST)
        .map_err(|e| {
            tracing::error!("Failed to hash password: {:?}", e);
            AppError::InternalError("Failed to hash password".to_string())
        })?;

    // 建立用戶
    let user = db::create_user(&pool, &req.username, &req.email, &password_hash).await?;

    // 生成 JWT token
    let token = create_token(user.id, &config.jwt_secret)?;

    tracing::info!("New user registered: {}", user.email);

    Ok((
        StatusCode::CREATED,
        Json(AuthResponse {
            token,
            user: user.into(),
        }),
    ))
}

// 用戶登入
pub async fn login(
    State(pool): State<PgPool>,
    State(config): State<Config>,
    Json(req): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, AppError> {
    // 查找用戶
    let user = db::find_user_by_email(&pool, &req.email)
        .await?
        .ok_or_else(|| AppError::Unauthorized("Invalid email or password".to_string()))?;

    // 驗證密碼
    let valid = bcrypt::verify(&req.password, &user.password_hash)
        .map_err(|e| {
            tracing::error!("Failed to verify password: {:?}", e);
            AppError::InternalError("Failed to verify password".to_string())
        })?;

    if !valid {
        return Err(AppError::Unauthorized("Invalid email or password".to_string()));
    }

    // 生成 token
    let token = create_token(user.id, &config.jwt_secret)?;

    tracing::info!("User logged in: {}", user.email);

    Ok(Json(AuthResponse {
        token,
        user: user.into(),
    }))
}

// 獲取當前用戶資訊
pub async fn get_current_user(
    Extension(user): Extension<User>,
) -> Json<UserResponse> {
    Json(user.into())
}

// 建立任務
pub async fn create_task_handler(
    State(pool): State<PgPool>,
    Extension(claims): Extension<Claims>,
    Json(req): Json<CreateTaskRequest>,
) -> Result<(StatusCode, Json<Task>), AppError> {
    req.validate()
        .map_err(|e| AppError::Validation(e.to_string()))?;

    let user_id = Uuid::parse_str(&claims.sub)
        .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;

    let task = db::create_task(&pool, user_id, req).await?;

    tracing::info!("Task created: {} by user {}", task.id, user_id);

    Ok((StatusCode::CREATED, Json(task)))
}

// 查詢任務列表(支援分頁和篩選)
pub async fn list_tasks_handler(
    State(pool): State<PgPool>,
    Extension(claims): Extension<Claims>,
    Query(query): Query<TaskQuery>,
) -> Result<Json<PaginatedTasks>, AppError> {
    let user_id = Uuid::parse_str(&claims.sub)
        .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;

    let result = db::get_user_tasks(&pool, user_id, query).await?;

    Ok(Json(result))
}

// 查詢單一任務
pub async fn get_task_handler(
    State(pool): State<PgPool>,
    Extension(claims): Extension<Claims>,
    Path(task_id): Path<Uuid>,
) -> Result<Json<Task>, AppError> {
    let user_id = Uuid::parse_str(&claims.sub)
        .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;

    let task = db::get_task_by_id(&pool, user_id, task_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Task not found".to_string()))?;

    Ok(Json(task))
}

// 更新任務
pub async fn update_task_handler(
    State(pool): State<PgPool>,
    Extension(claims): Extension<Claims>,
    Path(task_id): Path<Uuid>,
    Json(req): Json<UpdateTaskRequest>,
) -> Result<Json<Task>, AppError> {
    req.validate()
        .map_err(|e| AppError::Validation(e.to_string()))?;

    let user_id = Uuid::parse_str(&claims.sub)
        .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;

    let task = db::update_task(&pool, user_id, task_id, req).await?;

    tracing::info!("Task updated: {} by user {}", task_id, user_id);

    Ok(Json(task))
}

// 刪除任務
pub async fn delete_task_handler(
    State(pool): State<PgPool>,
    Extension(claims): Extension<Claims>,
    Path(task_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
    let user_id = Uuid::parse_str(&claims.sub)
        .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;

    db::delete_task(&pool, user_id, task_id).await?;

    tracing::info!("Task deleted: {} by user {}", task_id, user_id);

    Ok(StatusCode::NO_CONTENT)
}

// 獲取任務統計
pub async fn get_task_stats_handler(
    State(pool): State<PgPool>,
    Extension(claims): Extension<Claims>,
) -> Result<Json<TaskStats>, AppError> {
    let user_id = Uuid

主程式 main.rs

mod auth;
mod config;
mod db;
mod error;
mod handlers;
mod models;

use axum::{
    middleware,
    routing::{delete, get, post, put},
    Router,
};
use tower_http::{
    cors::{Any, CorsLayer},
    trace::TraceLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

use crate::{
    auth::{auth_middleware, AuthState},
    config::Config,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 初始化日誌
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info,sqlx=warn".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    // 載入配置
    let config = Config::from_env();

    // 初始化資料庫
    tracing::info!("Connecting to database...");
    let pool = db::init_db(&config.database_url).await?;
    tracing::info!("Database connection established");

    // 設定 CORS
    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_methods(Any)
        .allow_headers(Any);

    // 認證狀態
    let auth_state = AuthState {
        jwt_secret: config.jwt_secret.clone(),
        pool: pool.clone(),
    };

    // 公開路由(無需認證)
    let public_routes = Router::new()
        .route("/api/health", get(handlers::health_check))
        .route("/api/auth/register", post(handlers::register))
        .route("/api/auth/login", post(handlers::login))
        .with_state(pool.clone())
        .with_state(config.clone());

    // 受保護路由(需要認證)
    let protected_routes = Router::new()
        .route("/api/me", get(handlers::get_current_user))
        .route("/api/tasks", post(handlers::create_task_handler))
        .route("/api/tasks", get(handlers::list_tasks_handler))
        .route("/api/tasks/stats", get(handlers::get_task_stats_handler))
        .route("/api/tasks/:id", get(handlers::get_task_handler))
        .route("/api/tasks/:id", put(handlers::update_task_handler))
        .route("/api/tasks/:id", delete(handlers::delete_task_handler))
        .layer(middleware::from_fn_with_state(
            auth_state,
            auth_middleware,
        ))
        .with_state(pool.clone())
        .with_state(config.clone());

    // 組合所有路由
    let app = Router::new()
        .merge(public_routes)
        .merge(protected_routes)
        .layer(cors)
        .layer(TraceLayer::new_for_http());

    // 啟動伺服器
    let listener = tokio::net::TcpListener::bind(&config.server_addr).await?;
    
    tracing::info!("🚀 Server running on http://{}", config.server_addr);
    tracing::info!("📚 API Documentation:");
    tracing::info!("  - POST   /api/auth/register  - 註冊新用戶");
    tracing::info!("  - POST   /api/auth/login     - 用戶登入");
    tracing::info!("  - GET    /api/me             - 獲取當前用戶資訊");
    tracing::info!("  - POST   /api/tasks          - 建立任務");
    tracing::info!("  - GET    /api/tasks          - 查詢任務列表");
    tracing::info!("  - GET    /api/tasks/stats    - 獲取任務統計");
    tracing::info!("  - GET    /api/tasks/:id      - 查詢單一任務");
    tracing::info!("  - PUT    /api/tasks/:id      - 更新任務");
    tracing::info!("  - DELETE /api/tasks/:id      - 刪除任務");
    
    axum::serve(listener, app).await?;

    Ok(())
}

開始操作

首先我們肯定需要先 migration

# 安裝 sqlx-cli
cargo install sqlx-cli --no-default-features --features postgres

# 執行遷移
sqlx migrate run

用 curl 進行測試

  1. 註冊
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "username": "michael ho",
    "email": "michael@example.com",
    "password": "secure_password123"
  }'
  1. 登入
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "michael@example.com",
    "password": "secure_password123"
  }'

  1. 取得資訊
curl http://localhost:3000/api/me \
  -H "Authorization: Bearer YOUR_TOKEN"
  1. 建立任務
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "title": "完成 Rust 鐵人賽文章",
    "description": "撰寫第 28 天的任務管理 API 文章",
    "priority": "High",
    "tags": ["文件", "鐵人賽", "重要"],
    "due_date": "2025-10-15T18:00:00Z"
  }'

回應

{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "完成 Rust 鐵人賽文章",
  "description": "撰寫第 28 天的任務管理 API 文章",
  "status": "Todo",
  "priority": "High",
  "tags": ["文件", "鐵人賽", "重要"],
  "due_date": "2025-10-15T18:00:00Z",
  "completed_at": null,
  "created_at": "2025-10-11T10:35:00Z",
  "updated_at": "2025-10-11T10:35:00Z"
}

其他部分

# 查詢所有任務(第 1 頁,每頁 10 筆)
curl "http://localhost:3000/api/tasks?page=1&limit=10" \
  -H "Authorization: Bearer YOUR_TOKEN"

# 按狀態篩選
curl "http://localhost:3000/api/tasks?status=InProgress" \
  -H "Authorization: Bearer YOUR_TOKEN"

# 按優先級篩選
curl "http://localhost:3000/api/tasks?priority=High" \
  -H "Authorization: Bearer YOUR_TOKEN"

# 按標籤篩選
curl "http://localhost:3000/api/tasks?tag=重要" \
  -H "Authorization: Bearer YOUR_TOKEN"

# 組合篩選和排序
curl "http://localhost:3000/api/tasks?status=Todo&priority=High&sort_by=due_date&order=asc" \
  -H "Authorization: Bearer YOUR_TOKEN"

容器化

Dockerfile

# Dockerfile
FROM rust:1.75 as builder

WORKDIR /app
COPY . .

RUN cargo build --release

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    ca-certificates \
    libssl3 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/task-manager-api /usr/local/bin/

EXPOSE 3000

CMD ["task-manager-api"]

docker-compose.yml

# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres
    restart: always
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: task_manager
      POSTGRES_USER: task_user
      POSTGRES_PASSWORD: secure_password
    volumes:
      - postgres_data:/var/lib/postgresql/data

  api:
    build: .
    environment:
      DATABASE_URL: postgresql://task_user:secure_password@postgres:5432/task_manager
      JWT_SECRET: your-secret-key
      RUST_LOG: info
    ports:
      - "3000:3000"
    depends_on:
      - postgres

volumes:
  postgres_data:
docker-compose up -d

完成


上一篇
系統效能基準測試 - CPU/磁碟/網路效能測試工具
系列文
Rust 實戰專案集:30 個漸進式專案從工具到服務28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言