たむけん(エンジニア)
Rust/ActixWebでRestAPIでバリデーションとログ
たむけん(エンジニア)
2024.06.18
IT技術
はじめに
最近 Rust のお勉強をしてます。
前回 の続きです。
早く書きたいなビジネスロジック。
その前に
前回から動作環境(主にバージョン)を変更してます。
言語/ライブラリ | バージョン( 括弧内は前回) |
Rust | 1.78.0(1.75.0) |
actix-web | 4.5.1(4.4.1) |
バリデーション
クエリストリングまたはリクエストボディの内容をチェックする。
そうバリデーション。大事ですね。
ライブラリ(クレート)を追加します。
> cargo add validator --features derive
使い方としてはクエリストリング/リクエストボディを構造体で定義し、対象項目にアトリビュートにチェック内容を定義する、という感じです。
定義できるチェック内容は入力必須、文字数、範囲、正規表現などお馴染みのものからメール、URL 形式チェックなどなど。
サンプル
クエリストリング
最初はクエリストリング(GET)。
- x は数値(u32): 1〜10 意外はエラー
- y は文字列(String): 2〜5 文字意外はエラー
1
2// リクエスト(とレスポンス)構造体
3#[derive(serde::Deserialize, serde::Serialize, validator::Validate)]
4struct ValidateGetStruct {
5 #[validate(range(min = 1, max = 10, message = "1〜10の値を入力してください."))]
6 x: u32,
7 #[validate(length(min = 2, max = 5, message = "2〜5文字で入力してください."))]
8 y: String,
9}
10
11// エンドポイント
12#[get("/validate")]
13async fn get_validate(
14 param: Result<actix_web::web::Query, actix_web::Error>,
15) -> Result<HttpResponse, ApiCustomError> {
16 // 1. actix-web
17 let param = match param {
18 Ok(param) => param,
19 Err(err) => return Err(ApiCustomError::ActixWebError(err)),
20 };
21 // 2. validate
22 let is_valid = param.validate();
23 match is_valid {
24 Ok(_) => Ok(HttpResponse::Ok().json(ValidateGetStruct {
25 x: param.x,
26 y: param.y.to_string(),
27 })),
28 Err(err) => Err(ApiCustomError::ValidationError(err)),
29 }
30}
#[validate(range(min = 1, max = 10, message = "1〜10の値を入力してください."))] のように定義します。直感的ですね(ですよね?)。message はエラーメッセージを定義します。
エンドポイントでは actix_web::web::Query<T> でクエリストリングを受け取り、パラメータの定義有無のチェックしてます。これは actix-web の処理です(validator ではありません)。独自エラーに渡してよしなにレスポンスしたいので Result で囲って match param の判定を実施してます。
(なので必須入力( required)チェックの出番なさそうなんですよね)
パラメータが定義されていたら validator のチェックになります。
param.validate()
でバリデーションを実行します。
これバリデーション定義した構造体のリクエストを利用してる場合、毎度書かなきゃなんですね。
バリデートエラー時のレスポンスをいい感じするためエラーハンドリングを追加修正します。
1
2#[derivae(thiserror::Error, Debug)]
3pub enum ApiCustomError {
4 〜
5 // バリデートエラー
6 #[error(transparent)]
7 ValidationError(#[from] validator::ValidationErrors),
8 〜
9}
10
11impl ResponseError for ApiCustomError {
12 // ステータスコード
13 fn status_code(&self) -> actix_web::http::StatusCode {
14 〜
15 ApiCustomError::ValidationError(_) => StatusCode::BAD_REQUEST, // ### 追加
16 〜
17 }
18 // エラーレスポンス
19 fn error_response(&self) -> HttpResponse {
20 〜
21 ApiCustomError::ValidationError(err) => {
22 HttpResponse::build(self.status_code()).json(ErrRes {
23 message: format!("Bad Request. [{}]", err),
24 })
25 }
26 〜
27 }
リクエスト不備時、レスポンスはこんな感じ。
1# リクエスト
2GET /validate?x=0&y=abcdef
3
4# レスポンス
5{
6 "message": "Bad Request. [y: 2〜5文字で入力してください.\nx: 1〜10の値を入力してください.]"
7}
リクエストボディ
続いてリクエストボディ(POSTなど)。
- name は文字列(String): 1〜10 文字意外はエラー
- birth_month は数値(u32): 1〜12 意外はエラー
- email は文字列(String): メール形式不備時はエラー
- hp_url は文字列(String): URL 形式不備時はエラー
- post_code は文字列(String): "\^[\d]{3}-[\d]{4}$" にマッチしない場合はエラー
1※ 正規表現(regex)の利用には以下クレートの追加が必要でした
2
3- regex
4- once_cell
5
6
7> cargo add regex once_cell
1
2// 正規表現パターン(郵便番号)
3static REGEX_POST_CODE: once_cell::sync::Lazy =
4 once_cell::sync::Lazy::new(|| regex::Regex::new(r"^[\d]{3}-[\d]{4}$").unwrap());
5
6// リクエスト(とレスポンス)構造体
7#[derive(serde::Deserialize, serde::Serialize, validator::Validate)]
8struct ValidatePostStruct {
9 #[validate(length(min = 1, max = 10, message = "名前を入力してください.[1〜10文字]"))]
10 name: String,
11 #[validate(range(min = 1, max = 12, message = "誕生月を入力してくだい.[1〜12]"))]
12 birth_month: u32,
13 #[validate(email(message = "メールアドレスの形式が正しくありません."))]
14 email: String,
15 #[validate(url(message = "URLの形式が正しくありません."))]
16 hp_url: String,
17 #[validate(regex(path = *REGEX_POST_CODE, message = "郵便番号形式が正しくありません."))]
18 post_code: String,
19}
20
21#[post("/validate")]
22async fn post_validate(
23 form: Result<actix_web::web::Json, actix_web::Error>,
24) -> Result<HttpResponse, ApiCustomError> {
25 // 1. actix-web
26 let form = match form {
27 Ok(form) => form,
28 Err(err) => return Err(ApiCustomError::ActixWebError(err)),
29 };
30 // 2. validator
31 let is_valid = form.validate();
32 match is_valid {
33 Ok(_) => Ok(HttpResponse::Ok().json(form)),
34 Err(err) => Err(ApiCustomError::ValidationError(err)),
35 }
36}
アトリビュートの定義方法は同じ。
エンドポイントは actix_web::web::Json<T> でリクエストボディを受け取り、項目の定義有無のチェックしてます。こちらもクエリストリング同様 actix-web の処理です。
からの、
form.validate()
でバリデーションを実行してます。
リクエスト不備時、レスポンスはこんな感じ。
1# リクエスト
2POST /validate
3Content-Type: application/json
4
5{
6 "name": "abcdefghijk",
7 "birth_month": 13,
8 "email": "hoge.com",
9 "hp_url": "http//fuga.com",
10 "post_code": "123-4Z67"
11}
12
13# レスポンス
14{
15 "message": "Bad Request. [birth_month: 誕生月を入力してくだい.[1〜12]\nname: 名前を入力してください.[1〜10文字]\nhp_url: URLの形式が正しくありません.\nemail: メールアドレスの形式が正しくありません.\npost_code: 郵便番号形式が正しくありません.]"
16}
validator クレートの利用でバリデーション定義ができました。
定義は分かりやすいんですけど、.validate() でバリデーション実行して判定を記載するのがちと冗長なのかなと思いました。
スッキリ書きたい。宿題。
ログ
続いてログ。
ログ大事、いわずもがな。
公式 に記載されてるとおりにやってみる。
以下のクレートを追加する。
> cargo add env_logger
エントリポイントに処理を追加する。
1
2#[actix_web::main]
3async fn main() -> std::io::Result<()> {
4 // ## 追加(ログレベルをdebugで定義)
5 env_logger::init_from_env(Env::default().default_filter_or("debug"));
6
7 HttpServer::new(|| {
8 App::new()
9 .service(hello)
10 〜
11 .wrap(Logger::default()) // ## 追加
12 })
追加したら実行してみる。
1# リクエスト
2GET /
3
4# ログ
5[2024-05-15T10:11:12Z INFO actix_web::middleware::logger] 192.168.65.1 "GET / HTTP/1.1" 200 14 "-" "vscode-restclient" 0.000156
6
7---
8
9# リクエスト(バリデートエラー)
10GET /validate?x=11&y=abcde
11
12# ログ
13[2024-05-15T10:11:45Z DEBUG actix_web::middleware::logger] Error in response: ValidationError(ValidationErrors({"x": Field([ValidationError { code: "range", message: Some("1〜10の値を入力してください."), params: {"max": Number(10), "min": Number(1), "value": Number(11)} }])}))
14[2024-05-15T10:11:45Z INFO actix_web::middleware::logger] 192.168.65.1 "GET /validate?x=11&y=abcde HTTP/1.1" 400 72 "-" "vscode-restclient" 0.000469
出た。
ミドルウェアを利用しアクセスログやエラーログを出力しているようです。
あれ、でもデバッグログとかどうするんだろ?
デバッグログなどを出力する場合は別途クレートが必要みたいでした。
> cargo add log
ハロワなエンドポイントにデバッグログを追加してみる。
1
2#[get("/")]
3async fn hello() -> impl Responder {
4 log::debug!("+++ debug.");
5 log::info!("+++ info.");
6 log::warn!("+++ warn.");
7 log::error!("+++ error.");
8 HttpResponse::Ok().body("Hello World!!!")
9}
実行してみる。
1# リクエスト
2GET /
3
4# ログ
5[2024-05-15T10:12:08Z DEBUG tam_app] +++ debug.
6[2024-05-15T10:12:08Z INFO tam_app] +++ info.
7[2024-05-15T10:12:08Z WARN tam_app] +++ warn.
8[2024-05-15T10:12:08Z ERROR tam_app] +++ error.
9[2024-05-15T10:12:08Z INFO actix_web::middleware::logger] 192.168.65.1 "GET / HTTP/1.1" 200 14 "-" "vscode-restclient" 0.000332
いい感じ。
しかし欲張る。
構造化ログ(json 形式などで構造化されたあれ)で出力できるのか疑問に思った。
調べてみるとロガー設定でできそうな事が分かった。
「時間」、「ログレベル」、「メッセージ」 を json 形式で出力するよう定義してみる。
1
2pub struct StructedLogger;
3
4impl log::Log for StructedLogger {
5 fn enabled(&self, metadata: &log::Metadata) -> bool {
6 metadata.level() <= log::Level::Debug
7 }
8 fn log(&self, record: &log::Record) {
9 if self.enabled(record.metadata()) {
10 println!(
11 r#"{{"time":"{}","level":"{}","message":"{}"}}"#,
12 chrono::Utc::now(),
13 record.level(),
14 record.args()
15 );
16 }
17 }
18 fn flush(&self) {}
19}
定義したロガーを適用する。
1
2#[actix_web::main]
3async fn main() -> std::io::Result<()> {
4 // ## 変更(ロガーをセットする)
5 let _ = log::set_logger(&LOGGER).map(|()| log::set_max_level(log::LevelFilter::Debug));
6 // env_logger::init_from_env(Env::default().default_filter_or("debug"));
7
8 HttpServer::new(|| {
9 App::new()
10 .service(hello)
11 〜
12 // ## こちらも変更する
13 // .wrap(Logger::default()) // ## 追加
14 .wrap(Logger::new("[%a] [%r] [%s] [%b] [%{User-Agent}i] [%T]"))
15 })
実行してみる。
1# リクエスト
2GET /
3
4# ログ
5{"time":"2024-05-15 10:23:52.175117006 UTC","level":"DEBUG","message":"+++ debug."}
6{"time":"2024-05-15 10:23:52.175176580 UTC","level":"INFO","message":"+++ info."}
7{"time":"2024-05-15 10:23:52.175184426 UTC","level":"WARN","message":"+++ warn."}
8{"time":"2024-05-15 10:23:52.175189105 UTC","level":"ERROR","message":"+++ error."}
9{"time":"2024-05-15 10:23:52.175262003 UTC","level":"INFO","message":"[192.168.65.1] [GET / HTTP/1.1] [200] [14] [vscode-restclient] [0.000224]"}
Json フォーマットでログ出力できた。
今回はここまで mm
まとめ
しかしまだまだビジネスロジックを書くに至らない。
だけど諦めない。
千里の道も一歩から、って言うじゃないですか。
おまけ
vscode / devcontaner で動かしてるですが、ビルドが激オモだったです。
調べたところ マウントディレクトリに target (ビルド生成物など格納するディレクトリ)があるのが原因みたいでした。
上記サイトに記載されてる通り、ビルド時にオプション指定したら早くなりました。
1
2> cargo build --target-dir /tmp/target
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