6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust(axum, sqlx)とReactでWebアプリ開発 - 備忘録

Last updated at Posted at 2024-09-25

はじめに

この記事を書いている人

Rust初学者。
Udemyとthe bookを軽く一周したぐらい。the bookは後半さらっと流しているので正直あまり理解はできてない。Rust難しい。けどおもしろい。

近年、フロントエンド業界でもRustがじわじわ注目されているということでRustに興味を持った。
せっかくなら何か作ってみようということで取り組んでみた。

本記事は筆者の備忘録的な側面が多いため、読みにくい部分があります。
また、必ずしも正しい情報ではなかったり、実装における最適解ではない可能性があります。

※もしより正確な情報だったり詳細な内容をご存じの方はコメントで教えていただけると幸いです。

背景

こちらの書籍を参考に実装を進めているが、2024年7月現在では執筆当時と各クレートのバージョンが異なる。

せっかくなら最新バージョンの書き方で実装しようとしたが、やはり苦労したので備忘録として残す。

※書籍から引用したソースコードはファイル名の後ろに(書籍版)と記す。
 ()の記載がないものは、修正後のソースコードとする。
※後半はRustクレートのバージョン関連ではなく、純粋にクオリティUPのコード修正が多い

第3章

Cargo.toml

3章は以下の内容で進める。
おそらく最新なはず。

Cargo.toml
[package]
name = "todo"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0.86"
axum = "0.7.5"
hyper = { version = "1.4.1", features = ["full"] }
mime = "0.3.17"
serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.120"
thiserror = "1.0.63"
tokio = { version = "1", features = ["full"] }
tower = "0.4.13"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }

3.2 Hello, world

いきなりつまづく。
axum::Serverが使えなくなっていた。

axum v0.7以降ではこのように書く。(公式ドキュメントのExampleも同じように書いてある)

main.rs
#[tokio::main]
async fn main() {

/* 省略 */

    // axum 0.4.8
    // let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    // axum::Server::bind(&addr).serve(app.into_make_service()).await.unwrap();

    // axum 0.7.5
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    tracing::debug!("listening on {:?}", listener);
    axum::serve(listener, app).await.unwrap();
}

3.3 テスト

hyper::body::to_bytesがhyper v1.0を境になくなっている?ので使えなさそうだった。

hyper::Bodyがなくなり、axum::body::Bodyに移行した」という情報を頼りにドキュメントを漁っていたところ、たまたま以下を発見した。

引数にlimit: usizeが追加されている。

main.rs
#[cfg(test)]
mod test {
    use std::usize;
    use super::*;
    use axum::{
        body::Body,
        body::to_bytes,
        http::{header, Method, Request},
    };
    use tower::ServiceExt;

    #[tokio::test]
    async fn should_return_hello_world() {
        let req = Request::builder().uri("/").body(Body::empty()).unwrap();
        let res = create_app().oneshot(req).await.unwrap();
        // axum 0.4.8, hyper 0.14.16
        // let bytes = hyper::body::to_bytes(res.into_body()).await.unwrap();
        // axum 0.7.5, hyper 1.4.1
        let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
        let body: String = String::from_utf8(bytes.to_vec()).unwrap();
        assert_eq!(body, "Hello, World!")
    }
}

3.4 Todo情報を保存する

postの呼び出しでコンパイルエラーになる。

main.rs(書籍版)
fn create_app<T: TodoRepository>(repository: T) -> Router {
    Router::new()
        .route("/", get(root))
        .route("/todos", post(create_todo::<T>))
        .layer(Extension(Arc::new(repository)))
}

pub async fn create_todo<T: TodoRepository>(
    Json(payload): Json<CreateTodo>,
    Extension(repository): Extension<Arc<T>>,
) -> impl IntoResponse {
    let todo = repository.create(payload);

    (StatusCode::CREATED, Json(todo))
}
error[E0277]: the trait bound `fn(Json<CreateTodo>, Extension<Arc<T>>) -> impl Future<Output = impl IntoResponse> {create_todo::<T>}: 
Handler<_, _>` is not satisfied
   --> src/main.rs:40:31
    |
