CLOVER🍀

That was when it all began.

axumとSeaORMを組み合わせて簡単なREST APIを書いてみる

これは、なにをしたくて書いたもの?

前に、RustのORMであるSeaORMを試してみました。

RustのORMであるSeaORMをMySQLで試す - CLOVER🍀

今回は、axumにSeaORMを組み合わせてみたいと思います。

お題と参考にするもの

今回はシンプルに、このような書籍をお題テーブルに対する登録、参照といった基本的な操作を行うREST APIを作成して
みたいと思います。

create table book(
  isbn13 varchar(14) not null,
  title varchar(255) not null,
  publish_date date not null,
  price integer not null,
  primary key(isbn13)
);

参考にするのはSeaORMとaxumを組み合わせたサンプルですね。

https://github.com/SeaQL/sea-orm/tree/1.1.10/examples/axum_example

またaxumの使い方としてDieselと組み合わせたものもあるので、こちらも参考にしてみましょう。

https://github.com/tokio-rs/axum/tree/axum-v0.8.4/examples/diesel-async-postgres

環境

今回の環境はこちら。

$ rustup --version
rustup 1.28.1 (f9edccde0 2025-03-05)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.86.0 (05f9846f8 2025-03-31)`

MySQLは172.17.0.2でアクセスできるものとします。

 MySQL  localhost:3306 ssl  practice  SQL > select version();
+-----------+
| version() |
+-----------+
| 8.4.5     |
+-----------+
1 row in set (0.0007 sec)

準備

Cargoパッケージの作成。

$ cargo new --vcs none axum-sea-orm-example
$ cd axum-sea-orm-example

クレートの追加。

$ cargo add axum
$ cargo add tokio --features macros,rt-multi-thread
$ cargo add sea-orm --features sqlx-mysql,runtime-tokio-rustls,macros
$ cargo add serde --features derive
$ cargo add serde_json
$ cargo add chrono --features serde

テストコード向けには以下を追加。今回はHTTPサーバーを実行しないテストにします。

$ cargo add --dev tower --features=util
$ cargo add --dev http-body-util mime

Cargo.toml

[package]
name = "axum-sea-orm-example"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "0.8.4"
chrono = { version = "0.4.41", features = ["serde"] }
sea-orm = { version = "1.1.10", features = ["sqlx-mysql", "runtime-tokio-rustls", "macros"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }

[dev-dependencies]
http-body-util = "0.1.3"
mime = "0.3.17"
tower = { version = "0.5.2", features = ["util"] }

エンティティを生成するために、sea-orm-cliをインストールします。

$ cargo install --root tools sea-orm-cli --no-default-features --features codegen,cli,runtime-async-std-rustls,async-std

バージョン。

$ tools/bin/sea-orm-cli --version
sea-orm-cli 1.1.10

以下のコマンドでエンティティを自動生成します。

$ tools/bin/sea-orm-cli generate entity \
  -u mysql://kazuhira:password@172.17.0.2:3306/practice \
  -o src/entities

生成されたエンティティのソースコード

src/entities/book.rs

//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "book")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub isbn13: String,
    pub title: String,
    pub publish_date: Date,
    pub price: i32,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

axumでSeaORMを使ってみる

それでは、axumでSeaORMを使ってみます。

作成したソースコードはこちら。

src/main.rs

use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    routing::{delete, get, post},
    serve,
};
use chrono::NaiveDate;
use entities::book;
use entities::book::Entity as Book;
use sea_orm::{
    ActiveModelTrait, ActiveValue, ConnectOptions, Database, DatabaseConnection, EntityTrait,
    QueryOrder, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;

mod entities;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = app().await?;

    let listener = TcpListener::bind("0.0.0.0:3000").await?;
    serve(listener, app).await?;

    Ok(())
}

async fn app() -> Result<Router, Box<dyn std::error::Error>> {
    let mut opt =
        ConnectOptions::new("mysql://kazuhira:password@172.17.0.2:3306/practice".to_string());
    opt.max_connections(10)
        .min_connections(1)
        .sqlx_logging(true);

    let db = Database::connect(opt).await?;

    let state = AppState { db };

    let app = Router::new()
        .route("/books/{isbn13}", get(find_by_isbn13))
        .route("/books", get(find_all))
        .route("/books", post(register))
        .route("/books/{isbn13}", delete(delete_by_isbn13))
        .with_state(state);

    Ok(app)
}

#[derive(Clone)]
struct AppState {
    db: DatabaseConnection,
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct BookRequest {
    isbn13: String,
    title: String,
    price: i32,
    publish_date: NaiveDate,
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct BookResponse {
    isbn13: String,
    title: String,
    price: i32,
    publish_date: NaiveDate,
}

async fn find_by_isbn13(
    State(state): State<AppState>,
    Path(isbn13): Path<String>,
) -> Result<Json<BookResponse>, (StatusCode, String)> {
    let transaction = state.db.begin().await.map_err(internal_server_error)?;

    let result_book = Book::find_by_id(&isbn13)
        .one(&transaction)
        .await
        .map_err(internal_server_error)?;

    if result_book.is_none() {
        return Err((StatusCode::NOT_FOUND, format!("{} book not found", &isbn13)));
    }

    let book = result_book.unwrap();
    let res = entity_book_to_response(book);

    // コミットしなかった場合はロールバックされる

    Ok(Json(res))
}

async fn find_all(
    State(state): State<AppState>,
) -> Result<Json<Vec<BookResponse>>, (StatusCode, String)> {
    let transaction = state.db.begin().await.map_err(internal_server_error)?;

    let books = Book::find()
        .order_by_desc(book::Column::PublishDate)
        .all(&transaction)
        .await
        .map_err(internal_server_error)?;

    let res = books
        .iter()
        .map(|b| entity_book_to_response(b.clone()))
        .collect();

    // コミットしなかった場合はロールバックされる

    Ok(Json(res))
}

async fn register(
    State(state): State<AppState>,
    Json(book_request): Json<BookRequest>,
) -> Result<Json<BookResponse>, (StatusCode, String)> {
    let transaction = state.db.begin().await.map_err(internal_server_error)?;

    let book = book::ActiveModel {
        isbn13: ActiveValue::Set(book_request.isbn13.clone()),
        title: ActiveValue::Set(book_request.title),
        price: ActiveValue::Set(book_request.price),
        publish_date: ActiveValue::Set(book_request.publish_date),
    };

    book.insert(&transaction)
        .await
        .map_err(internal_server_error)?;

    let result_book = Book::find_by_id(book_request.isbn13)
        .one(&transaction)
        .await
        .map_err(internal_server_error)?
        .unwrap();

    let res = entity_book_to_response(result_book);

    transaction.commit().await.map_err(internal_server_error)?;

    Ok(Json(res))
}

async fn delete_by_isbn13(
    State(state): State<AppState>,
    Path(isbn13): Path<String>,
) -> Result<(), (StatusCode, String)> {
    let transaction = state.db.begin().await.map_err(internal_server_error)?;

    Book::delete_by_id(isbn13)
        .exec(&transaction)
        .await
        .map_err(internal_server_error)?;

    transaction.commit().await.map_err(internal_server_error)?;

    Ok(())
}

fn entity_book_to_response(book: book::Model) -> BookResponse {
    BookResponse {
        isbn13: book.isbn13,
        title: book.title,
        price: book.price,
        publish_date: book.publish_date,
    }
}

fn internal_server_error<E>(err: E) -> (StatusCode, String)
where
    E: std::error::Error,
{
    (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}

// テストは後で

テストコードは後で載せます。

Routerを組み立てるところは、テストしやすいように関数に分けました。

async fn app() -> Result<Router, Box<dyn std::error::Error>> {
    let mut opt =
        ConnectOptions::new("mysql://kazuhira:password@172.17.0.2:3306/practice".to_string());
    opt.max_connections(10)
        .min_connections(1)
        .sqlx_logging(true);

    let db = Database::connect(opt).await?;

    let state = AppState { db };

    let app = Router::new()
        .route("/books/{isbn13}", get(find_by_isbn13))
        .route("/books", get(find_all))
        .route("/books", post(register))
        .route("/books/{isbn13}", delete(delete_by_isbn13))
        .with_state(state);

    Ok(app)
}

今回のポイントは、Stateですね。

Crate axum / Sharing state with handlers

State in axum::extract - Rust

Stateは、ハンドラーの間で状態を共有するために使うようです。その例として、データベース接続も挙がっています。

It is common to share some state between handlers. For example, a pool of database connections or clients to other services may need to be shared.

Stateに持たせる内容は、こんな構造体にしました。Clonederiveする必要があります。

#[derive(Clone)]
struct AppState {
    db: DatabaseConnection,
}

こちらで作成したデータベース接続を

    let mut opt =
        ConnectOptions::new("mysql://kazuhira:password@172.17.0.2:3306/practice".to_string());
    opt.max_connections(10)
        .min_connections(1)
        .sqlx_logging(true);

    let db = Database::connect(opt).await?;

作成した構造体のインスタンスに設定して

    let state = AppState { db };

Routerwith_stateで指定します。

    let app = Router::new()
        .route("/books/{isbn13}", get(find_by_isbn13))
        .route("/books", get(find_all))
        .route("/books", post(register))
        .route("/books/{isbn13}", delete(delete_by_isbn13))
        .with_state(state);

ところで、SeaORMのDatabaseConnectionの設定をしていて、これがコネクションプールにもなっていることに気づきました。

Under the hood, a sqlx::Pool is created and owned by DatabaseConnection.

Each time you call execute or query_one/all on it, a connection will be acquired and released from the pool.

Database Connection | SeaORM 🐚 An async & dynamic ORM for Rust

続いてハンドラー関数です。

リクエストやレスポンスにはこのような構造体を使うことにしました。

#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct BookRequest {
    isbn13: String,
    title: String,
    price: i32,
    publish_date: NaiveDate,
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct BookResponse {
    isbn13: String,
    title: String,
    price: i32,
    publish_date: NaiveDate,
}

JSONシリアライズ、デシリアライズ可能にしてあります。

まずは単純な取得から。

async fn find_by_isbn13(
    State(state): State<AppState>,
    Path(isbn13): Path<String>,
) -> Result<Json<BookResponse>, (StatusCode, String)> {
    let transaction = state.db.begin().await.map_err(internal_server_error)?;

    let result_book = Book::find_by_id(&isbn13)
        .one(&transaction)
        .await
        .map_err(internal_server_error)?;

    if result_book.is_none() {
        return Err((StatusCode::NOT_FOUND, format!("{} book not found", &isbn13)));
    }

    let book = result_book.unwrap();
    let res = entity_book_to_response(book);

    // コミットしなかった場合はロールバックされる

    Ok(Json(res))
}

Routerwith_stateで指定した内容は、このように受け取ることができます。

async fn find_by_isbn13(
    State(state): State<AppState>,
    Path(isbn13): Path<String>,
) -> Result<Json<BookResponse>, (StatusCode, String)> {

関数の戻り値はResult<Json<BookResponse>, (StatusCode, String)>とし、エラーの時には(StatusCode, String)を返すように
します。

Errorから(StatusCode, String)への変換は、以下の関数で行います。

fn internal_server_error<E>(err: E) -> (StatusCode, String)
where
    E: std::error::Error,
{
    (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}

このあたりはここをマネしていますね。

https://github.com/tokio-rs/axum/blob/axum-v0.8.4/examples/diesel-async-postgres/src/main.rs#L133-L138

こちらも関数は用意していませんが、似た構造になっています。

https://github.com/SeaQL/sea-orm/blob/1.1.10/examples/axum_example/api/src/lib.rs

ところで、ここではトランザクションは開始するものの、データの読み取りしか行いません。

    let transaction = state.db.begin().await.map_err(internal_server_error)?;

最後にロールバックした方がいいのかなと思ったのですが、DatabaseTransactionDropトレイトを実装しているようで、
ドロップ時にロールバックするようです。

https://github.com/SeaQL/sea-orm/blob/1.1.10/src/database/transaction.rs#L215-L219

つまり明示的にロールバックしたい時を除いて、コミット以外は気にしなくてよさそうですね。

Modelをレスポンスに変換するための関数を用意。

fn entity_book_to_response(book: book::Model) -> BookResponse {
    BookResponse {
        isbn13: book.isbn13,
        title: book.title,
        price: book.price,
        publish_date: book.publish_date,
    }
}

取得したModelを変換して、レスポンスにします。

    let result_book = Book::find_by_id(&isbn13)
        .one(&transaction)
        .await
        .map_err(internal_server_error)?;

    if result_book.is_none() {
        return Err((StatusCode::NOT_FOUND, format!("{} book not found", &isbn13)));
    }

    let book = result_book.unwrap();
    let res = entity_book_to_response(book);

    // コミットしなかった場合はロールバックされる

    Ok(Json(res))

これで最初のハンドラーができました。

全件取得。出版日の降順に取得することにします。

async fn find_all(
    State(state): State<AppState>,
) -> Result<Json<Vec<BookResponse>>, (StatusCode, String)> {
    let transaction = state.db.begin().await.map_err(internal_server_error)?;

    let books = Book::find()
        .order_by_desc(book::Column::PublishDate)
        .all(&transaction)
        .await
        .map_err(internal_server_error)?;

    let res = books
        .iter()
        .map(|b| entity_book_to_response(b.clone()))
        .collect();

    // コミットしなかった場合はロールバックされる

    Ok(Json(res))
}

登録。

async fn register(
    State(state): State<AppState>,
    Json(book_request): Json<BookRequest>,
) -> Result<Json<BookResponse>, (StatusCode, String)> {
    let transaction = state.db.begin().await.map_err(internal_server_error)?;

    let book = book::ActiveModel {
        isbn13: ActiveValue::Set(book_request.isbn13.clone()),
        title: ActiveValue::Set(book_request.title),
        price: ActiveValue::Set(book_request.price),
        publish_date: ActiveValue::Set(book_request.publish_date),
    };

    book.insert(&transaction)
        .await
        .map_err(internal_server_error)?;

    let result_book = Book::find_by_id(book_request.isbn13)
        .one(&transaction)
        .await
        .map_err(internal_server_error)?
        .unwrap();

    let res = entity_book_to_response(result_book);

    transaction.commit().await.map_err(internal_server_error)?;

    Ok(Json(res))
}

先ほどと違うのは、HTTPボディを受け取るところとコミットがあるところですね。

削除。

async fn delete_by_isbn13(
    State(state): State<AppState>,
    Path(isbn13): Path<String>,
) -> Result<(), (StatusCode, String)> {
    let transaction = state.db.begin().await.map_err(internal_server_error)?;

    Book::delete_by_id(isbn13)
        .exec(&transaction)
        .await
        .map_err(internal_server_error)?;

    transaction.commit().await.map_err(internal_server_error)?;

    Ok(())
}

参照系、更新系がひとつできればあとはなんとかなりますが、最初のひとつが大変でした…。

これでアプリケーションとしては完成です。

動作確認してみます。

$ cargo run

データの登録。

$ curl -i localhost:3000/books --json '{"isbn13": "978-4873119786", "title": "プログラミングRust 第2版", "price": 5280, "publish_date": "2022-01-09"}'
HTTP/1.1 200 OK
content-type: application/json
content-length: 112
date: Sun, 04 May 2025 11:50:52 GMT

{"isbn13":"978-4873119786","title":"プログラミングRust 第2版","price":5280,"publish_date":"2022-01-09"}


$ curl -i localhost:3000/books --json '{"isbn13": "978-4065369579", "title": "RustによるWebアプリケーション開発 設計からリリース・運用まで", "price": 4400, "publish_date": "2024-09-30"}'
HTTP/1.1 200 OK
content-type: application/json
content-length: 165
date: Sun, 04 May 2025 11:51:48 GMT

{"isbn13":"978-4065369579","title":"RustによるWebアプリケーション開発 設計からリリース・運用まで","price":4400,"publish_date":"2024-09-30"}


$ curl -i localhost:3000/books --json '{"isbn13": "978-4295015291", "title": "Rustプログラミング完全ガイド 他言語との比較で違いが分かる! (impress top gear)", "price": 4070, "publish_date": "2022-09-28"}'
content-type: application/json
content-length: 182
date: Sun, 04 May 2025 11:54:29 GMT

{"isbn13":"978-4295015291","title":"Rustプログラミング完全ガイド 他言語との比較で違いが分かる! (impress top gear)","price":4070,"publish_date":"2022-09-28"}

全件取得。

$ curl -i localhost:3000/books
HTTP/1.1 200 OK
content-type: application/json
content-length: 463
date: Sun, 04 May 2025 11:54:52 GMT

[{"isbn13":"978-4065369579","title":"RustによるWebアプリケーション開発 設計からリリース・運用まで","price":4400,"publish_date":"2024-09-30"},{"isbn13":"978-4295015291","title":"Rustプログラミング完全ガイド 他言語との比較で違いが分かる! (impress top gear)","price":4070,"publish_date":"2022-09-28"},{"isbn13":"978-4873119786","title":"プログラミングRust 第2版","price":5280,"publish_date":"2022-01-09"}]

1件取得。

$ curl -i localhost:3000/books/978-4295015291
HTTP/1.1 200 OK
content-type: application/json
content-length: 182
date: Sun, 04 May 2025 11:55:20 GMT

{"isbn13":"978-4295015291","title":"Rustプログラミング完全ガイド 他言語との比較で違いが分かる! (impress top gear)","price":4070,"publish_date":"2022-09-28"}

削除。

$ curl -i -X DELETE localhost:3000/books/978-4295015291
HTTP/1.1 200 OK
content-length: 0
date: Sun, 04 May 2025 11:56:03 GMT


$ curl -i localhost:3000/books
HTTP/1.1 200 OK
content-type: application/json
content-length: 280
date: Sun, 04 May 2025 11:56:19 GMT

[{"isbn13":"978-4065369579","title":"RustによるWebアプリケーション開発 設計からリリース・運用まで","price":4400,"publish_date":"2024-09-30"},{"isbn13":"978-4873119786","title":"プログラミングRust 第2版","price":5280,"publish_date":"2022-01-09"}]

よさそうです。

テストを書く

最後にテストを書きます。

src/main.rs

use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    routing::{delete, get, post},
    serve,
};
use chrono::NaiveDate;
use entities::book;
use entities::book::Entity as Book;
use sea_orm::{
    ActiveModelTrait, ActiveValue, ConnectOptions, Database, DatabaseConnection, EntityTrait,
    QueryOrder, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;

mod entities;

// 省略

async fn app() -> Result<Router, Box<dyn std::error::Error>> {
    let mut opt =
        ConnectOptions::new("mysql://kazuhira:password@172.17.0.2:3306/practice".to_string());
    opt.max_connections(10)
        .min_connections(1)
        .sqlx_logging(true);

    let db = Database::connect(opt).await?;

    let state = AppState { db };

    let app = Router::new()
        .route("/books/{isbn13}", get(find_by_isbn13))
        .route("/books", get(find_all))
        .route("/books", post(register))
        .route("/books/{isbn13}", delete(delete_by_isbn13))
        .with_state(state);

    Ok(app)
}

// 省略

#[cfg(test)]
mod tests {
    use axum::{
        Router,
        body::Body,
        http::{Request, StatusCode, header::CONTENT_TYPE},
    };
    use chrono::NaiveDate;
    use http_body_util::BodyExt;
    use mime::APPLICATION_JSON;
    use tower::ServiceExt;

    use crate::{BookRequest, BookResponse, app};

    async fn cleanup(app: Router) {
        let targets = vec!["978-4873119786", "978-4065369579", "978-4295015291"];

        for target in targets {
            let request = Request::delete(format!("/books/{}", target))
                .body(Body::empty())
                .unwrap();
            app.clone().oneshot(request).await.unwrap();
        }
    }

    #[tokio::test]
    async fn register_and_get() {
        let app = app().await.unwrap();
        cleanup(app.clone()).await;

        // register
        let register_request = Request::post("/books")
            .header(CONTENT_TYPE, APPLICATION_JSON.as_ref())
            .body(Body::from(
                serde_json::to_string(&BookRequest {
                    isbn13: "978-4873119786".to_string(),
                    title: "プログラミングRust 第2版".to_string(),
                    price: 5280,
                    publish_date: NaiveDate::from_ymd_opt(2022, 1, 9).unwrap(),
                })
                .unwrap()
            ))
            .unwrap();

        let register_response = app.clone().oneshot(register_request).await.unwrap();

        assert_eq!(register_response.status(), StatusCode::OK);

        let register_response = serde_json::from_slice::<BookResponse>(
            &register_response
                .into_body()
                .collect()
                .await
                .unwrap()
                .to_bytes(),
        )
        .unwrap();

        assert_eq!(register_response.title, "プログラミングRust 第2版");

        // find_by_isbn13
        let find_request = Request::get(format!("/books/{}", "978-4873119786"))
            .body(Body::empty())
            .unwrap();
        let find_response = app.clone().oneshot(find_request).await.unwrap();

        assert_eq!(find_response.status(), StatusCode::OK);

        let find_response = serde_json::from_slice::<BookResponse>(
            &find_response
                .into_body()
                .collect()
                .await
                .unwrap()
                .to_bytes(),
        )
        .unwrap();

        assert_eq!(find_response.isbn13, "978-4873119786");
        assert_eq!(find_response.title, "プログラミングRust 第2版");
        assert_eq!(find_response.price, 5280);
        assert_eq!(
            find_response.publish_date,
            NaiveDate::from_ymd_opt(2022, 1, 9).unwrap()
        );
    }

    #[tokio::test]
    async fn find_all() {
        let app = app().await.unwrap();
        cleanup(app.clone()).await;

        let request_books = vec![
            BookRequest {
                isbn13: "978-4873119786".to_string(),
                title: "プログラミングRust 第2版".to_string(),
                price: 5280,
                publish_date: NaiveDate::from_ymd_opt(2022, 1, 9).unwrap(),
            },
            BookRequest {
                isbn13: "978-4065369579".to_string(),
                title: "RustによるWebアプリケーション開発 設計からリリース・運用まで".to_string(),
                price: 4400,
                publish_date: NaiveDate::from_ymd_opt(2024, 9, 30).unwrap(),
            },
            BookRequest {
                isbn13: "978-4295015291".to_string(),
                title:
                    "Rustプログラミング完全ガイド 他言語との比較で違いが分かる! (impress top gear)"
                        .to_string(),
                price: 4070,
                publish_date: NaiveDate::from_ymd_opt(2022, 9, 28).unwrap(),
            },
        ];

        // register
        for request_book in request_books {
            let request = Request::post("/books")
                .header(CONTENT_TYPE, APPLICATION_JSON.as_ref())
                .body(Body::from(
                    serde_json::to_string(&request_book).unwrap(),
                ))
                .unwrap();

            app.clone().oneshot(request).await.unwrap();
        }

        // find_all
        let find_all_request = Request::get("/books").body(Body::empty()).unwrap();
        let find_all_response = app.clone().oneshot(find_all_request).await.unwrap();

        assert_eq!(find_all_response.status(), StatusCode::OK);

        let find_all_response = serde_json::from_slice::<Vec<BookResponse>>(
            &find_all_response
                .into_body()
                .collect()
                .await
                .unwrap()
                .to_bytes(),
        )
        .unwrap();

        assert_eq!(find_all_response.len(), 3);

        assert_eq!(
            find_all_response[0].title,
            "RustによるWebアプリケーション開発 設計からリリース・運用まで"
        );
        assert_eq!(
            find_all_response[1].title,
            "Rustプログラミング完全ガイド 他言語との比較で違いが分かる! (impress top gear)"
        );
        assert_eq!(find_all_response[2].title, "プログラミングRust 第2版");
    }
}

本体の部分は省略しています。

テストの最初に、データをすべて削除するようにしました。

    async fn cleanup(app: Router) {
        let targets = vec!["978-4873119786", "978-4065369579", "978-4295015291"];

        for target in targets {
            let request = Request::delete(format!("/books/{}", target))
                .body(Body::empty())
                .unwrap();
            app.clone().oneshot(request).await.unwrap();
        }
    }

あとは、前に書いたこちらのエントリーを見ながら書いてみました…。

axumで書いたアプリケーションのテストを書いてみる - CLOVER🍀

なお、テストは以下のようにシーケンシャルに実行する必要があります。

$ cargo test -- --test-threads=1

おわりに

axumとSeaORMを組み合わせて、簡単なREST APIを書いてみました。

いやぁ、とてもてこずりました…。axumとSeaORMが例を書いていてくれなかったら、たぶん完成していないと思います(笑)。

トランザクションだったり、エラーハンドリングといったところが慣れないですね。あと、テストももうちょっとうまく
書けるのではないかという気がします…。

練習としてもちょうどいい内容でした。もっとステップアップしていきたいですね。