REST API mit Axum in Rust schreiben

1. Übersicht

Axum ist ein Web-Framework des Tokio-Teams. Es basiert auf hyper und tower und nutzt Rusts Typensystem, um Handler bereits zur Compile-Zeit zu überprüfen.

In diesem Artikel wirst du eine REST-API zur Verwaltung von Bestellungen (Order) mit vollständigen CRUD-Operationen schreiben. Der Artikel behandelt auch, wie du State zwischen Handlern teilst, strukturierte JSON-Fehler zurückgibst und den Router testest, ohne einen Netzwerkport öffnen zu müssen.

Der Code in diesem Artikel verwendet Axum 0.8 und Rust Edition 2021 oder höher.

2. Projekteinrichtung

Erstelle ein neues Projekt:

cargo new order-api
cd order-api

Füge die Abhängigkeiten zu Cargo.toml hinzu:

[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"

Die Rolle der einzelnen Crates:

  • axum: Router, Extractor und Response-Typen.
  • tokio: Async-Runtime. Axum muss auf Tokio laufen.
  • serde und serde_json: Konvertierung von Structs zu JSON und zurück.
  • tower und http-body-util: nur beim Testen. Sie ermöglichen das direkte Senden von Requests an den Router und das Auslesen des Response-Body.

3. Deine erste Axum-Anwendung

Beginne mit einem Health-Check-Endpoint für den 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();
}

Dieser Code macht drei Dinge:

  • #[tokio::main] wandelt main in eine asynchrone Funktion um, die auf der Tokio-Runtime läuft.
  • Router::new().route(...) bindet den Pfad /health an die Methode GET. Der Handler ist ein asynchroner Closure, der &'static str zurückgibt. Axum konvertiert diesen String automatisch in eine 200 OK-Response mit Content-Type: text/plain.
  • axum::serve nimmt einen TcpListener entgegen und beginnt, Requests zu bearbeiten.

Versuche es:

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 und gemeinsamer State

4.1. Model definieren

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,
}

Das Trennen von CreateOrder und Order ist beabsichtigt. Der Client sollte nicht selbst die id senden, da nur der Server diese generiert. Wenn du denselben Struct verwendest, musst du den Fall handeln, dass der Client eine beliebige id sendet.

4.2. Gemeinsamer State

In diesem Artikel werden die Daten im Speicher gespeichert. In der Praxis wäre dies normalerweise ein Connection Pool wie 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 klont den State für jeden Request. Deshalb muss AppState Clone implementieren, und das Klonen muss günstig sein. Das Wrappen der Daten in Arc erfüllt beide Anforderungen: Klonen erhöht nur den Referenzzähler, und alle Handler zeigen auf dieselbe Datenmenge.

RwLock erlaubt mehreren Threads, parallel zu lesen, aber nur einem Thread zu schreiben. AtomicU64 wird zum Generieren von ids ohne Lock verwendet.

Hinweis: Wir verwenden std::sync::RwLock, da es kein .await zwischen dem Acquire und Release des Locks gibt. Wenn du den Lock über einen .await-Befehl halten musst, wechsle zu tokio::sync::RwLock. Andernfalls wird das Future nicht mehr Send sein und der Handler wird nicht kompilieren.

5. Fehlerbehandlung mit IntoResponse

Bevor du Handler schreibst, definiere einen gemeinsamen Fehlertyp. Jeder Typ, der IntoResponse implementiert, kann vom Handler zurückgegeben werden, auch in einem 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()
    }
}

Das Tuple (StatusCode, Json<_>) implementiert auch IntoResponse. Damit kannst du Statuscode und Body gleichzeitig setzen.

Von nun an muss der Handler nur noch Err(AppError::NotFound(id)) zurückgeben. Der Client erhält eine Response wie diese:

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

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

6. CRUD-Handler schreiben

6.1. Bestellung erstellen

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)))
}

Jeder Parameter des Handlers ist ein Extractor:

  • State(state) ruft die AppState ab, die an den Router gebunden wurde.
  • Json(input) liest den Body und deserialisiert ihn in CreateOrder. Wenn der Body falsch formatiert ist, gibt Axum automatisch einen Fehler zurück, bevor deine Funktion ausgeführt wird.

fetch_add gibt den alten Wert zurück, also müssen wir 1 addieren, damit id bei 1 beginnt. Der Handler gibt 201 Created mit der gerade erstellten Bestellung zurück.

Hinweis: Extractor, die den Body lesen, wie Json, String oder Bytes, müssen der letzte Parameter sein. Der Body kann nur einmal gelesen werden. Das Platzieren von Json vor State führt zu einem Compilerfehler wie “the trait Handler<_, _> is not implemented”, und diese Nachricht ist ziemlich verwirrend, wenn du nicht weißt warum.

6.2. Eine Bestellung nach ID abrufen

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> parst das {id}-Segment in der URL in u64. Wenn der Client /orders/abc aufruft, gibt Axum 400 Bad Request zurück, ohne dass du zusätzlichen Code schreiben musst. Wenn die Bestellung nicht gefunden wird, konvertiert ok_or den Wert None in einen NotFound-Fehler.