40  |         .route("/todos", post(create_todo::<T>))
    |                          ---- ^^^^^^^^^^^^^^^^ the trait `Handler<_, _>` is not implemented for fn item `fn(Json<CreateTodo>, Extension<Arc<T>>) -> impl Future<Output = impl IntoResponse> {create_todo::<T>}`
    |                          |
    |                          required by a bound introduced by this call
    |
    = help: the following other types implement trait `Handler<T, S>`:
              <MethodRouter<S> as Handler<(), S>>
              <axum::handler::Layered<L, H, T, S> as Handler<T, S>>
note: required by a bound in `post`
   --> C:\Users\mrhd\.cargo\registry\src\index.crates.io-6f17d22bba15001f\axum-0.7.5\src\routing\method_routing.rs:389:1
    |
389 | top_level_handler_fn!(post, POST);
    | ^^^^^^^^^^^^^^^^^^^^^^----^^^^^^^
    | |                     |
    | |                     required by a bound in this function
    | required by this bound in `post`
    = note: this error originates in the macro `top_level_handler_fn` (in Nightly builds, run with -Z macro-backtrace for more info)  

以下の記事を参考にして、create_todoの引数の順番を入れ替えると解消した。

main.rs
pub async fn create_todo<T: TodoRepository>(
    Extension(repository): Extension<Arc<T>>,
    Json(payload): Json<CreateTodo>,
) -> impl IntoResponse {
    let todo = repository.create(payload);

    (StatusCode::CREATED, Json(todo))
}

3.5 httpリクエスト

handlers.rs(書籍版)
pub async fn update_todo<T: TodoRepository>(
    Path(id): Path<i32>,
    Json(payload): Json<UpdateTodo>,
    Extension(repository): Extension<Arc<T>>,
) -> Result<impl IntoResponse, StatusCode> {
    todo!();
    Ok(StatusCode::OK)
}
main.rs(書籍版)
fn create_app<T: TodoRepository>(repository: T) -> Router {
    Router::new()
        .route("/", get(root))
        .route("/todos", post(create_todo::<T>).get(all_todo::<T>))
        .route(
            "/todos/:id",
            get(find_todo::<T>)
                .delete(delete_todo::<T>)
                .patch(update_todo::<T>),
        )
        .layer(Extension(Arc::new(repository)))
}

update_todo::<T>の呼び出しでエラーになった。

error[E0277]: the trait bound `fn(axum::extract::Path<i32>, Json<UpdateTodo>, Extension<Arc<T>>) -> impl Future<Output = Result<impl IntoResponse, StatusCode>> {update_todo::<T>}: Handler<_, _>` is not satisfied
   --> src/main.rs:42:24
    |
42  |                 .patch(update_todo::<T>),
    |                  ----- ^^^^^^^^^^^^^^^^ the trait `Handler<_, _>` is not implemented for fn item `fn(Path<i32>, Json<UpdateTodo>, Extension<Arc<T>>) -> ... {update_todo::<...>}`
    |                  |
    |                  required by a bound introduced by this call
    |
    = help: the following other types implement trait `Handler<T, S>`:
              <MethodRouter<S> as Handler<(), S>>
              <axum::handler::Layered<L, H, T, S> as Handler<T, S>>
note: required by a bound in `MethodRouter::<S>::patch`
   --> /Users/01051530/.cargo/registry/src/index.crates.io-6f17d22bba15001f/axum-0.7.5/src/routing/method_routing.rs:589:5
    |
589 |     chained_handler_fn!(patch, PATCH);
    |     ^^^^^^^^^^^^^^^^^^^^-----^^^^^^^^
    |     |                   |
    |     |                   required by a bound in this associated function
    |     required by this bound in `MethodRouter::<S>::patch`
    = note: this error originates in the macro `chained_handler_fn` (in Nightly builds, run with -Z macro-backtrace for more info)

これも同じくExtensionの引数の順番を変えるとエラーが解消した。

