Open5

[メモ] Webアプリ開発で学ぶRust言語入門 6章(最終章)

ki504178ki504178

6.1 ラベルの CRUD

DB 設計

DEFERRABLE INITIALLY DEFERRED: 遅延制約。トランザクションコミット時まで制約の検証を遅延する。
ほぇ〜こんな制約あるの知らなかった。

LabelRepository の作成

  • LabelRepository トレイトの設計とテスト

cargo test したらtodoリポジトリで1つFailした。
前章で画面から登録してたデータが残ってたせいだったので、消したらイケた。

running 8 tests
test repositories::todo::test_utils::tests::todo_crud_scenario ... ok
test tests::should_ハローワールドを返す ... ok
test tests::should_get_all_todos ... ok
test tests::should_find_todo ... ok
test tests::should_delete_todo ... ok
test tests::should_created_todo ... ok
test tests::should_update_todo ... ok
test repositories::todo::test::crud_scenario ... FAILED

failures:

---- repositories::todo::test::crud_scenario stdout ----
thread 'repositories::todo::test::crud_scenario' panicked at 'assertion failed: `(left == right)`
  left: `Todo { id: 6, text: "[crud_scenario] text", completed: false }`,
 right: `Todo { id: 3, text: "cccc", completed: true }`', src/repositories/todo.rs:169:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    repositories::todo::test::crud_scenario

test result: FAILED. 7 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
  • LabelRepositoryForMemory

お、TodoRepositoryForMemory とほぼ同じ実装だから詳細に解説しないスパルタスタイル来た。
ページ数の都合とかありそう。
GitHub見ないで実装したろと思って、ほぼ同じならとコピペして微調整したらイケた。
Rust完全に理解したフェーズ(全然理解できてない)

実装
src/repositories/label.rs
// mod test の後

#[cfg(test)]
pub mod test_utils {
    use super::*;
    use std::{
        collections::HashMap,
        sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard},
    };

    impl Label {
        pub fn new(id: i32, name: String) -> Self {
            Self { id, name }
        }
    }

    type LabelDatas = HashMap<i32, Label>;

    #[derive(Debug, Clone)]
    pub struct LabelRepositoryForMemory {
        store: Arc<RwLock<LabelDatas>>,
    }

    impl LabelRepositoryForMemory {
        pub fn new() -> Self {
            LabelRepositoryForMemory {
                store: Arc::default(),
            }
        }

        fn write_store_ref(&self) -> RwLockWriteGuard<LabelDatas> {
            return self.store.write().unwrap();
        }

        fn read_store_ref(&self) -> RwLockReadGuard<LabelDatas> {
            return self.store.read().unwrap();
        }
    }

    #[async_trait]
    impl LabelRepository for LabelRepositoryForMemory {
        async fn create(&self, name: String) -> anyhow::Result<Label> {
            let mut store = self.write_store_ref();
            let id = (store.len() + 1) as i32;
            let label = Label::new(id, name);
            store.insert(id, label.clone());

            return Ok(label);
        }

        async fn all(&self) -> anyhow::Result<Vec<Label>> {
            let store = self.read_store_ref();
            return Ok(Vec::from_iter(store.values().cloned()));
        }

        async fn delete(&self, id: i32) -> anyhow::Result<()> {
            let mut store = self.write_store_ref();
            store.remove(&id).ok_or(RepositoryError::NotFound(id))?;

            return Ok(());
        }
    }

    mod tests {
        use super::*;

        #[tokio::test]
        async fn label_crud_scenario() {
            let name = "label".to_string();
            let id = 1;
            let expected = Label::new(id, name.clone());

            // create
            let repository = LabelRepositoryForMemory::new();
            let label = repository.create(name).await.expect("failed create todo");
            assert_eq!(expected, label);

            // all
            let labels = repository.all().await.expect("failed get all todo");
            assert_eq!(vec![expected], labels);

            // delete
            let res = repository.delete(id).await;
            assert!(res.is_ok());
        }
    }
}
  • Label の CREATE/READ/DELETE ハンドラ

ここもコードの触りだけ見てTodoハンドラコピペ+調整でイケた。
deleteだけ失敗した時のステータスコード NotFound じゃなくて InternalServerError 返してた。

  • LabelRepository の layer 追加とハンドラ追加

ここはラベルの使い方の最終系見えてないので全部自前実装は厳しい。

そしてシナリオテストはTodo参考にして詳細はGitHubスタイル。
ここは自前でイケた。

