これは、なにをしたくて書いたもの?
前に、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
は、ハンドラーの間で状態を共有するために使うようです。その例として、データベース接続も挙がっています。
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
に持たせる内容は、こんな構造体にしました。Clone
をderive
する必要があります。
#[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 };
Router
にwith_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, }
まずは単純な取得から。
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)) }
Router
にwith_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/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)?;
最後にロールバックした方がいいのかなと思ったのですが、DatabaseTransaction
がDrop
トレイトを実装しているようで、
ドロップ時にロールバックするようです。
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>( ®ister_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が例を書いていてくれなかったら、たぶん完成していないと思います(笑)。
トランザクションだったり、エラーハンドリングといったところが慣れないですね。あと、テストももうちょっとうまく
書けるのではないかという気がします…。
練習としてもちょうどいい内容でした。もっとステップアップしていきたいですね。