RustのAxumでREST APIを構築する

1. 概要

AxumはTokioチームが開発したwebフレームワークです。hyperとtowerの上に構築され、Rustの型システムを使ってコンパイル時にhandlerを検査します。

この記事では、注文(Order)を管理するREST APIを完全なCRUD操作で実装します。またhandler間でのstateの共有、JSON形式の構造化されたエラーレスポンス、ネットワークポートを開かずにrouterをテストする方法も説明します。

このコードはAxum 0.8とRust edition 2021以上を使用しています。

2. プロジェクトのセットアップ

新しいプロジェクトを作成します:

cargo new order-api
cd order-api

Cargo.tomlに依存関係を追加します:

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

各crateの役割:

  • axum: router、extractor、およびresponseタイプ。
  • tokio: asyncランタイム。Axumはtokio上で実行する必要があります。
  • serdeとserde_json: structをJSONに変換し、逆も行います。
  • towerとhttp-body-util: テスト時にのみ使用します。requestをrouterに直接送信し、responseのbodyを読み取るのに役立ちます。

3. 最初のAxumアプリケーション

サービスのヘルスチェックエンドポイントから始めましょう:

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

このコードは3つのことを行います:

  • #[tokio::main]はmainをTokioランタイムで実行するasync関数に変更します。
  • Router::new().route(...)はpath /healthをmethod GETにバインドします。handlerは&'static strを返すasync closureです。Axumはこの文字列を自動的に200 OKのresponseに変換し、Content-Type: text/plainを設定します。
  • axum::serveはTcpListenerを受け取ってrequestの処理を開始します。

試してみましょう:

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 and shared state

4.1. Model定義

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

CreateOrderをOrderから分離するのは意図的です。clientはidを自分で送信すべきではありません。サーバーがそれを生成するためです。同じstructを使用した場合、clientが任意のidを送信する場合を処理する必要があります。

4.2. 共有State

この記事では、データはメモリに保存されます。実際には、この部分は通常sqlx::PgPoolのようなconnection poolです。

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は各requestに対してstateをcloneします。したがって、AppStateはCloneを実装する必要があり、cloneは軽量である必要があります。データをArcで囲むことで、両方の要件を満たします。cloneは参照カウンターを増やすだけで、すべてのhandlerが同じデータを指します。

RwLockは複数のスレッドによる並列読み取りを許可しますが、書き込みは一度に1つのスレッドのみです。AtomicU64はlockなしでidを生成するために使用されます。

注意: ここでstd::sync::RwLockを使用しているのは、lockの取得と解放の間に.awaitがないためです。.awaitを超えてlockを保持する必要がある場合は、tokio::sync::RwLockに切り替えてください。そうしないと、futureはSendではなくなり、handlerはコンパイルされません。

5. IntoResponseでのエラーハンドリング

handlerを書く前に、共通のエラータイプを定義しましょう。IntoResponseを実装するあらゆる型はhandlerから返すことができます。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()
    }
}

tuple (StatusCode, Json<_>)もIntoResponseを実装しています。これにより、status codeとbodyを同時に設定できます。

これからは、handlerは単にErr(AppError::NotFound(id))を返すだけです。clientは次のようなresponseを受け取ります:

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

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

6. CRUD handlerの実装

6.1. 注文の作成

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

handlerの各パラメータはextractorです:

  • State(state)はrouterにバインドされたAppStateを取得します。
  • Json(input)はbodyを読み込み、CreateOrderにdeserializeします。bodyの形式が正しくない場合、Axumはあなたの関数が実行される前に自動的にエラーを返します。

fetch_addは古い値を返すため、idが1から始まるように1を追加する必要があります。handlerは新しく作成された注文を含む201 Createdを返します。

注意: bodyを読むextractor(Json、String、Bytesなど)は最後のパラメータである必要があります。bodyは一度だけ読み込むことができます。JsonをStateの前に置くと、「the trait Handler<_, _> is not implemented」というコンパイルエラーが発生します。原因を知らないと、このメッセージはかなり紛らわしいでしょう。

6.2. IDで注文を取得

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>はURL内の{id}セグメントをu64に解析します。clientが/orders/abcを呼び出すと、追加のコードなしにAxumは400 Bad Requestを返します。注文が見つからない場合、ok_orはNoneをNotFoundエラーに変換します。

