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.
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.
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.
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.
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.
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": "[email protected]"}'
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.
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.