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.

Megjegyzés

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.

Tipp

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(())
}
Jó tudni

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.

Tipp

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.

Figyelem

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
Megjegyzés

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.