実装
src/main.rs
mod tests {
// import とかは省略

    #[tokio::test]
    async fn should_created_label() {
        let expected = Label::new(1, "should_return_created_label".to_string());

        let req = build_todo_req_with_json(
            "/labels",
            Method::POST,
            r#"{ "name": "should_return_created_label" }"#.to_string(),
        );
        let res = create_app(
            TodoRepositoryForMemory::new(),
            LabelRepositoryForMemory::new(),
        )
        .oneshot(req)
        .await
        .unwrap();
        let label = res_to_label(res).await;
        assert_eq!(expected, label);
    }

    #[tokio::test]
    async fn should_get_all_label() {
        let expected = Label::new(1, "should_get_all_label".to_string());

        let repository = LabelRepositoryForMemory::new();
        repository
            .create("should_get_all_label".to_string())
            .await
            .expect("failed create label");
        let req = build_todo_req_with_empty("/labels", Method::GET);
        let res = create_app(TodoRepositoryForMemory::new(), repository)
            .oneshot(req)
            .await
            .unwrap();
        let bytes = hyper::body::to_bytes(res.into_body()).await.unwrap();
        let body: String = String::from_utf8(bytes.to_vec()).unwrap();
        let label: Vec<Label> = serde_json::from_str(&body)
            .unwrap_or_else(|_| panic!("cannot convert Todo instance. body: {body}"));
        assert_eq!(vec![expected], label);
    }

    #[tokio::test]
    async fn should_delete_label() {
        let repository = LabelRepositoryForMemory::new();
        repository
            .create("should_delete_label".to_string())
            .await
            .expect("failed create todo");
        let req = build_todo_req_with_empty("/labels/1", Method::DELETE);
        let res = create_app(TodoRepositoryForMemory::new(), repository)
            .oneshot(req)
            .await
            .unwrap();
        assert_eq!(StatusCode::NO_CONTENT, res.status());
    }
}
ki504178ki504178

6.2 TodoRepository のラベル対応

そういえばめっちゃ clone() するけどパフォーマンスに影響ないのかのう。

Todo に labels を追加

  • TodoRepository のメソッドの戻り値に利用している Todo を TodoEntity に変更

お、今度は一部の変更を解説しつつ、あとはコンパイルエラー見ながら潰し込んでねスタイル。
ここまでくれば余裕やろ(慢心
イケた。

実装
src/main.rs
mod handlers;
mod repositories;

use crate::repositories::{label::LabelRepositoryForDb, todo::TodoRepositoryForDb};
use axum::{
    routing::{delete, get, post},
    Extension, Router,
};
use dotenv::dotenv;
use handlers::{
    label::{all_label, create_label, delete_label},
    todo::{all_todo, create_todo, delete_todo, find_todo, update_todo},
};
use hyper::header::CONTENT_TYPE;
use repositories::{label::LabelRepository, todo::TodoRepository};
use sqlx::PgPool;
use std::{env, net::SocketAddr, sync::Arc};
use tower_http::cors::{AllowOrigin, Any, CorsLayer};

#[tokio::main]
async fn main() {
    // loggingの初期化
    let log_level = env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string());
    env::set_var("RUST_LOG", log_level);
    tracing_subscriber::fmt::init();
    dotenv().ok();

    let database_url = &env::var("DATABASE_URL").expect("undefined [DATABASE_URL]");
    tracing::debug!("start connect database...");
    let pool = PgPool::connect(database_url)
        .await
        .unwrap_or_else(|_| panic!("fail connect database, url is [{database_url}]"));

    let app = create_app(
        TodoRepositoryForDb::new(pool.clone()),
        LabelRepositoryForDb::new(pool),
    );
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::debug!("listening on {addr}");

    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn root() -> &'static str {
    "Hello, World!"
}

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

#[cfg(test)]
mod tests {
    use crate::repositories::{
        label::{test_utils::LabelRepositoryForMemory, Label},
        todo::{test_utils::TodoRepositoryForMemory, CreateTodo, TodoEntity},
    };

    use super::*;
    use axum::response::Response;
    use hyper::{header, Body, Method, Request, StatusCode};
    use tower::ServiceExt;

    fn build_todo_req_with_json(path: &str, method: Method, json_body: String) -> Request<Body> {
        Request::builder()
            .uri(path)
            .method(method)
            .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
            .body(Body::from(json_body))
            .unwrap()
    }