6.3. ページネーション付きリスト表示

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>は?offset=10&limit=5のようなquery stringを読み込みます。#[serde(default)]のおかげで、clientは両方のパラメータを省略してもまだ有効な結果を得られます。

codeはHashMapが順序を保証しないため、idでソートする必要があります。このステップをスキップすると、ページネーション結果が呼び出し間で変わります。

6.4. 注文の削除

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自体もIntoResponseを実装しています。204 No Contentを返すことは、削除操作が成功したことを示す標準的な方法です。

7. Routerの組み立て

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

このrouterは以下のように構成されています:

  • 1つのpathはget(...).post(...)のようにチェーンすることで複数のmethodにバインドできます。
  • with_stateはstateをrouterにバインドします。この後、routerはRouter<()>型になり、requestを処理する準備が整います。
  • 関数appは分離されており、テストはポートを開かずにrouterを構築できます。

注文を作成して読み取ってみましょう:

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}

注意: Axum 0.8から、pathパラメータは以前の/:idの代わりに{id}構文を使用します。古いコードの/:idをコピーすると、routerを構築するときすぐにpanicが発生します。wildcardの構文も*restから{*rest}に変わりました。

8. Routerのテスト

RouterはtowerのServiceトレイトを実装しています。したがって、実際のサーバーを実行する必要なく、oneshotを使ってrequestをrouterに直接送信できます。main.rsの最後にtestモジュールを追加しましょう:

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

このテストコードで注意すべき3つのポイント:

  • oneshotはrouterをconsumeします。同じstateで複数のrequestを送信したい場合、各呼び出しの前にrouterclone()する必要があります。stateはArc内にあるため、引き続き共有されます。
  • BodyExt::collectは全body全体をBytesに集約し、その後serde_jsonがOrderに解析します。
  • 各テストは新しいAppStateを作成するため、テスト同士は独立しており、並列実行できます。

テストを実行します:

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. よくあるエラー

Content-Typeヘッダーがない。 extractor Jsonにはapplication/jsonヘッダーが必要です。このheaderがないと、bodyが完全に有効でも、clientは415 Unsupported Media Typeを受け取ります。これはcurlでテストするときの最も一般的なエラーです。

Jsonエラーコードの区別。 JSON構文エラーは400を返します。JSON構文は正しいが、fieldが欠落しているか型が異なる場合は422を返します。デフォルトでは、これらのresponseはプレーンテキスト形式で、JSONではありません。APIが統一されたエラー形式を必要とする場合は、Jsonを専用のextractorでラップし、JsonRejectionをAppErrorに変換してください。

本番環境でlockに対してunwrap()を使用する。 threadがlockを保持している間にpanicが発生すると、lockが「poisoned」になり、その後のunwrap()はすべてpanicを起こします。本番コードでは、このケースを処理するか、実際のデータベースを使用してください。

大きなAppStateにすべてを詰め込む。 アプリケーションが成長するにつれて、FromRefを使ってhandlerが必要なstateの部分だけを取得できます。例えば、State<PgPool>のように、AppState全体ではなく。

Axumを使うべきではない場合は? ORM、template、admin、migrationが組み込まれた「バッテリー付き」フレームワークが必要な場合、Axumは多くの部分を自分で組み立てさせます。チームがasync Rustに慣れていない場合、handlerのtrait boundエラーメッセージも初期段階では大きな障害になるでしょう。

10. まとめ

あなたはAxum 0.8で完全なCRUD REST APIを構築しました。主な概念は次のとおりです:

  • Router: pathとmethodをhandlerにバインドします。
  • Extractor: State、Path、Query、Jsonがrequestからデータを型安全に取得します。
  • Shared state: 共有データはArcで囲まれています。
  • エラーハンドリング: 構造化されたJSON エラーを返すためにIntoResponseを実装します。
  • Test: tower::ServiceExt::oneshotを使ってrequestをrouterに直接送信します。

次の合理的なステップは、HashMapをsqlx PostgreSQL接続で置き換えることです。また、tower-httpからmiddlewareを追加する必要があります。例えば、requestをログするためのTraceLayer、CORSを設定するためのCorsLayerです。HTTPロジックがstorage層から分離されているため、ほとんどのhandlerは変更する必要がありません。

rustaxumrest-apitokiobackend