Miért az Axum?

Ha webes API-t szeretnél írni Rustban, hamar szembejön néhány ismert név: Actix-web, Warp, Rocket – és persze az Axum. Az Axum a Tokio csapat saját frameworkje, és pontosan azért szeretik sokan, mert nem varázsol semmit: a tower middleware ökoszisztémára épül, a routing pedig egyszerű, típusbiztos függvényhívásokból áll össze.

Nincs makró-mágia a route-definícióknál, nincs saját DSL – csak sima Rust függvények, amikből a handlerek lesznek, és a típusrendszer segít abban, hogy a query paramétereket, JSON body-kat és állapotot helyesen dolgozd fel.

Megjegyzés

A cikk az Axum 0.6-os verzióját és a Tokio async runtime-ot használja. Ez a mai napon (2023 szeptember) a stabil, ajánlott kombináció.

Kezdjük egy minimális Cargo.toml-lal:

[package]
name = "axum-rest-demo"
version = "0.1.0"
edition = "2021"

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

Router és handler függvények

Az Axum lelke a Router. Ehhez adogatod hozzá az útvonalakat, mindegyikhez egy handler függvénnyel. A handler egy egyszerű async függvény, amelynek a paraméterei és visszatérési típusa mondják meg az Axumnak, mit is kell kinyernie a requestből, illetve hogyan kell választ generálnia.

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

async fn hello() -> &'static str {
    "Szia, ez itt egy Axum API!"
}

async fn health_check() -> &'static str {
    "OK"
}

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

    let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("Szerver fut: http://{addr}");

    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Ennyi is elég egy futóképes szerverhez! A axum::Server::bind a Hyper-t indítja a háttérben, a Router pedig Service trait-et implementál, amit a tower ökoszisztéma minden más darabja is megért – ezért lehet könnyen middleware-eket rárakni.

Tipp

Ha több route-od van, érdemes külön modulba szervezni a handlereket, és a main.rs-ben csak a Router::new() összeállítást tartani. Így a projekt gyorsan olvasható marad.

Query paraméterek és JSON body kezelése

Az Axum extraktor-alapú: a handler paraméterei mondják meg, mit szeretnél kinyerni a requestből. A Query<T> a query stringből deszerializál, a Json<T> pedig a body-ból.

Nézzünk egy egyszerű keresési endpointot query paraméterrel:

use axum::extract::Query;
use serde::Deserialize;

#[derive(Deserialize)]
struct SearchParams {
    q: String,
    limit: Option<u32>,
}

async fn search(Query(params): Query<SearchParams>) -> String {
    let limit = params.limit.unwrap_or(10);
    format!("Keresés: '{}' (limit: {})", params.q, limit)
}

Ha a kliens meghívja a /search?q=rust&limit=5 URL-t, az Axum automatikusan deszerializálja a SearchParams struktúrát – ha valami hiányzik vagy hibás típusú, egy 400-as választ küld vissza magától, még mielőtt a handler kódja lefutna.

POST endpointnál a JSON body kezelése hasonlóan egyszerű:

use axum::{extract::Json, routing::post, Router};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

#[derive(Serialize)]
struct UserResponse {
    id: u64,
    name: String,
    email: String,
}

async fn create_user(Json(payload): Json<CreateUser>) -> Json<UserResponse> {
    let response = UserResponse {
        id: 1,
        name: payload.name,
        email: payload.email,
    };
    Json(response)
}

A Json<T> extraktor a request body-t próbálja serde_json-nal deszerializálni a T típusba, a visszatérési Json<T> pedig automatikusan application/json content type-tal szerializálja a választ.

Figyelem

Az extraktorok sorrendje fontos: a body-t felemésztő extraktor (mint a Json<T>) csak egyszer, és mindig utolsóként szerepelhet a paraméterlistában, mivel a request body csak egyszer olvasható ki.

Állapot megosztása (State)

A legtöbb valós API-nak szüksége van megosztott állapotra: adatbázis-kapcsolatra, cache-re vagy egyszerűen egy in-memory tárolóra. Az Axumban ezt a State extraktorral és a with_state metódussal oldjuk meg.

use axum::{extract::State, routing::get, Router};
use std::sync::{Arc, Mutex};

#[derive(Default)]
struct AppState {
    counter: Mutex<u64>,
}

async fn increment(State(state): State<Arc<AppState>>) -> String {
    let mut counter = state.counter.lock().unwrap();
    *counter += 1;
    format!("Számláló: {}", *counter)
}

