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をmethodGETにバインドします。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 traitHandler<_, _>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は変更する必要がありません。