iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Rust

Rust 後端入門系列 第 10

Day10 Axum 請求處理全攻略:從提取器到資料驗證

  • 分享至 

  • xImage
  •  

今天的教學內容包含:

  • Path、Query、Headers 提取器的使用方法
  • 如何撰寫自訂提取器
  • 表單資料處理
  • 請求驗證與資料檢查

為什麼要學這些?

  • 在後端開發中,可靠且清晰地取得請求資料是基礎。Axum 的提取器設計能讓程式碼更模組化、型別安全、容易測試。
  • 正確處理檔案上傳、表單與驗證,能避免安全漏洞(如檔案注入、SQL/JSON 注入、超大上傳)並提供更佳的開發體驗。
  • 自訂提取器與驗證流程能把共通邏輯抽離,提高可維護性與一致性,對中大型專案非常關鍵。

Path、Query、Headers 提取器

其中,Path以及Query在前一天已經使用過,範例可以參考當時的程式碼。

Path

作用是從路徑取得參數

注意:

  • 如果 path 的參數無法轉型(例如非數字),Axum 會回傳400錯誤。
  • 有多個參數時,可使用struct來接收,當然別忘了加上#[derive(Serialize)]。

Query

作用是取得查詢字串

注意:

  • 要確保輸入的參數是空的時候,程式不會出錯,建議使用Option與unwrap_or。

Headers

作用是讀取與檢查標頭,接下來的範例將示範如何讀取 HTTP header(Authorization)並回傳處理結果。

範例程式碼

use axum::{
    http::HeaderMap,
    response::{IntoResponse, Json, Response},
    routing::get,
    Router,
};
use serde::Serialize;
use tokio::net::TcpListener;

#[derive(Serialize)]
struct Profile {
    username: String,
}

async fn profile(headers: HeaderMap) -> Response {
    if let Some(value) = headers.get("authorization") {
        if let Ok(s) = value.to_str() {
            if s.starts_with("Bearer ") {
                let profile = Profile {
                    username: "user1".to_string(),
                };
                return (axum::http::StatusCode::OK, Json(profile)).into_response();
            }
        }
    }
    (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/profile", get(profile));
    
    let listener = TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("Failed to bind to address");
    
    println!("Server running on http://127.0.0.1:3000");
    
    axum::serve(listener, app)
        .await
        .expect("Failed to start server");
}

輸入cargo run開始測試:

首先我們將輸入:

curl -X GET "http://127.0.0.1:3000/profile" -H "Authorization: Bearer TOKEN123" -H "Content-Type: application/json" -v

我們可以得到以下的輸出,還可以看到200的狀態碼:

*   Trying 127.0.0.1:3000...
* Connected to 127.0.0.1 (127.0.0.1) port 3000
> GET /profile HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/8.4.0
> Accept: */*
> Authorization: Bearer TOKEN123
> Content-Type: application/json
>
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 20
< date: Wed, 24 Sep 2025 03:07:08 GMT
<
{"username":"user1"}* Connection #0 to host 127.0.0.1 left intact

換成沒有帶上Bearer,測試一下:

curl -X GET "http://127.0.0.1:3000/profile" -H "Authorization: Nothing" -H "Content-Type: application/json"

這次的輸出就不同了,狀態碼也換成401

*   Trying 127.0.0.1:3000...
* Connected to 127.0.0.1 (127.0.0.1) port 3000
> GET /profile HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/8.4.0
> Accept: */*
> Authorization: Nothing
> Content-Type: application/json
>
< HTTP/1.1 401 Unauthorized
< content-type: text/plain; charset=utf-8
< content-length: 12
< date: Wed, 24 Sep 2025 03:06:00 GMT
<
Unauthorized* Connection #0 to host 127.0.0.1 left intact

當然真正的專案中,授權與驗證沒有這麽隨便,這邊簡化了驗證部分。


自訂提取器(Custom Extractors)

接下來,我們將完成一個具有自訂提取器的Axum專案。

範例程式碼

use axum::{
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
    response::Json,
    routing::get,
    Router,
};
use serde::Serialize;
use tokio::net::TcpListener;

#[derive(Debug)]
struct AuthUser {
    user_id: i64,
}

impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = (StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        if let Some(auth) = parts.headers.get("authorization") {
            if let Ok(s) = auth.to_str() {
                if let Some(token) = s.strip_prefix("Bearer ") {
                    if token.starts_with("user:") {
                        if let Ok(id) = token["user:".len()..].parse::<i64>() {
                            return Ok(AuthUser { user_id: id });
                        }
                    }
                }
            }
        }
        Err((StatusCode::UNAUTHORIZED, "Unauthorized"))
    }
}

#[derive(Serialize)]
struct UserInfo {
    user_id: i64,
    message: String,
}