#[tokio::main]
async fn main() {
    let shared_state = Arc::new(AppState::default());

    let app = Router::new()
        .route("/increment", get(increment))
        .with_state(shared_state);

    let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000));
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Az Arc<AppState> klónozódik minden requestnél (ami csak egy referenciaszám-növelés, nem drága), a belső Mutex pedig biztosítja a szinkron hozzáférést. Ha adatbázis-poolod van (pl. sqlx::PgPool), ugyanezt a mintát követve tudod becsatornázni.

Tipp

Ha a state csak olvasásra kell (nincs mutáció), elhagyhatod a Mutex-et, és egyszerűen csak Arc<T>-t adhatsz át – így elkerülöd a felesleges lock overheadet.

Hibakezelés és egyedi response típusok

Eddig minden handlerünk "boldog úton" futott – de mi történik, ha hiba adódik? Az Axumban a hibakezelés az IntoResponse trait köré épül: bármi, ami ezt implementálja, válaszként visszaadható.

Definiáljunk egy egyedi hibatípust, ami különböző HTTP státuszkódokat és JSON hibaüzeneteket ad vissza:

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

enum ApiError {
    NotFound(String),
    BadRequest(String),
    Internal,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            ApiError::Internal => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Belső szerverhiba".to_string(),
            ),
        };

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

async fn get_user(id: axum::extract::Path<u64>) -> Result<Json<serde_json::Value>, ApiError> {
    let user_id = id.0;

    if user_id == 0 {
        return Err(ApiError::BadRequest("Az ID nem lehet 0".to_string()));
    }

    if user_id > 100 {
        return Err(ApiError::NotFound(format!("Nincs ilyen felhasználó: {user_id}")));
    }

    Ok(Json(json!({ "id": user_id, "name": "Teszt Felhasználó" })))
}

Mivel a handler Result<T, ApiError>-t ad vissza, és mind a T, mind az ApiError implementálja az IntoResponse-t, az Axum automatikusan helyesen konvertálja a választ – akár sikeres, akár hibás az útvonal.

Jó tudni

Az IntoResponse trait a kulcs a tiszta hibakezeléshez. Ne unwrap()-elj a handlerekben – panic esetén az Axum bár nem omlik össze teljesen (a Hyper elkapja), de a kliens csak egy csupasz 500-as választ kap, ami nem túl hasznos debug szempontból.

Az API tesztelése és futtatása lokálisan

Minden összeraktunk egy nagyobb main függvényben, és futtatás után cargo run-nal indíthatjuk:

use axum::{
    routing::{get, post},
    Router,
};
use std::sync::Arc;

#[tokio::main]
async fn main() {
    let shared_state = Arc::new(AppState::default());

    let app = Router::new()
        .route("/", get(hello))
        .route("/health", get(health_check))
        .route("/search", get(search))
        .route("/users", post(create_user))
        .route("/users/:id", get(get_user))
        .route("/increment", get(increment))
        .with_state(shared_state);

    let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("Szerver fut: http://{addr}");

    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Ezt indítva curl-lal könnyen tesztelheted:

curl http://127.0.0.1:3000/
curl "http://127.0.0.1:3000/search?q=rust&limit=3"
curl -X POST http://127.0.0.1:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Anna", "email": "anna@example.com"}'
curl http://127.0.0.1:3000/users/42
curl http://127.0.0.1:3000/users/0

Ha a projektben automatizált teszteket is szeretnél, az Axum Router-je egy tower::Service, amit közvetlenül tesztelhetsz tower::ServiceExt::oneshot hívással, anélkül, hogy valódi TCP portot kellene nyitnod. Ez különösen jól jön CI környezetben, ahol gyors, izolált integrációs teszteket akarsz futtatni.

Megjegyzés

A tower::ServiceExt trait-hez add hozzá a tower = { version = "0.4", features = ["util"] } dependency-t a Cargo.toml-hoz, ha unit tesztet írsz a router ellen.

Összefoglalás

Az Axum filozófiája letisztult: a Rust típusrendszerére bízza a nehéz munkát, és nem próbál újra feltalálni semmit, ami a tower ökoszisztémában már megvan. Láttuk, hogyan épül fel egy router handlerekkel, hogyan nyerhetsz ki query paramétert és JSON body-t, hogyan oszthatsz meg állapotot típusbiztosan, és hogyan alakíthatsz ki egyedi hibatípusokat az IntoResponse traiten keresztül.

Ez csak az alapokat fedte le – a következő lépés lehet egy valódi adatbázis-integráció (pl. sqlx), autentikáció middleware-rel, vagy strukturált logolás a tracing crate segítségével. De ha eddig eljutottál, már van egy stabil alapod, amire bármilyen komolyabb REST API-t rá tudsz építeni.