    fn build_todo_req_with_empty(path: &str, method: Method) -> Request<Body> {
        Request::builder()
            .uri(path)
            .method(method)
            .body(Body::empty())
            .unwrap()
    }

    async fn res_to_todo(res: Response) -> TodoEntity {
        let bytes = hyper::body::to_bytes(res.into_body()).await.unwrap();
        let body: String = String::from_utf8(bytes.to_vec()).unwrap();
        let todo: TodoEntity = serde_json::from_str(&body)
            .unwrap_or_else(|_| panic!("cannot convert Todo instance. body: {body}"));
        return todo;
    }

    async fn res_to_label(res: Response) -> Label {
        let bytes = hyper::body::to_bytes(res.into_body()).await.unwrap();
        let body: String = String::from_utf8(bytes.to_vec()).unwrap();
        let label: Label = serde_json::from_str(&body)
            .unwrap_or_else(|_| panic!("cannot convert Label instance. body: {body}"));
        return label;
    }

    #[tokio::test]
    async fn should_ハローワールドを返す() {
        let req = Request::builder().uri("/").body(Body::empty()).unwrap();
        let res = create_app(
            TodoRepositoryForMemory::new(),
            LabelRepositoryForMemory::new(),
        )
        .oneshot(req)
        .await
        .unwrap();
        let bytes = hyper::body::to_bytes(res.into_body()).await.unwrap();
        let body = String::from_utf8(bytes.to_vec()).unwrap();

        assert_eq!(body, "Hello, World!");
    }

    #[tokio::test]
    async fn should_created_todo() {
        let expected = TodoEntity {
            id: 1,
            text: "should_return_created_todo".to_string(),
            completed: false,
            labels: vec![],
        };

        let req = build_todo_req_with_json(
            "/todos",
            Method::POST,
            r#"{ "text": "should_return_created_todo" }"#.to_string(),
        );
        let res = create_app(
            TodoRepositoryForMemory::new(),
            LabelRepositoryForMemory::new(),
        )
        .oneshot(req)
        .await
        .unwrap();
        let todo = res_to_todo(res).await;
        assert_eq!(expected, todo);
    }

    #[tokio::test]
    async fn should_find_todo() {
        let expected = TodoEntity {
            id: 1,
            text: "should_return_find_todo".to_string(),
            completed: false,
            labels: vec![],
        };

        let repository = TodoRepositoryForMemory::new();
        repository
            .create(CreateTodo::new("should_return_find_todo".to_string()))
            .await
            .expect("failed create todo");
        let req = build_todo_req_with_empty("/todos/1", Method::GET);
        let res = create_app(repository, LabelRepositoryForMemory::new())
            .oneshot(req)
            .await
            .unwrap();
        let todo = res_to_todo(res).await;
        assert_eq!(expected, todo);
    }

    #[tokio::test]
    async fn should_get_all_todos() {
        let expected = TodoEntity {
            id: 1,
            text: "should_return_all_todo".to_string(),
            completed: false,
            labels: vec![],
        };

        let repository = TodoRepositoryForMemory::new();
        repository
            .create(CreateTodo::new("should_return_all_todo".to_string()))
            .await
            .expect("failed create todo");
        let req = build_todo_req_with_empty("/todos", Method::GET);
        let res = create_app(repository, LabelRepositoryForMemory::new())
            .oneshot(req)
            .await
            .unwrap();
        let bytes = hyper::body::to_bytes(res.into_body()).await.unwrap();
        let body: String = String::from_utf8(bytes.to_vec()).unwrap();
        let todo: Vec<TodoEntity> = serde_json::from_str(&body)
            .unwrap_or_else(|_| panic!("cannot convert Todo instance. body: {body}"));
        assert_eq!(vec![expected], todo);
    }

