Viết REST API bằng Axum trong Rust

1. Tổng quan

Axum là web framework của đội ngũ Tokio. Nó xây trên hyper và tower, dùng hệ thống type của Rust để kiểm tra handler ngay lúc compile.

Trong bài này, bạn sẽ viết một REST API quản lý đơn hàng (Order) với đủ các thao tác CRUD. Bài cũng đi qua cách chia sẻ state giữa các handler, trả lỗi có cấu trúc JSON và viết test cho router mà không cần mở cổng mạng.

Code trong bài dùng Axum 0.8 và Rust edition 2021 trở lên.

2. Thiết lập dự án

Tạo project mới:

cargo new order-api
cd order-api

Thêm dependency vào Cargo.toml:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

Vai trò của từng crate:

  • axum: router, extractor và kiểu response.
  • tokio: async runtime. Axum bắt buộc chạy trên Tokio.
  • serde và serde_json: chuyển đổi struct sang JSON và ngược lại.
  • tower và http-body-util: chỉ dùng khi test. Chúng giúp gửi request trực tiếp vào router và đọc body của response.

3. Ứng dụng Axum đầu tiên

Bắt đầu bằng một endpoint kiểm tra sức khỏe của service:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(|| async { "OK" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

Đoạn code này làm ba việc:

  • #[tokio::main] biến main thành hàm async chạy trên Tokio runtime.
  • Router::new().route(...) gắn path /health với method GET. Handler là một closure async trả về &'static str. Axum tự chuyển chuỗi này thành response 200 OK với Content-Type: text/plain.
  • axum::serve nhận một TcpListener và bắt đầu phục vụ request.

Chạy thử:

cargo run
curl -i http://localhost:3000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2

OK

4. Model và shared state

4.1. Định nghĩa model

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub amount: u64,
}

#[derive(Debug, Deserialize)]
pub struct CreateOrder {
    pub customer: String,
    pub amount: u64,
}

Tách CreateOrder ra khỏi Order là có chủ đích. Client không được tự gửi id, vì server mới là bên sinh ra nó. Nếu dùng chung một struct, bạn phải xử lý trường hợp client gửi id tùy ý.

4.2. State dùng chung

Trong bài này, dữ liệu được lưu trong bộ nhớ. Trong thực tế, chỗ này thường là một connection pool như sqlx::PgPool.

use std::collections::HashMap;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};

#[derive(Clone, Default)]
pub struct AppState {
    orders: Arc<RwLock<HashMap<u64, Order>>>,
    next_id: Arc<AtomicU64>,
}

Axum clone state cho mỗi request. Vì vậy AppState phải implement Clone, và việc clone phải rẻ. Bọc dữ liệu trong Arc đáp ứng cả hai yêu cầu: clone chỉ tăng bộ đếm tham chiếu, còn mọi handler cùng trỏ tới một vùng dữ liệu.

RwLock cho phép nhiều thread đọc song song, nhưng chỉ một thread được ghi tại một thời điểm. AtomicU64 dùng để sinh id mà không cần lock.

Lưu ý: Ở đây dùng std::sync::RwLock vì không có .await nào nằm giữa lúc lấy và nhả lock. Nếu bạn cần giữ lock qua một lệnh .await, hãy chuyển sang tokio::sync::RwLock. Nếu không, future sẽ không còn là Send, và handler sẽ không compile.

5. Xử lý lỗi bằng IntoResponse

Trước khi viết handler, hãy định nghĩa kiểu lỗi chung. Mọi kiểu implement IntoResponse đều có thể được handler trả về, kể cả nằm trong Result.

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