handlers.rs
pub async fn update_todo<T: TodoRepository>(
    Extension(repository): Extension<Arc<T>>,
    Path(id): Path<i32>,
    Json(payload): Json<UpdateTodo>,
) -> Result<impl IntoResponse, StatusCode> {
    todo!();
    Ok(StatusCode::OK)
}

3.6 バリデーションの追加

ここが苦労した末にエラーが解決できなかった。

handlers.rs(書籍版)
#[derive(Debug)]
pub struct ValidatedJson<T>(T);

#[async_trait]
impl<T, B> FromRequest<B> for ValidatedJson<T>
where
    T: DeserializeOwned + Validate,
    B: http_body::Body + Send,
    B::Data: Send,
    B::Error: Into<BoxError>,
{
    type Rejection = (StatusCode, String);

    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req).await.map_err(|rejection| {
            let message = format!("Json parse error: [{}]", rejection);
            (StatusCode::BAD_REQUEST, message)
        })?;
        value.validate().map_err(|rejection| {
            let message = format!("Validation error: [{}]", rejection).replace('\n', ", ");
            (StatusCode::BAD_REQUEST, message)
        })?;
        Ok(ValidatedJson(value))
    }
}

from_requestRequestParts<B>という型がaxum0.7系では存在せず(axum0.6系を境になくなっている様子)、コンパイルエラーとなる。

chatGPTの力を借りて以下のような形までたどり着いたが、Json::<T>::from_requestに渡す引数の型がよろしくないようだった。

handlers.rs(一部修正したがエラー)
#[derive(Debug)]
pub struct ValidatedJson<T>(T);

#[async_trait]
impl<T, S> FromRequestParts<S> for ValidatedJson<T>
where
    T: DeserializeOwned + Validate,
    S: Send + Sync,
{
    type Rejection = (StatusCode, String);

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(parts, state).await.map_err(|rejection| {
            let message = format!("Json parse error: [{}]", rejection);
            (StatusCode::BAD_REQUEST, message)
        })?;
        value.validate().map_err(|rejection| {
            let message = format!("Validation error: [{}]", rejection).replace('\n', ", ");
            (StatusCode::BAD_REQUEST, message)
        })?;
        Ok(ValidatedJson(value))
    }
}
error[E0308]: mismatched types
  --> src\handlers.rs:87:51
   |