    #[tokio::test]
    async fn should_update_todo() {
        let expected = TodoEntity {
            id: 1,
            text: "should_return_update_todo".to_string(),
            completed: false,
            labels: vec![],
        };

        let repository = TodoRepositoryForMemory::new();
        repository
            .create(CreateTodo::new("before_update_todo".to_string()))
            .await
            .expect("failed create todo");
        let req = build_todo_req_with_json(
            "/todos/1",
            Method::PATCH,
            r#"{
    "text": "should_return_update_todo",
    "completed": false
}"#
            .to_string(),
        );
        let res = create_app(repository, LabelRepositoryForMemory::new())
            .oneshot(req)
            .await
            .unwrap();
        let todo = res_to_todo(res).await;
        assert_eq!(expected, todo);
    }

    #[tokio::test]
    async fn should_delete_todo() {
        let repository = TodoRepositoryForMemory::new();
        repository
            .create(CreateTodo::new("should_delete_todo".to_string()))
            .await
            .expect("failed create todo");
        let req = build_todo_req_with_empty("/todos/1", Method::DELETE);
        let res = create_app(repository, LabelRepositoryForMemory::new())
            .oneshot(req)
            .await
            .unwrap();
        assert_eq!(StatusCode::NO_CONTENT, res.status());
    }

    #[tokio::test]
    async fn should_created_label() {
        let expected = Label::new(1, "should_return_created_label".to_string());

        let req = build_todo_req_with_json(
            "/labels",
            Method::POST,
            r#"{ "name": "should_return_created_label" }"#.to_string(),
        );
        let res = create_app(
            TodoRepositoryForMemory::new(),
            LabelRepositoryForMemory::new(),
        )
        .oneshot(req)
        .await
        .unwrap();
        let label = res_to_label(res).await;
        assert_eq!(expected, label);
    }

    #[tokio::test]
    async fn should_get_all_label() {
        let expected = Label::new(1, "should_get_all_label".to_string());

        let repository = LabelRepositoryForMemory::new();
        repository
            .create("should_get_all_label".to_string())
            .await
            .expect("failed create label");
        let req = build_todo_req_with_empty("/labels", Method::GET);
        let res = create_app(TodoRepositoryForMemory::new(), repository)
            .oneshot(req)
            .await
            .unwrap();
        let bytes = hyper::body::to_bytes(res.into_body()).await.unwrap();
        let body: String = String::from_utf8(bytes.to_vec()).unwrap();
        let label: Vec<Label> = serde_json::from_str(&body)
            .unwrap_or_else(|_| panic!("cannot convert Todo instance. body: {body}"));
        assert_eq!(vec![expected], label);
    }

    #[tokio::test]
    async fn should_delete_label() {
        let repository = LabelRepositoryForMemory::new();
        repository
            .create("should_delete_label".to_string())
            .await
            .expect("failed create todo");
        let req = build_todo_req_with_empty("/labels/1", Method::DELETE);
        let res = create_app(TodoRepositoryForMemory::new(), repository)
            .oneshot(req)
            .await
            .unwrap();
        assert_eq!(StatusCode::NO_CONTENT, res.status());
    }
}

TodoWithLabelFromRow の畳み込み

'outer while ~ してるところで for 使ってねってWarningでた。

試行錯誤して、for 使う形の以下で解消された。結構シンプルになった気がする。

// 省略
    'outer: for row in rows {
        for todo in accum.iter_mut() {
            // idが一致=Todoに紐づくラベルが複数存在している
            if todo.id == row.id {
                todo.labels.push(Label {
                    id: row.label_id.unwrap(),
                    name: row.label_name.clone().unwrap(),
                });
                continue 'outer;
            }
        }
// 省略

あと、TodoWithLabelFromRowlabel_idlabel_name 指定しなされってコンパイルエラー出てたので、
以下追加して、struct でそのまま使ってるとこにも同じやつ追加したら解消された。

    impl TodoWithLabelFromRow {
        pub fn new(id: i32, text: String) -> Self {
            Self {
                id,
                text,
                completed: false,
  +            label_id: None,
  +            label_name: None,
            }
        }
    }
ki504178ki504178

長くなってきたので分割

TodoRepositoryForDb の CRUD 修正

if let Some(labels) = payload.labels みたいなやつスマートキャストチックで良き。

誤字

  • // ...(省略:creat 部分)

    • // ...(省略:crate 部分)
  • delete メソッドの修正

サンプルコードだと todo_labels 削除のクエリの結果がエラーの場合で、
RowNotFound のパターンマッチがあるけど、ラベルって任意なのであればむしろエラー扱いしてはいけないのでは?

  • fold_entity の削除

あら? impl TodoRepository for TodoRepositoryForMemoryで使ってるけど、
どこかで修正対応するの見逃したかな?

  • 擬似リクエストのテスト修正

should_created_todo のFail原因でちょっとハマった。
リクエストボディに空配列でも labels を指定してあげる必要があることに気づいた。

ki504178ki504178

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

前章でハマったけどフロントエンドは大丈夫っしょ(フラグ
大丈夫だったb

ki504178ki504178

6.4 さらなる機能拡張

とりあえず読了。Rust 完全に理解した(嘘
機能拡張はまたの機会に。