#[derive(Debug)]
pub enum AppError {
    NotFound(u64),
    Validation(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(id) => (StatusCode::NOT_FOUND, format!("Order {id} not found")),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

Tuple (StatusCode, Json<_>) cũng implement IntoResponse. Nhờ đó, bạn có thể đặt status code và body cùng lúc.

Từ giờ, handler chỉ cần trả Err(AppError::NotFound(id)). Client sẽ nhận được response như sau:

HTTP/1.1 404 Not Found
content-type: application/json

{"error":"Order 42 not found"}

6. Viết các handler CRUD

6.1. Tạo đơn hàng

use axum::extract::State;
use std::sync::atomic::Ordering;

async fn create_order(
    State(state): State<AppState>,
    Json(input): Json<CreateOrder>,
) -> Result<(StatusCode, Json<Order>), AppError> {
    if input.customer.trim().is_empty() {
        return Err(AppError::Validation("customer must not be empty".into()));
    }
    let id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
    let order = Order { id, customer: input.customer, amount: input.amount };
    state.orders.write().unwrap().insert(id, order.clone());
    Ok((StatusCode::CREATED, Json(order)))
}

Mỗi tham số của handler là một extractor:

  • State(state) lấy AppState đã gắn vào router.
  • Json(input) đọc body và deserialize thành CreateOrder. Nếu body sai định dạng, Axum tự trả lỗi trước khi hàm của bạn kịp chạy.

fetch_add trả về giá trị cũ, nên cần cộng thêm 1 để id bắt đầu từ 1. Handler trả về 201 Created kèm đơn hàng vừa tạo.

Lưu ý: Extractor đọc body, như Json, String hay Bytes, phải là tham số cuối cùng. Body chỉ đọc được một lần. Đặt Json trước State sẽ gây lỗi compile kiểu “the trait Handler<_, _> is not implemented”, và thông báo này khá khó hiểu nếu bạn chưa biết nguyên nhân.

6.2. Lấy một đơn hàng theo id

use axum::extract::Path;

async fn get_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Order>, AppError> {
    state
        .orders
        .read()
        .unwrap()
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(AppError::NotFound(id))
}

Path<u64> parse đoạn {id} trên URL thành u64. Nếu client gọi /orders/abc, Axum trả 400 Bad Request mà không cần thêm code nào. Còn khi không tìm thấy đơn hàng, ok_or biến None thành lỗi NotFound.

6.3. Liệt kê có phân trang

use axum::extract::Query;

#[derive(Debug, Deserialize)]
pub struct Pagination {
    #[serde(default)]
    offset: usize,
    #[serde(default = "default_limit")]
    limit: usize,
}

fn default_limit() -> usize {
    20
}

async fn list_orders(
    State(state): State<AppState>,
    Query(page): Query<Pagination>,
) -> Json<Vec<Order>> {
    let orders = state.orders.read().unwrap();
    let mut result: Vec<Order> = orders.values().cloned().collect();
    result.sort_by_key(|o| o.id);
    let result = result.into_iter().skip(page.offset).take(page.limit).collect();
    Json(result)
}

Query<Pagination> đọc query string như ?offset=10&limit=5. Nhờ #[serde(default)], client có thể bỏ qua cả hai tham số mà vẫn nhận được kết quả hợp lệ.

Code phải sắp xếp theo id vì HashMap không đảm bảo thứ tự. Nếu bỏ bước này, kết quả phân trang sẽ thay đổi giữa các lần gọi.

6.4. Xóa đơn hàng

async fn delete_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, AppError> {
    match state.orders.write().unwrap().remove(&id) {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(AppError::NotFound(id)),
    }
}

Bản thân StatusCode cũng implement IntoResponse. Trả về 204 No Content là cách quen thuộc cho thao tác xóa thành công.

7. Ghép router

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/health", get(|| async { "OK" }))
        .route("/orders", get(list_orders).post(create_order))
        .route("/orders/{id}", get(get_order).delete(delete_order))
        .with_state(state)
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app(AppState::default())).await.unwrap();
}

Router này được tổ chức như sau:

  • Một path có thể gắn nhiều method bằng cách nối chuỗi: get(...).post(...).
  • with_state gắn state vào router. Sau bước này, router có kiểu Router<()> và sẵn sàng để phục vụ request.
  • Hàm app được tách riêng để test có thể dựng router mà không cần mở cổng.

Thử tạo và đọc một đơn hàng:

curl -i -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer":"Nguyen Van An","amount":150000}'

curl http://localhost:3000/orders/1
HTTP/1.1 201 Created
content-type: application/json

{"id":1,"customer":"Nguyen Van An","amount":150000}

{"id":1,"customer":"Nguyen Van An","amount":150000}

Lưu ý: Từ Axum 0.8, tham số path dùng cú pháp {id} thay cho /:id của các phiên bản trước. Nếu bạn chép code cũ dùng /:id, ứng dụng sẽ panic ngay khi dựng router. Cú pháp wildcard cũng đổi từ *rest thành {*rest}.