async fn get_user_info(auth_user: AuthUser) -> Json<UserInfo> {
    Json(UserInfo {
        user_id: auth_user.user_id,
        message: format!("Hello, user {}!", auth_user.user_id),
    })
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/user", get(get_user_info));
    
    let listener = TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("Failed to bind to address");
    
    println!("Server running on http://127.0.0.1:3000");
    println!("GET /user - Get user info (requires Bearer user:ID token)");
    
    axum::serve(listener, app)
        .await
        .expect("Failed to start server");
}

讓我解釋各部分的功能:

  1. 定義了一個儲存已認證用戶資訊的結構體 (AuthUser)
#[derive(Debug)]
struct AuthUser {
    user_id: i64,
}
  1. FromRequestParts 實作
impl<S> FromRequestParts<S> for AuthUser
// ...
// ...
// ...

這部分實作了自動從 HTTP 請求中提取認證資訊的邏輯:

認證流程**:**

  • 檢查請求標頭中是否有Authorization欄位
  • 驗證值是否以Bearer開頭
  • 檢查 token 是否符合 user:數字 的格式
  • 解析出用戶 ID
  • 成功則返回 AuthUser 結構體,失敗則返回 401 錯誤
  1. 受保護的路由

程式定義了需要認證的端點:

  • GET /user : 返回用戶資訊的 JSON

這個端點會自動要求認證,因為它們的 handler 函數參數包含 AuthUser,如果沒有 AuthUser 將回傳401錯誤。

測試範例

成功的範例:

curl -X GET "http://127.0.0.1:3000/user" -H "Authorization: Bearer user:123" -H "Content-Type: application/json" -v

輸出結果:

*   Trying 127.0.0.1:3000...
* Connected to 127.0.0.1 (127.0.0.1) port 3000
> GET /user HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/8.4.0
> Accept: */*
> Authorization: Bearer user:123
> Content-Type: application/json
>
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 44
< date: Wed, 24 Sep 2025 03:52:08 GMT
<
{"user_id":123,"message":"Hello, user 123!"}* Connection #0 to host 127.0.0.1 left intact

失敗的範例,會收到401,因為不符合 user:數字 的格式:

curl -X GET "http://127.0.0.1:3000/user" -H "Authorization: Bearer user:nothing" -H "Content-Type: application/json" -v

表單資料處理

我們接下來要完成一個處理用戶註冊表單的伺服器,能夠接收表單資料並返回 JSON 響應。

程式核心

  1. 表單資料結構體 (SignupForm)

    • name :用戶名稱(必填)
    • age :可省略的年齡欄位(0-255 歲)
    • 使用 Deserialize 自動解析表單資料
  2. 響應結構體 (SignupResp)

    • 定義 API 返回的 JSON 格式
    • 包含操作是否成功和用戶名稱
  3. 表單處理函數(signup函數)

    • Form(payload) : 使用解構語法直接提取表單內容
    • 自動將表單資料反序列化為 SignupForm 結構
    • 返回 JSON 格式的響應

流程圖

用戶送出表單
    ↓
POST /submit
Content-Type: application/x-www-form-urlencoded
    ↓
Axum 接收請求
    ↓
Form 提取器解析資料
    ↓
資料符合 SignupForm 格式嗎?
    ├─ 否 → 返回 422 Unprocessable Entity
    └─ 是 → 繼續
        ↓
執行 signup 函數
    ↓
建立 SignupResp
    ↓
序列化為 JSON
    ↓
返回給客戶端

程式碼

use axum::{
    extract::Form,
    response::Json,
    routing::post,
    Router,
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;

#[derive(Deserialize)]
struct SignupForm {
    name: String,
    age: Option<u8>,
}

#[derive(Serialize)]
struct SignupResp {
    success: bool,
    name: String,
}

async fn signup(Form(payload): Form<SignupForm>) -> Json<SignupResp> {
    Json(SignupResp {
        success: true,
        name: payload.name,
    })
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/submit", post(signup));
    
    let listener = TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("Failed to bind to address");
    
    println!("Server running on http://127.0.0.1:3000");
    println!("POST /submit - Submit signup form");
    
    axum::serve(listener, app)
        .await
        .expect("Failed to start server");
}

測試範例

  • 完整表單(包含所有欄位)

    curl -X POST http://127.0.0.1:3000/submit -H "Content-Type: application/x-www-form-urlencoded" -d "name=user1&age=114"
    
    # 回傳內容
    {"success":true,"name":"user1"}
    

如果去除age部分,回傳的內容不會改變,因為age是可省略的欄位

  • 缺少必填欄位(會失敗)
curl -X POST http://127.0.0.1:3000/submit -H "Content-Type: application/x-www-form-urlencoded" -d "age=114"

# 回傳內容
Failed to deserialize form body: missing field `name`

請求驗證與資料檢查

現在,我們來完成一個有自動資料驗證的 RESTful API,在處理用戶註冊前會先驗證所有輸入資料的格式和規則。

程式核心

  1. 驗證規則定義 (CreateUserRequest)

    各欄位的驗證規則:

    • name: 長度必須在 2-30 個字元之間
    • email: 必須是有效的電子郵件格式
    • password:至少 8 個字元

    Derive 巨集:

    • Deserialize: 自動從JSON格式解析資料
    • Validate: 啟用自動驗證功能
  2. 錯誤響應結構 (ErrorResp)

    • 提供結構化的錯誤訊息
    • details包含具體的錯誤細節
  3. 驗證處理邏輯(create_user函數)

    1. 接收並解析 JSON 資料
    2. 呼叫 payload.validate() 執行驗證
    3. 驗證失敗時返回詳細錯誤訊息
    4. 驗證成功時返回成功響應

流程圖

客戶端發送 JSON 請求
    ↓
POST /users
Content-Type: application/json
    ↓
Axum 解析 JSON → 失敗 → 400 Bad Request
    ↓ 成功
建立 CreateUserRequest 物件
    ↓
執行 validate() 方法
    ↓
檢查所有驗證規則
    ├─ name 長度 (2-30)
    ├─ email 格式
    └─ password 長度 (≥8)
        ↓
所有規則都通過?
    ├─ 否 → 收集錯誤細節
    │      ↓
    │      建立 ErrorResp
    │      ↓
    │      返回 400 + 錯誤詳情
    │
    └─ 是 → 返回 200 + {"success": true}

程式碼

use axum::{extract::Json, response::IntoResponse, routing::post, Router};
use serde::{Deserialize, Serialize};
use validator::Validate;
use tokio::net::TcpListener;

#[derive(Debug, Deserialize, Validate)]
struct CreateUserRequest {
    #[validate(length(min = 2, max = 30))]
    name: String,
    #[validate(email)]
    email: String,
    #[validate(length(min = 8))]
    password: String,
}

async fn create_user(Json(payload): Json<CreateUserRequest>) -> impl IntoResponse {
    if let Err(e) = payload.validate() {
        let error_response = serde_json::json!({
            "message": "Validation error",
            "details": e.to_string()
        });
        return (axum::http::StatusCode::BAD_REQUEST, axum::Json(error_response));
    }

    (axum::http::StatusCode::OK, axum::Json(serde_json::json!({"success": true})))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/users", post(create_user));
    
    let listener = TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("Failed to bind to address");
    
    println!("Server running on http://127.0.0.1:3000");
    println!("POST /users - Create user with validation");
    
    axum::serve(listener, app)
        .await
        .expect("Failed to start server");
}

我們需要在cargo.toml中,添加依賴套件:

serde_json = "1"
validator = { version = "0.20", features = ["derive"] }

測試範例

  • 無錯誤的範例

    curl -X POST http://127.0.0.1:3000/users -H "Content-Type: application/json" -d "{\"name\": \"user1\", \"email\": \"user1@aaa.com\", \"password\": \"12345678\"}"
    
    # 結果
    {"success":true}
    
  • 錯誤:名稱過短

    curl -X POST http://127.0.0.1:3000/users -H "Content-Type: application/json" -d "{\"name\": \"a\", \"email\": \"user1@aaa.com\", \"password\": \"12345678\"}"
    
    # 結果
    {"details":"name: Validation error: length [{\"max\": Number(30), \"min\": Number(2), \"value\": String(\"a\")}]","message":"Validation error"}
    
  • 多個欄位出錯(名稱過短、email格式錯誤、密碼是空的)

    curl -X POST http://127.0.0.1:3000/users -H "Content-Type: application/json" -d "{\"name\": \"a\", \"email\": \"not-email\", \"password\": \"\"}"
    
    #結果
    {"details":"name: Validation error: length [{\"min\": Number(2), \"max\": Number(30), \"value\": String(\"a\")}]\nemail: Validation error: email [{\"value\": String(\"not-email\")}]\npassword: Validation error: length [{\"min\": Number(8), \"value\": String(\"\")}]","message":"Validation error"}
    

除了我們使用到的規則,還支援的以下的格式:

// 數字範圍
#[validate(range(min = 18, max = 100))]
age: u8,

// 正規表示式
#[validate(regex = "^[A-Z]{2}[0-9]{4}$")]
text: String,

// URL 格式
#[validate(url)]
website: String,

// 必須包含特定內容
#[validate(contains = "@")]
email_alt: String,

上一篇
Day 9 Axum GET/POST:打造 RESTful API 的第一步
下一篇
Day 11 Tower與中間件(Middleware)
系列文
Rust 後端入門12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言