Ha eddig csak actix-web-bel vagy warp-pal dolgoztál, itt az idő, hogy kipróbáld az Axum-ot – a Tokio csapat saját webes keretrendszerét. Az Axum 0.6-os verziója friss, kényelmes routing API-t hozott, és tökéletesen passzol az sqlx aszinkron, compile-time ellenőrzött SQL query-jeihez. Ebben az oktatóanyagban egy egyszerű "todo" API-t építünk fel: listázás, létrehozás, módosítás, törlés – a klasszikus CRUD.
A cikk Rust 1.66-tal és Axum 0.6-tal készült. Ha nálad más verziók vannak feltelepítve, érdemes a Cargo.lock-ot ellenőrizni, mert az Axum API-ja verziók között gyakran változik.
Projekt előkészítése – Cargo.toml
Először hozzunk létre egy új binary crate-et:
cargo new axum-todo-api
cd axum-todo-api
A Cargo.toml-ba a következő függőségeket vesszük fel:
[dependencies]
axum = \"0.6\"
tokio = { version = \"1\", features = [\"full\"] }
sqlx = { version = \"0.6\", features = [\"runtime-tokio-rustls\", \"postgres\", \"macros\", \"migrate\", \"chrono\"] }
serde = { version = \"1\", features = [\"derive\"] }
serde_json = \"1\"
dotenvy = \"0.15\"
tracing = \"0.1\"
tracing-subscriber = \"0.3\"
Az sqlx macros feature-je kell a query! és query_as! makrókhoz, a migrate pedig a migrációk futtatásához. A runtime-tokio-rustls biztosítja, hogy az sqlx a Tokio runtime-on fusson TLS támogatással.
Használj dotenvy-t (a dotenv crate karbantartott forkja) a .env fájl betöltésére – így az adatbázis connection string nem kerül be a kódba.
Adatbázis kapcsolat és migrációk sqlx-cli-vel
Telepítsd az sqlx-cli eszközt, amivel migrációkat tudunk generálni és futtatni:
cargo install sqlx-cli --no-default-features --features rustls,postgres
Hozz létre egy .env fájlt:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/todo_db
Ezután generáljuk az első migrációt:
sqlx database create
sqlx migrate add create_todos_table
A létrejövő migrations/xxxx_create_todos_table.sql fájlba írjuk a táblát:
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Futtassuk le:
sqlx migrate run
A connection pool-t az main.rs-ben hozzuk létre, és megosztjuk az Axum állapotán keresztül:
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
pool: sqlx::PgPool,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
let database_url = std::env::var(\"DATABASE_URL\")?;
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await?;
sqlx::migrate!().run(&pool).await?;
let state = Arc::new(AppState { pool });
let app = build_router(state);
axum::Server::bind(&\"0.0.0.0:3000\".parse()?)
.serve(app.into_make_service())
.await?;
Ok(())
}
A sqlx::migrate!() makró build időben olvassa be a migrations mappát, tehát ha új migrációt adsz hozzá, cargo build-et is kell futtatnod, mielőtt élesben elindítanád az appot.
Route-ok és handler függvények Axum-ban
Az Axum-ban a router egyszerű builder-mintát követ. Definiáljuk a végpontjainkat:
use axum::{routing::{get, post, put, delete}, Router};
use std::sync::Arc;
fn build_router(state: Arc<AppState>) -> Router {
Router::new()
.route(\"/todos\", get(list_todos).post(create_todo))
.route(\"/todos/:id\", get(get_todo).put(update_todo).delete(delete_todo))
.with_state(state)
}
A handler függvények az Axum extractoraival dolgoznak – a State, Path és Json a leggyakoribbak. Nézzünk egy egyszerű listázó handlert:
use axum::{extract::State, Json};
use std::sync::Arc;
async fn list_todos(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Todo>>, ApiError> {
let todos = sqlx::query_as!(
Todo,
\"SELECT id, title, completed, created_at FROM todos ORDER BY id\"
)
.fetch_all(&state.pool)
.await?;
Ok(Json(todos))
}
A Todo struct-ot a serde::Serialize és sqlx::FromRow derive-okkal kell felszerelni:
#[derive(serde::Serialize, sqlx::FromRow)]
struct Todo {
id: i32,
title: String,
completed: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
CRUD végpontok sqlx query! makróval
A query! és query_as! makrók compile time-ban ellenőrzik az SQL szintaxist és a típusokat – ehhez az sqlx-nek élő adatbázis-kapcsolatra van szüksége build közben (vagy egy előre generált sqlx-data.json fájlra cargo sqlx prepare után offline módhoz).
Lássuk a create és update végpontokat:
use axum::extract::Path;
#[derive(serde::Deserialize)]
struct CreateTodo {
title: String,
}
async fn create_todo(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateTodo>,
) -> Result<Json<Todo>, ApiError> {
let todo = sqlx::query_as!(
Todo,
\"INSERT INTO todos (title) VALUES ($1) RETURNING id, title, completed, created_at\",
payload.title
)
.fetch_one(&state.pool)
.await?;
Ok(Json(todo))
}
#[derive(serde::Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
async fn update_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<i32>,
Json(payload): Json<UpdateTodo>,
) -> Result<Json<Todo>, ApiError> {
let Some(existing) = sqlx::query_as!(Todo, \"SELECT id, title, completed, created_at FROM todos WHERE id = $1\", id)
.fetch_optional(&state.pool)
.await?
else {
return Err(ApiError::NotFound);
};
let title = payload.title.unwrap_or(existing.title);
let completed = payload.completed.unwrap_or(existing.completed);
let todo = sqlx::query_as!(
Todo,
\"UPDATE todos SET title = $1, completed = $2 WHERE id = $3 RETURNING id, title, completed, created_at\",
title,
completed,
id
)
.fetch_one(&state.pool)
.await?;
Ok(Json(todo))
}
Észrevehetted, hogy a let Some(existing) = ... else { ... } mintát használtam – ez a Rust 1.65-ben stabilizált let-else szintaxis, ami sokkal olvashatóbbá teszi a "korai kilépés hiányzó érték esetén" mintát, mint egy match vagy if let blokk.
A query_as! makró minden build-en lefuttatja a lekérdezést az adatbázison típusellenőrzés céljából. Ha CI-ban futtatod, gondoskodj róla, hogy legyen elérhető Postgres instance, vagy használd az SQLX_OFFLINE=true módot a cargo sqlx prepare által generált metaadatokkal.
Hibakezelés és egységes válaszformátum
Egy jól megírt API nem dob nyers 500-as hibát minden bakinál. Készítsünk egy közös ApiError enum-ot, ami implementálja az Axum IntoResponse trait-jét:
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
enum ApiError {
NotFound,
Database(sqlx::Error),
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
ApiError::Database(err)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, \"A keresett elem nem található\".to_string()),
ApiError::Database(err) => {
tracing::error!(?err, \"adatbázis hiba\");
(StatusCode::INTERNAL_SERVER_ERROR, \"Belső szerverhiba\".to_string())
}
};
let body = Json(json!({ \"error\": message }));
(status, body).into_response()
}
}
Ezzel minden handler visszatérési típusa Result<Json<T>, ApiError> lehet, és a ? operátor automatikusan konvertál sqlx::Error-ból ApiError-ba a From implementáció miatt. A kliens mindig egységes JSON hibaformátumot kap, függetlenül attól, hogy melyik végpont dobta a hibát.
Sose küldd vissza a nyers sqlx::Error szöveget a kliensnek – könnyen kiszivárogtathat belső táblaneveket vagy SQL részleteket. A tracing::error!-ral logold szerver oldalon, a kliensnek pedig csak egy semleges üzenetet adj.
Build gyorsítása: sparse registry (kitekintés)
A nagyobb crate.io-s függőségfák (mint amilyen az sqlx is a sok feature flag-jével) build közben sokáig tudják tartani az index letöltését, mert a Cargo alapból egy hatalmas git registry-t klónoz le. A Cargo csapat már dolgozik egy "sparse" (HTTP-alapú, csak a szükséges metaadatokat letöltő) registry protokollon, ami jelentősen csökkenti az index frissítésének idejét.
Jelenleg ez még kísérleti (unstable) feature, nightly toolchain-en és explicit flag-gel kapcsolható be:
# .cargo/config.toml (nightly)
[unstable]
sparse-registry = true
A sparse registry stabilizálása a Cargo csapat nyilvános roadmapjén szerepel, várhatóan egy közelgő kiadásban válik alapértelmezetté – de a mai napon (Rust 1.66) ez még csak nightly-n, unstable flag mögött próbálható ki. Éles projektben ne támaszkodj rá.
Amíg ez stabil nem lesz, a build idő csökkentésére inkább a cargo build --release cache-elésére (pl. sccache) vagy a felesleges feature flag-ek kikapcsolására érdemes koncentrálni – az sqlx-nél például csak azokat a runtime/TLS feature-öket kapcsold be, amikre valóban szükséged van.
Összefoglalás
Egy Axum + sqlx alapú REST API felépítése nem bonyolult, ha végigmész a lépéseken: migrációk sqlx-cli-vel, típusbiztos query-k a query_as! makróval, tiszta routing az Axum router builder-rel, és egységes hibaválasz egy közös ApiError típussal. A let-else szintaxis és a compile-time SQL ellenőrzés kombinációja igazán kényelmes, típusbiztos fejlesztői élményt ad – közben pedig a Tokio async runtime gondoskodik arról, hogy az API skálázhatóan tudjon sok egyidejű kérést kezelni. A következő lépés lehet a middleware-ek (pl. autentikáció, request logging) bevezetése, de az alapok, amiket itt megtanultál, minden komolyabb Axum projekt gerincét képezik.