8. Test router

Router implement trait Service của tower. Vì vậy, bạn có thể gửi request thẳng vào router bằng oneshot, không cần chạy server thật. Thêm module test vào cuối main.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    fn post_order(json: &str) -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/orders")
            .header("content-type", "application/json")
            .body(Body::from(json.to_owned()))
            .unwrap()
    }

    #[tokio::test]
    async fn create_then_get_order() {
        let app = app(AppState::default());

        let response = app
            .clone()
            .oneshot(post_order(r#"{"customer":"An","amount":150000}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::CREATED);

        let response = app
            .oneshot(Request::get("/orders/1").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);

        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let order: Order = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(order, Order { id: 1, customer: "An".into(), amount: 150000 });
    }

    #[tokio::test]
    async fn empty_customer_is_rejected() {
        let response = app(AppState::default())
            .oneshot(post_order(r#"{"customer":"  ","amount":1}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
    }

    #[tokio::test]
    async fn unknown_order_returns_404() {
        let response = app(AppState::default())
            .oneshot(Request::get("/orders/42").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}

Ba điểm cần chú ý trong đoạn test:

  • oneshot tiêu thụ router. Muốn gửi nhiều request lên cùng một state, bạn cần clone() router trước mỗi lần gọi. State vẫn được chia sẻ vì nó nằm trong Arc.
  • BodyExt::collect gom toàn bộ body thành Bytes, sau đó serde_json parse lại thành Order.
  • Mỗi test tạo AppState mới, nên các test độc lập với nhau và có thể chạy song song.

Chạy test:

cargo test
running 3 tests
test tests::unknown_order_returns_404 ... ok
test tests::empty_customer_is_rejected ... ok
test tests::create_then_get_order ... ok

test result: ok. 3 passed; 0 failed

9. Những lỗi hay gặp

Thiếu header Content-Type. Extractor Json yêu cầu header application/json. Thiếu header này, client nhận 415 Unsupported Media Type dù body hoàn toàn hợp lệ. Đây là lỗi thường gặp nhất khi test bằng curl.

Phân biệt mã lỗi của Json. JSON sai cú pháp trả 400. JSON đúng cú pháp nhưng thiếu field hoặc sai kiểu trả 422. Mặc định, các response này có body dạng text thuần, không phải JSON. Nếu API cần định dạng lỗi thống nhất, hãy bọc Json trong một extractor riêng và chuyển JsonRejection thành AppError.

Dùng unwrap() trên lock trong production. Nếu một thread panic khi đang giữ lock, lock sẽ bị “poisoned”, và mọi lệnh unwrap() sau đó cũng panic theo. Trong code thật, hãy xử lý trường hợp này hoặc dùng một cơ sở dữ liệu thực sự.

Nhét mọi thứ vào một AppState lớn. Khi ứng dụng phát triển, bạn có thể dùng FromRef để handler chỉ lấy phần state cần thiết, ví dụ State<PgPool>, thay vì cả AppState.

Khi nào không nên dùng Axum? Nếu bạn cần một framework “đủ pin” với ORM, template, admin và migration tích hợp sẵn, Axum sẽ đòi bạn tự lắp ghép nhiều thứ. Nếu đội ngũ chưa quen async Rust, các thông báo lỗi trait bound của handler cũng sẽ là rào cản đáng kể ở giai đoạn đầu.

10. Kết luận

Bạn vừa xây dựng một REST API CRUD hoàn chỉnh bằng Axum 0.8. Các khái niệm chính gồm:

  • Router: gắn path và method với handler.
  • Extractor: State, Path, Query và Json lấy dữ liệu từ request một cách type-safe.
  • Shared state: dữ liệu dùng chung được bọc trong Arc.
  • Xử lý lỗi: implement IntoResponse để trả lỗi JSON có cấu trúc.
  • Test: gửi request trực tiếp vào router bằng tower::ServiceExt::oneshot.

Bước tiếp theo hợp lý là thay HashMap bằng sqlx kết nối PostgreSQL. Bạn cũng nên thêm middleware từ tower-http, như TraceLayer để log request và CorsLayer để cấu hình CORS. Phần lớn handler sẽ không cần thay đổi, vì logic HTTP đã được tách khỏi tầng lưu trữ.

rustaxumrest-apitokiobackend