87 |         let Json(value) = Json::<T>::from_request(parts, state).await.map_err(|rejection| {
   |                           ----------------------- ^^^^^ expected `Request<Body>`, found `&mut Parts`
   |                           |
   |                           arguments to this function are incorrect
   |
   = note:         expected struct `axum::http::Request<Body>`
           found mutable reference `&'life0 mut axum::http::request::Parts`
note: associated function defined here
  --> C:\Users\mrhd\.cargo\registry\src\index.crates.io-6f17d22bba15001f\axum-core-0.4.3\src\extract\mod.rs:85:14
   |
85 |     async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection>;
   |              ^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `todo` (bin "todo") due to 1 previous error

そこで、ハンドラ関数の引数にバリデーション後の値を渡すのではなく、
ハンドラ関数で値を受け取った後に自力でバリデーションをかけるようにした。

handlers.rs
use crate::repositories::{CreateTodo, TodoRepository, UpdateTodo};
use axum::{extract::Path, http::StatusCode, response::IntoResponse, Extension, Json};
use std::sync::Arc;

const ERR_STR_EMPTY: &str = "Error!: Can not be Empty";
const ERR_STR_OVER: &str = "Error!: Over text length";
const ERR_STR_NOT_FOUND: &str = "Todo not found";

pub async fn create_todo<T: TodoRepository>(
    Extension(repository): Extension<Arc<T>>,
    Json(payload): Json<CreateTodo>,
) -> impl IntoResponse {
    let response = match payload.text.len() {
        len if len <= 0 => (StatusCode::BAD_REQUEST, ERR_STR_EMPTY.to_string()).into_response(),
        len if len > 100 => (StatusCode::BAD_REQUEST, ERR_STR_OVER.to_string()).into_response(),
        _ => {
            let todo = repository.create(payload);
            (StatusCode::CREATED, Json(todo)).into_response()
        }
    };

    response
}

pub async fn find_todo<T: TodoRepository>(
    Path(id): Path<i32>,
    Extension(repository): Extension<Arc<T>>,
) -> Result<impl IntoResponse, StatusCode> {
    let response = match repository.find(id) {
        Some(todo) => (StatusCode::OK, Json(todo)).into_response(),
        None => (StatusCode::NOT_FOUND, ERR_STR_NOT_FOUND.to_string()).into_response(),
    };

    Ok(response)
}

pub async fn all_todo<T: TodoRepository>(
    Extension(repository): Extension<Arc<T>>,
) -> impl IntoResponse {
    let todos = repository.all();
    (StatusCode::OK, Json(todos)).into_response()
}

pub async fn update_todo<T: TodoRepository>(
    Extension(repository): Extension<Arc<T>>,
    Path(id): Path<i32>,
    Json(payload): Json<UpdateTodo>,
) -> Result<impl IntoResponse, StatusCode> {
    let response = match payload.text.as_deref().unwrap_or("").len() {
        len if len <= 0 => (StatusCode::BAD_REQUEST, ERR_STR_EMPTY.to_string()).into_response(),
        len if len > 100 => (StatusCode::BAD_REQUEST, ERR_STR_OVER.to_string()).into_response(),
        _ => match repository.update(id, payload) {
            Ok(todo) => (StatusCode::CREATED, Json(todo)).into_response(),
            Err(_) => (StatusCode::NOT_FOUND, ERR_STR_NOT_FOUND.to_string()).into_response(),
        },
    };

    Ok(response)
}

pub async fn delete_todo<T: TodoRepository>(
    Path(id): Path<i32>,
    Extension(repository): Extension<Arc<T>>,
) -> StatusCode {
    let response = match repository.delete(id) {
        Ok(_) => StatusCode::NO_CONTENT,
        Err(_) => StatusCode::NOT_FOUND,
    };

    response
}

※以下の記事ではaxum v0.7.5で似たようなことをしているようだったので、こちらを参考にすればValidatedJsonを活かせるかもしれない(が試していない)

3章までのソースコード全体はこちら

第4章

Cargo.toml

4章で追加したのは以下

Cargo.toml
[dependencies]
dotenv = "0.15.0"
sqlx = { version = "0.8.0", features = ["runtime-tokio-rustls", "any", "postgres" ] }

[features]
default = ["database-test"]
database-test = []

sqlxでは特にバージョンの違いで詰まることはなかった。
4章までのソースコード全体はこちら

第5章

node, npmのバージョンは以下の通り

$ node -v
v18.12.1

$ npm -v
8.19.2

Cargo.toml

5章で追加したのは以下

Cargo.toml
[dependencies]
tower-http = { version = "0.5.2", features = ["cors"] }

5.4 外部APIとの通信(1)

tower_http::cors::Originがなくなっており、tower-http v0.5.2では代わりにtower_http::cors::AllowOriginになっていた。

main.rs
use tower_http::cors::{Any, CorsLayer, AllowOrigin};

/* 省略 */

fn create_app<T: TodoRepository>(repository: T) -> Router {
    Router::new()
        .route("/", get(root))
        .route("/todos", post(create_todo::<T>).get(all_todo::<T>))
        .route(
            "/todos/:id",
            get(find_todo::<T>)
                .delete(delete_todo::<T>)
                .patch(update_todo::<T>),
        )
        .layer(Extension(Arc::new(repository)))
        .layer(
            CorsLayer::new()
            .allow_origin(AllowOrigin::exact("http://localhost:3001".parse().unwrap()))
            .allow_methods(Any)
            .allow_headers(vec![CONTENT_TYPE]),
        )
}

ただ、これだけだとhttp://127.0.0.1:3001 からアクセスした場合にCORSエラーで登録ができない。

AllowOrigin::listを使うとCORSを許可したいURLのリストをベクタで持つことができた。

main.rs
fn create_app<T: TodoRepository>(repository: T) -> Router {
    let allowed_origins = vec![
        "http://localhost:3001".parse().unwrap(),
        "http://127.0.0.1:3001".parse().unwrap(),
    ];

    let cors = CorsLayer::new()
        .allow_origin(AllowOrigin::list(allowed_origins))
        .allow_methods(Any)
        .allow_headers(vec![CONTENT_TYPE]);

    Router::new()
        .route("/", get(root))
        .route("/todos", post(create_todo::<T>).get(all_todo::<T>))
        .route(
            "/todos/:id",
            get(find_todo::<T>)
                .delete(delete_todo::<T>)
                .patch(update_todo::<T>),
        )
        .layer(Extension(Arc::new(repository)))
        .layer(cors)
}

5章までのソースコードはこちら

第6章

6.1 ラベルのCRUD

handlerの追加

ValidatedJsonをhandlers.rsへ移動しましょう。

とあるが、3.6 バリデーションの追加でValidatedJson型を作らず、ハンドラの中で自力でバリデーションするようにしたので、移動するものはなかった。

また、バージョン違いによるエラーとは関係ないが、TodoとLabelで書籍の中で若干コードの構造が異なるところがあったので少し手間取った。
その際のコミットはこちら

6.2 TodoRepositoryのラベル対応

書籍と最新バージョンの違いで実装に悩むところはなかったが、ちょっとしたタイポなどでテストが通らず苦労した。
※Amazonのレビューなどにもあるが、6章から「あとは自分で調べて実装してみましょう」みたいな端折ってある演習形式の部分が出てくる

コードは後でまとめて記載。

6.3 ラベル機能を画面に追加する

Rustのコード変更はなく、Reactの修正のみ。
これで一通り書籍の内容は完了。

6.3_ex 一部追加で修正

書籍の内容は一応すべて完了したのだが、今のままでは少しバグが残っている。

  • todoにlabelが紐づけられている場合、todo自体が削除できない
  • todoにlabelが紐づけられている場合、紐づいているlabelが削除できない

1つずつ見ていく。

labelが紐づいているtodoの削除

これはDBの外部キー制約によって、todo_labelsテーブルのカラムが削除できないためエラーになる。
todo_idON DELETE CASCADEをつけるとlabelの紐づきがあってもtodoを削除できるようになる。
※一度DB設定をリセットしないとマイグレーションファイルとDBの状態が一致せずにエラーになる

CREATE TABLE todo_labels
(
    id       SERIAL PRIMARY KEY,
    todo_id  INTEGER NOT NULL REFERENCES todos (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
    label_id INTEGER NOT NULL REFERENCES labels (id) DEFERRABLE INITIALLY DEFERRED
);

todoに紐づいているlabelを削除しようとした場合のエラー表示

同じ理由でtodoに紐づいているlabelもエラーになるが、こちらはtodoとの紐づきがなくなるまで削除させない仕様としたい。
このとき、label削除に失敗するが、コンソールエラーが出るだけで画面上では削除に失敗していることが分からない。
書籍にも「自分なりにエラーメッセージの表出ロジックやUIの検討をしてみましょう」と記載があるので、取り組んでみた。

UIとしてはお粗末ではあるが、とりあえずlabel削除時のモーダルにエラーメッセージを出すようにした。
表出ロジックも単純で、label削除の状態をuseStateで管理し、DELETEリクエストで失敗が返ってきたらメッセージを出す、というだけ。
※もっと他にいい方法があったら教えてください

image.png

ここまでのコードはこちら。

感想

「Rustを学びたい、Rustで何か作ってみたい」というところから、書籍のバージョンより新しいものがあるならやってみようという感じで初めてみたが、まずRustの日本語の解説記事や資料が2024年現在でもまだ豊富でなく、エラー解消に苦労した。(一部はChatGPTに頼ったりもしました)
各クレートに関してはとにかくひらすらドキュメントを読むしかない。

また、8割ぐらいは書籍の内容通りなのと、どちらかというとRustそのものではなくRustの"axum"、"sqlx"の使い方を学んだという感じなので、このアプリケーション制作を通してRustへの理解が深まったかというとあまりそうではないかもしれない。(Rustのコードに慣れる、というところは達成できたかも)

次にやりたいこと

派生記事リンク

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?