はじめに
この記事を書いている人
Rust初学者。
Udemyとthe bookを軽く一周したぐらい。the bookは後半さらっと流しているので正直あまり理解はできてない。Rust難しい。けどおもしろい。
近年、フロントエンド業界でもRustがじわじわ注目されているということでRustに興味を持った。
せっかくなら何か作ってみようということで取り組んでみた。
本記事は筆者の備忘録的な側面が多いため、読みにくい部分があります。
また、必ずしも正しい情報ではなかったり、実装における最適解ではない可能性があります。
※もしより正確な情報だったり詳細な内容をご存じの方はコメントで教えていただけると幸いです。
背景
こちらの書籍を参考に実装を進めているが、2024年7月現在では執筆当時と各クレートのバージョンが異なる。
- axum:
- 書籍:0.4.8 → 0.7.5
https://docs.rs/axum/0.7.5/axum/index.html
- 書籍:0.4.8 → 0.7.5
- hyper
- 書籍:0.14.16 → 1.4.1
https://docs.rs/hyper/latest/hyper/
- 書籍:0.14.16 → 1.4.1
せっかくなら最新バージョンの書き方で実装しようとしたが、やはり苦労したので備忘録として残す。
※書籍から引用したソースコードはファイル名の後ろに(書籍版)と記す。
()の記載がないものは、修正後のソースコードとする。
※後半はRustクレートのバージョン関連ではなく、純粋にクオリティUPのコード修正が多い
第3章
Cargo.toml
3章は以下の内容で進める。
おそらく最新なはず。
[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も同じように書いてある)
#[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
が追加されている。
#[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
の呼び出しでコンパイルエラーになる。
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
の引数の順番を入れ替えると解消した。
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リクエスト
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)
}
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
の引数の順番を変えるとエラーが解消した。
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 バリデーションの追加
ここが苦労した末にエラーが解決できなかった。
#[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_request
のRequestParts<B>
という型がaxum0.7系では存在せず(axum0.6系を境になくなっている様子)、コンパイルエラーとなる。
chatGPTの力を借りて以下のような形までたどり着いたが、Json::<T>::from_request
に渡す引数の型がよろしくないようだった。
#[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
そこで、ハンドラ関数の引数にバリデーション後の値を渡すのではなく、
ハンドラ関数で値を受け取った後に自力でバリデーションをかけるようにした。
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
- sqlx:
- 書籍:0.14.0 → 0.8.0
https://docs.rs/sqlx/latest/sqlx/
- 書籍:0.14.0 → 0.8.0
4章で追加したのは以下
[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
- tower-http:
- 書籍:0.2.5 → 0.5.2
https://docs.rs/tower-http/0.5.2/tower_http/
- 書籍:0.2.5 → 0.5.2
5章で追加したのは以下
[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
になっていた。
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のリストをベクタで持つことができた。
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_id
にON 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リクエストで失敗が返ってきたらメッセージを出す、というだけ。
※もっと他にいい方法があったら教えてください
ここまでのコードはこちら。
感想
「Rustを学びたい、Rustで何か作ってみたい」というところから、書籍のバージョンより新しいものがあるならやってみようという感じで初めてみたが、まずRustの日本語の解説記事や資料が2024年現在でもまだ豊富でなく、エラー解消に苦労した。(一部はChatGPTに頼ったりもしました)
各クレートに関してはとにかくひらすらドキュメントを読むしかない。
また、8割ぐらいは書籍の内容通りなのと、どちらかというとRustそのものではなくRustの"axum"、"sqlx"の使い方を学んだという感じなので、このアプリケーション制作を通してRustへの理解が深まったかというとあまりそうではないかもしれない。(Rustのコードに慣れる、というところは達成できたかも)
次にやりたいこと
- 本番環境にデプロイ (更新:次の記事でデプロイについて記載)
- オリジナル機能追加
- フロントエンドもRustで実装(Dioxusなど)
など...
派生記事リンク