概要
RustのWebフレームワーク「actix-web」で、htmlのcheckboxを使ってフォーム入力し、postしようとしたら上手く動かずにハマった…。その際の解決法をご紹介する。
やりたいこと(最終目標)
html上で下図のようなチェックボックスによる入力フォームを用意し、postメソッドでチェックした情報を送れるようにしたい。以下の例の場合「"りんご"、"みかん"にチェックされました」という情報を送りたい。
htmlは(あまり知識がないが)以下のように記述。
属性値を複数選択する場合は、name属性の値の最後に[]
をつけて配列にする必要がある。
<!DOCTYPE html>
<html>
<head>
<title>top page</title>
</head>
<meta charset="utf-8" />
<body>
好きなくだものを選んでください。<br>
<form method="post" action="show_checked">
<input type="checkbox" name="fruits[]" value="apple">りんご<br>
<input type="checkbox" name="fruits[]" value="orange">みかん<br>
<input type="checkbox" name="fruits[]" value="banana">バナナ<br>
<input type="submit" name="button" value="Send"><br>
</form>
</body>
</html>
http(s)://hostname/index.html でチェックボックス選択サイトを表示して、「送信」ボタンを押すと、選択したチェックボックスの情報を保持しつつ、http(s)://hostname/show_checked に遷移するWebサイトを作りたい。
環境情報
- 動作確認したバージョン
- rustc : 1.51.0
- cargo : 1.51.0
使うソースの構成は下記。
.
├── Cargo.toml
├── src
│ └── main.rs
└── templates
└── index.html
手始め:属性値を一つ指定する場合の実装
まずは、ラジオボタンを使って属性値を一つを選択するWebサービスを実装する。
templates/index.htmlの中身
body部分のみ記載。デフォルトは「りんご」が選択された状態になっている。
(ここではname属性の値には[]
をつけずにスカラー値にしていることに注意)
好きなくだものを一つ選んでください。<br>
<form method="post" action="show_checked">
<input type="radio" name="fruits" checked="checked" value="apple">りんご<br>
<input type="radio" name="fruits" value="orange">みかん<br>
<input type="radio" name="fruits" value="banana">バナナ<br>
<input type="submit" name="button" value="Send"><br>
</form>
Cargo.tomlの中身
[package]
name = "webform_single"
version = "0.1.0"
authors = ["auther"]
edition = "2018"
[dependencies]
actix-web = "3.3.2"
askama = "0.10.5"
serde = "1.0.126"
- actix-web
- Webフレームワークとして利用
- askama
-
templates/index.html
をプログラムで読むために利用 - askamaを使うとPythonのJinja2テンプレートのような感じで、htmlファイル内に変数を指定することも可能だが、今回はその機能は使わない。
-
- serde
- Rustのシリアライズ/デシリアライズするツール
- ラジオボタンやチェックボックスを選択してフォーム送信すると、元のデータは「fruits=apple&button=Send」のようなクエリ文字列になっているが、これを自動でRustの構造体に変換してくれる模様
src/main.rsの中身
以下ソースは、属性fruitsの値を一つだけ指定する場合のみに動く。
use actix_web::{App,HttpServer,get,post,web,HttpResponse};
use askama::Template;
use serde::Deserialize;
// index.htmlのテンプレート定義
#[derive(Template)]
#[template(path = "index.html")]
// htmlテンプレートに変数指定する場合は、構造体メンバーを定義することも可能(今回は使わない)
struct IndexTemplate;
/// index.htmlのbodyを返す
#[get("/index.html")]
async fn index() -> HttpResponse {
let html = IndexTemplate {};
let response_body = html.render().unwrap();
HttpResponse::Ok()
.content_type("text/html")
.body(response_body)
}
// 入力フォーム用の構造体定義
// index.htmlのラジオボタンのタグで指定した名前(name=fruits)とメンバ名を合わせる必要あり
#[derive(Deserialize)]
pub struct FormDataCheckBox {
pub fruits: String,
}
/// チェックボックスのフォームで入力した情報を返す
#[post("/show_checked")]
async fn show_checked(
form: web::Form<FormDataCheckBox>,
) -> HttpResponse {
// レスポンスで、指定したチェックボックスのvalueの属性値が返る
HttpResponse::Ok()
.content_type("text/html")
.body(&form.fruits)
}
#[actix_web::main]
async fn main() -> Result<(), actix_web::Error> {
// Webサーバを起動
HttpServer::new(move ||
App::new()
.service(index)
.service(show_checked)
)
.bind("0.0.0.0:8080")?
.run()
.await?;
Ok(())
}
上記ソースをcargo run等で実行後、Webブラウザでindex.htmlにアクセスしてフォーム送信すると、ラジオボタンで選択した果物に相当する値(value属性)がレスポンスで返るのを確認できる。
curlコマンドで再現すると、以下のようになる。
$ curl -X POST -d "fruits=apple&button=Send" "http://[ホスト名]:8080/show_checked"
apple
本題:属性値を配列にする場合の実装
入力フォームの属性値を配列で受け取る場合、先の例でフォームを受け取るのに使ったweb::Form<FormDataCheckBox>
構造体のジェネリック部分(FormDataCheckBox)を次のようにfruitsをVec型で定義すれば動きそう!
と最初は思った。
#[derive(Deserialize)]
pub struct FormDataCheckBox {
pub fruits: Vec<String>,
}
だが実際にやってみるとビルドは通るが、レスポンスがParse error
となって動かないことが判明…。調べると、現時点ではserdeのDeserializeが配列タイプの属性値に対応していないのが原因の模様。
参考記事 : https://banatech.net/blog/view/46
そこで、次の手順で解決する。
- 入力フォームのリクエストbodyをactix-webの
web::Payload
型でそのまま受け取る - 受け取ったbodyを、serde_qsを使ってパースする
ソース構成(再掲)は下記。
.
├── Cargo.toml
├── src
│ └── main.rs
└── templates
└── index.html
これらのファイルの中身を以降に示す。
templates/index.htmlの中身
body部分のみ記載。既に説明済だが、チェックボックスでの複数選択に対応できるようname属性の値の最後に[]
をつけている。
好きなくだものを選んでください。<br>
<form method="post" action="show_checked">
<input type="checkbox" name="fruits[]" value="apple">りんご<br>
<input type="checkbox" name="fruits[]" value="orange">みかん<br>
<input type="checkbox" name="fruits[]" value="banana">バナナ<br>
<input type="submit" name="button" value="Send"><br>
</form>
Cargo.tomlの中身
[package]
name = "webform_multi"
version = "0.1.0"
authors = ["auther"]
edition = "2018"
[dependencies]
actix-web = "3.3.2"
askama = "0.10.5"
serde = "1.0.126"
# 以下を追加
serde_qs = "0.8.4"
futures-util = "0.3.15"
- serde_qs
- URLのクエリ文字列(QueryString)に特化したシリアライズ/デシリアライズツール。
- futures-util
- リクエストのbodyを連結する際に必要となる模様。(非同期処理に対応するために必要?)
src/main.rsの中身
use actix_web::{App,HttpServer,get,post,web,HttpResponse};
use askama::Template;
use serde::Deserialize;
// 追加パッケージ
use serde_qs;
use futures_util::StreamExt;
// index.htmlのテンプレート定義
#[derive(Template)]
#[template(path = "index.html")]
// htmlテンプレートに変数指定する場合は、構造体メンバーを定義することも可能(今回は使わない)
struct IndexTemplate;
/// index.htmlのbodyを返す
#[get("/index.html")]
async fn index() -> HttpResponse {
let html = IndexTemplate {};
let response_body = html.render().unwrap();
HttpResponse::Ok()
.content_type("text/html")
.body(response_body)
}
// 入力フォーム用の構造体定義
// index.htmlのチェックボックスのタグで指定した名前(name=fruits[])とメンバ名を合わせる必要あり
#[derive(Deserialize)]
pub struct FormDataCheckBox {
pub fruits: Vec<String>,
}
// チェックボックスのフォームで入力した情報を返す
#[post("/show_checked")]
async fn show_checked(
mut body: web::Payload,
) -> HttpResponse {
// bodyをbyte型の配列に変換する
let mut bytes = web::BytesMut::new();
while let Some(item) = body.next().await {
bytes.extend_from_slice(&item.unwrap());
}
// byte型配列を文字列に変換
let query_string
= bytes.iter().map(|&s| s as char).collect::<String>();
// クエリ文字列からfruitsの属性値の配列を抽出する
// 括弧はそれぞれ"%5B","%5D"となっているので予め変更しておく
let sqs = serde_qs::from_str::<FormDataCheckBox>(
&query_string
.replace("%5B", "[")
.replace("%5D", "]")
);
let fruits = match &sqs {
// 一つもチェックされていない場合は空のVec<String>を作る
Err(_) => Vec::<String>::new(),
Ok(v) => v.fruits.iter()
.map(|c| c.to_string())
.collect::<Vec<String>>()
};
// チェックされた属性値を配列として表示
HttpResponse::Ok()
.content_type("text/html")
.body(format!("{:?}", fruits))
}
#[actix_web::main]
async fn main() -> Result<(), actix_web::Error> {
// Webサーバを起動
HttpServer::new(move ||
App::new()
.service(index)
.service(show_checked)
)
.bind("0.0.0.0:8080")?
.run()
.await?;
Ok(())
}
cargo run等で実行後、Webブラウザでindex.htmlにアクセスしてフォーム送信して挙動を確認いただきたい。
curlコマンドでも再現可能で、以下のような挙動になる。
- Webブラウザで「りんご」「バナナ」を選択した場合の再現
$ curl -X POST -d "fruits[]=apple&fruits[]=banana&button=Send" "http://[ホスト名]:8080/show_checked"
["apple", "banana"]
- Webブラウザで何も選択しない場合の再現
$ curl -X POST -d "button=Send" "http://[ホスト名]:8080/show_checked"
[]