幾乎是最後的應用了所以回到我的老本行,網頁應用的部分
今天直接來實作 CRUD 去完成我們常見的網頁服務
Web 框架、資料庫操作、JWT 認證、錯誤處理
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
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()),
}
}
}
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,
},
})
}
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
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 -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "michael ho",
"email": "michael@example.com",
"password": "secure_password123"
}'
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "michael@example.com",
"password": "secure_password123"
}'
curl http://localhost:3000/api/me \
-H "Authorization: Bearer YOUR_TOKEN"
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
完成