6.3. Auflistung mit Pagination

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> liest den Query-String wie ?offset=10&limit=5. Dank #[serde(default)] kann der Client beide Parameter auslassen und erhält trotzdem ein gültiges Ergebnis.

Der Code muss nach id sortiert werden, da HashMap die Reihenfolge nicht garantiert. Wenn du diesen Schritt auslässt, ändern sich die Paginierungsergebnisse zwischen den Aufrufen.

6.4. Bestellung löschen

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)),
    }
}

StatusCode selbst implementiert auch IntoResponse. 204 No Content zurückzugeben ist die konventionelle Antwort auf einen erfolgreichen Löschvorgang.

7. Router zusammenstellen

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();
}

Dieser Router ist wie folgt organisiert:

  • Ein Pfad kann mehrere Methoden durch Verkettung binden: get(...).post(...).
  • with_state bindet den State an den Router. Nach diesem Schritt hat der Router den Typ Router<()> und ist bereit, Requests zu bearbeiten.
  • Die Funktion app ist getrennt, damit Tests den Router dorthin bauen können, ohne einen Port zu öffnen.

Versuche, eine Bestellung zu erstellen und zu lesen:

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}

Hinweis: Ab Axum 0.8 verwenden Pfadparameter die Syntax {id} statt /:id aus früheren Versionen. Wenn du alten Code mit /:id kopierst, wird die Anwendung paniken, wenn der Router gebaut wird. Die Wildcard-Syntax hat sich auch von *rest zu {*rest} geändert.

8. Router testen

Router implementiert das Service-Trait von tower. Dadurch kannst du Requests direkt mit oneshot an den Router senden, ohne einen echten Server zu starten. Füge ein Test-Modul am Ende von main.rs hinzu:

#[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);
    }
}

Drei Punkte sind bei Tests zu beachten:

  • oneshot verbraucht den Router. Um mehrere Requests an denselben State zu senden, musst du den Router vor jedem Aufruf clone(). Der State wird weiterhin geteilt, da er in Arc vorhanden ist.
  • BodyExt::collect sammelt den gesamten Body in Bytes, dann parst serde_json ihn zurück in Order.
  • Jeder Test erstellt einen neuen AppState, daher sind die Tests voneinander unabhängig und können parallel laufen.

Teste ausführen:

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. Häufige Fehler

Fehlender Content-Type-Header. Der Json-Extractor erfordert den Header application/json. Ohne diesen Header erhält der Client 415 Unsupported Media Type, obwohl der Body völlig gültig ist. Dies ist der häufigste Fehler beim Testen mit curl.

Unterscheide die Fehlercodes von Json. Ungültiges JSON gibt 400 zurück. JSON mit korrekter Syntax, aber fehlenden Feldern oder falschen Typen gibt 422 zurück. Standardmäßig haben diese Responses einen Text-Body, nicht JSON. Wenn die API ein einheitliches Fehlerformat benötigt, wickle Json in einen eigenen Extractor ein und konvertiere JsonRejection in AppError.

unwrap() auf Locks in der Produktion verwenden. Wenn ein Thread während des Haltens eines Locks panikt, wird der Lock “vergiftet” und alle nachfolgenden unwrap()-Aufrufe paniken ebenfalls. In echtem Code solltest du diesen Fall handhaben oder eine echte Datenbank verwenden.

Alles in einen großen AppState werfen. Wenn die Anwendung wächst, kannst du FromRef verwenden, damit Handler nur den benötigten Teil des State erhalten, z.B. State<PgPool>, statt des gesamten AppState.

Wann sollte man Axum nicht verwenden? Wenn du ein “Batteries-included”-Framework mit integriertem ORM, Templates, Admin und Migration benötigst, wirst du bei Axum viele Komponenten selbst zusammenstellen müssen. Wenn dein Team nicht mit async Rust vertraut ist, werden die Trait-Bound-Fehlermeldungen des Handlers auch zu Beginn ein erhebliches Hindernis sein.

10. Fazit

Du hast gerade eine vollständige CRUD-REST-API mit Axum 0.8 gebaut. Die Hauptkonzepte sind:

  • Router: bindet Pfade und Methoden an Handler.
  • Extractor: State, Path, Query und Json extrahieren auf type-sichere Weise Daten aus Requests.
  • Gemeinsamer State: gemeinsam genutzte Daten werden in Arc eingewickelt.
  • Fehlerbehandlung: implementiere IntoResponse, um strukturierte JSON-Fehler zurückzugeben.
  • Test: sende Requests direkt mit tower::ServiceExt::oneshot an den Router.

Der nächste logische Schritt ist es, HashMap durch sqlx mit PostgreSQL-Verbindung zu ersetzen. Du solltest auch Middleware aus tower-http hinzufügen, wie TraceLayer zum Protokollieren von Requests und CorsLayer zum Konfigurieren von CORS. Die meisten Handler müssen nicht geändert werden, da die HTTP-Logik von der Speicherebene getrennt ist.

rustaxumrest-apitokiobackend