今までwebフレームワークは actix/actix-web にお世話になってきたんですが
最近は seanmonstar/warp をよく見かけるようになってきたので使ってみようと思いたってやってみたメモ。
環境
- OS: Ubuntu 20.04
- DockerCompose: 1.27.4
- Rust: 1.49.0
[package]
name = "rust_warp_examples"
version = "0.1.0"
authors = ["yagince <xxxx@gmail.com>"]
edition = "2018"
[dependencies]
anyhow = "=1.0.36"
thiserror = "=1.0.23"
warp = "=0.3.0"
tokio = { version = "1", features = ["full"] }
env_logger = "=0.8.2"
serde_derive = "=1.0.118"
serde = "=1.0.118"
serde_json = "=1.0.61"
[dev-dependencies]
assert-json-diff = "1.1.0"
実行するようのdocker-compose.ymlを用意
FROM rust:1.49.0-slim-buster
ENV CARGO_TARGET_DIR=/tmp/target \
DEBIAN_FRONTEND=noninteractive \
LC_CTYPE=ja_JP.utf8 \
LANG=ja_JP.utf8
RUN apt-get update \
&& apt-get install -y -q \
ca-certificates \
locales \
apt-transport-https\
libssl-dev \
pkg-config \
curl \
build-essential \
&& echo "ja_JP UTF-8" > /etc/locale.gen \
&& locale-gen \
\
&& echo "install rust tools" \
&& rustup component add rustfmt \
&& cargo install cargo-watch cargo-make
WORKDIR /app
CMD ["cargo", "run"]
version: "3.7"
services:
app:
build:
context: .
container_name: app
working_dir: /app
command: cargo watch -x run
tty: true
volumes:
- ./:/app
ports:
- 3000:3000
今回はまだDBとか使わないのでシンプル
まずはシンプルにhello worldしてみる
まずはREADMEに書いてあるExampleを書いてみる
use warp::Filter;
#[tokio::main]
async fn main() {
// GET /hello/warp => 200 OK with body "Hello, warp!"
let hello = warp::path!("hello" / String).map(|name| format!("Hello, {}!", name));
warp::serve(hello).run(([0, 0, 0, 0], 3000)).await;
}
docker-compose up -d
で起動
❯ curl -v http://localhost:3000/hello/warp
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /hello/warp HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.73.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain; charset=utf-8
< content-length: 12
< date: Thu, 07 Jan 2021 13:12:23 GMT
<
* Connection #0 to host localhost left intact
Hello, warp!%
うん、とりあえず動いた
ログを出したい
アクセスログが出たほうが動作確認しやすいので、ログを出したいですね
この辺を真似てみます。
use warp::Filter;
#[tokio::main]
async fn main() {
std::env::set_var("RUST_LOG", "example");
env_logger::init();
// GET /hello/warp => 200 OK with body "Hello, warp!"
let hello = warp::path!("hello" / String).map(|name| format!("Hello, {}!", name));
let routes = hello.with(warp::log("example"));
warp::serve(routes).run(([0, 0, 0, 0], 3000)).await;
}
[2021-01-07T13:22:19Z INFO warp::filters::log] 192.168.16.1:58474 "GET /hello/warp HTTP/1.1" 200 "-" "curl/7.73.0" 84.539µs
[2021-01-07T13:22:20Z INFO warp::filters::log] 192.168.16.1:58478 "GET /hello/warp HTTP/1.1" 200 "-" "curl/7.73.0" 81.013µs
こんな感じで出るようになりました。
※ warp::log
で指定した名前が RUST_LOG
で設定されていないと出ません。
filterとhandlerを真似てみる
このexampleが一番実際なにかアプリケーションを作った場合に近い気がするので、真似てみます。
use warp::Filter;
pub mod filters;
pub mod handlers;
#[tokio::main]
async fn main() {
std::env::set_var("RUST_LOG", "example");
env_logger::init();
let hello = filters::hello();
let routes = hello.with(warp::log("example"));
warp::serve(routes).run(([0, 0, 0, 0], 3000)).await;
}
use std::convert::Infallible;
pub async fn hello(name: String) -> Result<impl warp::Reply, Infallible> {
Ok(format!("Hello, {}!", name))
}
use super::handlers;
use warp::Filter;
pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / String)
.and(warp::get())
.and_then(handlers::hello)
}
❯ curl -v http://localhost:3000/hello/warp
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /hello/warp HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.73.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain; charset=utf-8
< content-length: 12
< date: Thu, 07 Jan 2021 13:42:14 GMT
<
* Connection #0 to host localhost left intact
Hello, warp!%
うん、動きました。
ちょっとハマったポイントとしては
use warp::Filter;
が必要だったってことですね。
use super::handlers;
pub fn hello() -> impl warp::Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / String)
.and(warp::get())
.and_then(handlers::hello)
}
こんな風に書くと
app | error[E0599]: no method named `and` found for struct `warp::filter::and::And<warp::filter::and::And<warp::filter::and::And<impl warp::Filter+std::marker::Copy, Exact<warp::path::internal::Opaque<__StaticPath>>>, impl warp::Filter+std::marker::Copy>, impl warp::Filter+std::marker::Copy>` in the current scope
app | --> src/filters.rs:5:10
app | |
app | 5 | .and(warp::get())
app | | ^^^ method not found in `warp::filter::and::And<warp::filter::and::And<warp::filter::and::And<impl warp::Filter+std::marker::Copy, Exact<warp::path::internal::Opaque<__StaticPath>>>, impl warp::Filter+std::marker::Copy>, impl warp::Filter+std::marker::Copy>`
app | |
app | = help: items from traits can only be used if the trait is in scope
app | = note: the following trait is implemented but not in scope; perhaps add a `use` for it:
app | `use warp::Filter;`
こんな風に怒られます。
ちゃんと use warp::Filter;
ってエラーに書いてあるのですぐ気づくっちゃ気づくんですが。
おそらくwarp::Filterにtraitがいろいろあるんですね。
なるほど。
テストを書いてみる
なるほど、filter毎に単体テストを書けるようなしくみがサポートされてるんですね。
良い( ・∀・)イイ!!
docker-compose run --rm app bash
cargo watch -x test
先程のページにかかれてるサンプル通り書いてみる
use super::handlers;
use warp::Filter;
pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / String)
.and(warp::get())
.and_then(handlers::hello)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hello() {
let filter = hello();
let res = warp::test::request().path("/hello/warp").reply(&filter);
assert_eq!(res.status(), 200);
}
}
error[E0599]: no method named `status` found for opaque type `impl warp::Future` in the current scope
--> src/filters.rs:20:24
|
20 | assert_eq!(res.status(), 200);
| ^^^^^^ method not found in `impl warp::Future`
|
help: consider `await`ing on the `Future` and calling the method on its `Output`
|
20 | assert_eq!(res.await.status(), 200);
| ^^^^^^
おぉ、なるほど。Futureが返ってくるからawaitしないとイケナイらしい。
use super::handlers;
use warp::Filter;
pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / String)
.and(warp::get())
.and_then(handlers::hello)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_hello() {
let filter = hello();
let res = warp::test::request()
.path("/hello/warp")
.reply(&filter)
.await;
assert_eq!(res.status(), 200);
}
}
running 1 test
test filters::tests::test_hello ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
うん、通りましたね。
res.body
も見てみます。
use super::handlers;
use warp::Filter;
pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / String)
.and(warp::get())
.and_then(handlers::hello)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_hello() {
let filter = hello();
let res = warp::test::request()
.path("/hello/warp")
.reply(&filter)
.await;
assert_eq!(res.status(), 200);
assert_eq!(res.body(), "Hello, warp");
}
}
running 1 test
test filters::tests::test_hello ... FAILED
failures:
---- filters::tests::test_hello stdout ----
thread 'filters::tests::test_hello' panicked at 'assertion failed: `(left == right)`
left: `b"Hello, warp!"`,
right: `"Hello, warp"`', src/filters.rs:24:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
filters::tests::test_hello
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
あぁ、なるほど、バイナリか。
use super::handlers;
use warp::Filter;
pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / String)
.and(warp::get())
.and_then(handlers::hello)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_hello() {
let filter = hello();
let res = warp::test::request()
.path("/hello/warp")
.reply(&filter)
.await;
assert_eq!(res.status(), 200);
assert_eq!(res.body(), "Hello, warp!".as_bytes());
}
}
running 1 test
test filters::tests::test_hello ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
パスしました。
JSONを返してみる
Handler
use serde_derive::Serialize;
use std::convert::Infallible;
pub async fn hello(name: String) -> Result<impl warp::Reply, Infallible> {
Ok(format!("Hello, {}!", name))
}
#[derive(Debug, Clone, PartialEq, Serialize)]
struct HelloJson {
data: String,
}
pub async fn hello_json(name: String) -> Result<impl warp::Reply, Infallible> {
Ok(warp::reply::json(&HelloJson {
data: format!("Hello, {}!", name),
}))
}
Filter
use super::handlers;
use warp::Filter;
pub fn api() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
hello().or(hello_json())
}
pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / String)
.and(warp::get())
.and_then(handlers::hello)
}
pub fn hello_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / "json" / String)
.and(warp::get())
.and_then(handlers::hello_json)
}
#[cfg(test)]
mod tests {
use super::*;
use assert_json_diff::assert_json_eq;
use serde_json::json;
#[tokio::test]
async fn test_hello() {
let filter = api();
let res = warp::test::request()
.path("/hello/warp")
.reply(&filter)
.await;
assert_eq!(res.status(), 200);
assert_eq!(res.body(), "Hello, warp!".as_bytes());
}
#[tokio::test]
async fn test_hello_json() -> anyhow::Result<()> {
let filter = api();
let res = warp::test::request()
.path("/hello/json/warp")
.reply(&filter)
.await;
assert_eq!(res.status(), 200);
assert_json_eq!(
serde_json::from_slice::<serde_json::Value>(res.body())?,
json!({ "data": "Hello, warp!"})
);
Ok(())
}
}
-
hello_json
を追加-
warp::reply::json
で Serializeを実装した構造体を渡す
-
-
hello_json
のテストを追加-
hello
とhello_json
を両方ハンドリングするapi
を追加 - これをテスト対象にする
- jsonの期待値は文字列で書くと辛いので、serde_json::jsonマクロにおまかせして、assert-json-diffを使ってassert
-
Test
running 2 tests
test filters::tests::test_hello ... ok
test filters::tests::test_hello_json ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
通りましたね。
なるほど。
Infalliable
ってどんな型?
impl<T, U> TryFrom<U> for T where U: Into<T> {
type Error = Infallible;
fn try_from(value: U) -> Result<Self, Infallible> {
Ok(U::into(value)) // Never returns `Err`
}
}
なるほど
Result
を返さなきゃいけないけど、絶対失敗しないので必ず Ok
を返すような時の ResultのErrに使う型、ですかね?
将来的には !
という never
型になると?
ふむ、なるほど。
POSTでJSONデータを受け取ってみる
そろそろテスト駆動で行けそうなので、テストを先に書いてみる
Test
...
#[tokio::test]
async fn test_receive_json() -> anyhow::Result<()> {
let body = json!({ "data": "Hello, warp!"});
let res = warp::test::request()
.method("POST")
.path("/receive/json")
.body(body.to_string())
.reply(&api())
.await;
assert_eq!(res.status(), 200);
assert_json_eq!(
serde_json::from_slice::<serde_json::Value>(res.body())?,
body,
);
Ok(())
}
...
-
{ "data": "..." }
っていうJSONを投げる - 投げたJSONがそのまま返ってくる
...
running 3 tests
test filters::tests::test_hello ... ok
test filters::tests::test_receive_json ... FAILED
test filters::tests::test_hello_json ... ok
failures:
---- filters::tests::test_receive_json stdout ----
thread 'filters::tests::test_receive_json' panicked at 'assertion failed: `(left == right)`
left: `404`,
right: `200`', src/filters.rs:75:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
filters::tests::test_receive_json
test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
...
当然まだ404ですね
Filter
use super::handlers;
use warp::Filter;
pub fn api() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
hello().or(hello_json()).or(receive_json()) // 追加
}
pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / String)
.and(warp::get())
.and_then(handlers::hello)
}
pub fn hello_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("hello" / "json" / String)
.and(warp::get())
.and_then(handlers::hello_json)
}
// 追加
pub fn receive_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("receive" / "json")
.and(warp::post())
.and(warp::body::json())
.and_then(handlers::receive_json)
}
...
-
receive_json
を追加- methodは
POST
- bodyは
JSON
- methodは
Handler
use serde_derive::{Deserialize, Serialize};
use std::convert::Infallible;
pub async fn hello(name: String) -> Result<impl warp::Reply, Infallible> {
Ok(format!("Hello, {}!", name))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HelloJson {
data: String,
}
pub async fn hello_json(name: String) -> Result<impl warp::Reply, Infallible> {
Ok(warp::reply::json(&HelloJson {
data: format!("Hello, {}!", name),
}))
}
pub async fn receive_json(body: HelloJson) -> Result<impl warp::Reply, Infallible> {
Ok(warp::reply::json(&body))
}
-
HelloJson
をそのまま使う- 受け取ったbodyをマッピングする為に
Deserialize
を deriveに追加 - 引数で受け取るのでpubに変更
- 受け取ったbodyをマッピングする為に
Test
running 3 tests
test filters::tests::test_hello ... ok
test filters::tests::test_hello_json ... ok
test filters::tests::test_receive_json ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
OK
helloをDRYにしたい
hello/{param}
hello/json/{param}
は hello
で始まるルーティングとしてまとめたい
use super::handlers;
use warp::Filter;
pub fn api() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
hello().or(receive_json())
}
pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path("hello").and(hello_string().or(hello_json()))
}
pub fn hello_string() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!(String)
.and(warp::get())
.and_then(handlers::hello)
}
pub fn hello_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("json" / String)
.and(warp::get())
.and_then(handlers::hello_json)
}
-
warp::path("hello")
の後につなげればいい- マクロではなく関数の方を使う
bodyのDeserializeができない場合のレスポンスはどうなるのか
#[tokio::test]
async fn test_receive_json_invalid() -> anyhow::Result<()> {
let body = json!({ "hoge": "foo"});
let res = warp::test::request()
.method("POST")
.path("/receive/json")
.body(body.to_string())
.reply(&api())
.await;
assert_eq!(res.status(), 400);
assert_eq!(
res.body(),
"Request body deserialize error: missing field `data` at line 1 column 14".as_bytes()
);
Ok(())
}
こんな感じになるようです
serdeのエラーメッセージがBodyに入って返ってくるんですね。
認証Filter入れてみたい
Authorizationヘッダを見て認証してみたい
Test
#[tokio::test]
async fn test_with_auth() -> anyhow::Result<()> {
let body = json!({ "data": "foo"});
let res = warp::test::request()
.method("POST")
.header("Authorization", "Bearer hogehoge")
.path("/with_auth/json")
.body(body.to_string())
.reply(&api())
.await;
dbg!(res.body());
assert_eq!(res.status(), 200);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(res.body())?,
json!({
"user": { "id": 0, "name": "hoge" },
"body": body,
}),
);
Ok(())
}
こんな感じを目指します。
Authorization
ヘッダに Bearerトークンを入れてみる。
https://github.com/seanmonstar/warp/blob/6cc14a44ba/examples/rejections.rs#L80-L122
Rejectionをハンドリングしてレスポンスをカスタマイズできそう。
(今回はやらない)
Filter
https://blog.logrocket.com/jwt-authentication-in-rust/
これを参考にやってみます。
ログインは一旦すっとばして、トークンを受け取って認証するFilterを入れてみたい。
pub fn api() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
hello().or(receive_json()).or(with_auth_json())
}
...
pub fn with_auth_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone
{
warp::path!("with_auth" / "json")
.and(with_authenticate())
.and(warp::post())
.and(warp::body::json())
.and_then(handlers::with_auth)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct User {
pub id: u64,
pub name: String,
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("no auth header")]
NoAuthHeaderError,
#[error("invalid auth header")]
InvalidAuthHeaderError,
}
impl warp::reject::Reject for Error {}
pub fn with_authenticate() -> impl Filter<Extract = (User,), Error = warp::Rejection> + Clone {
warp::header::headers_cloned().and_then(authorize)
}
async fn authorize(
headers: warp::http::HeaderMap<warp::http::HeaderValue>,
) -> Result<User, warp::Rejection> {
match token_from_header(&headers) {
Ok(token) => {
if token != "hogehoge" {
return Err(warp::reject::custom(Error::InvalidAuthHeaderError));
}
Ok(User {
id: 0,
name: "hoge".to_owned(),
})
}
Err(e) => Err(warp::reject::custom(e)),
}
}
fn token_from_header(
headers: &warp::http::HeaderMap<warp::http::HeaderValue>,
) -> Result<String, Error> {
let header = headers
.get(warp::http::header::AUTHORIZATION)
.ok_or_else(|| Error::NoAuthHeaderError)?;
let value = std::str::from_utf8(header.as_bytes()).map_err(|_| Error::NoAuthHeaderError)?;
if !value.starts_with("Bearer ") {
return Err(Error::InvalidAuthHeaderError);
}
Ok(value.trim_start_matches("Bearer ").to_owned())
}
- thiserrorでError型を定義
- ヘッダーの処理で失敗した時に使う
- ここはとりあえずanyhow::Errorで雑にやってもよかったかも
-
Authorization
ヘッダーからトークンを取得- ヘッダーが無い場合や、Bearerトークンじゃない場合はエラー
- トークンが
hogehoge
だったらUser
を返す- トークンが違ったらエラー
- 後のhandlerにUserを渡す
Handler
...
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WithAuthJson {
user: User,
body: HelloJson,
}
pub async fn with_auth(user: User, body: HelloJson) -> Result<impl warp::Reply, Infallible> {
Ok(warp::reply::json(&WithAuthJson { user, body }))
}
- filtersで
with_authenticate
がセットしたUser
を第一引数で受け取る- filtersで定義したandの順番になるっぽい
- なので、user -> bodyの順番
- 受け取ったuserとbodyをそのままJSONとして返す
running 5 tests
test filters::tests::test_hello ... ok
test filters::tests::test_hello_json ... ok
test filters::tests::test_receive_json_invalid ... ok
test filters::tests::test_with_auth ... ok
test filters::tests::test_receive_json ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
テストは通りましたね。
なるほど。
まとめ
- 一旦今回はWebAPIを実装する前提で最低限試してみたい事をちょろっとやってみました
- Warpは基本的にFilterを実装して、つなげていく
- わかりやすいといえばわかりやすい
- こういうのどうやってやるの?があまり探しやすくはない
- warpがpub useしている外部ライブラリ(例えばwarp::http)はそれがwarp外のcrateである事を知っていないとドキュメントまでたどり着くのが大変
- exampleの種類は多いがあくまでexample
- なんというかもう少し実際のWebアプリケーションなexampleがあると嬉しい
- 例えばdieselと組み合わせてるサンプルみたいなのとか
- テストは書きやすい
- warpくらいシンプルなフレームワークもアリだなと思った
- actix-webがRailsだとしたらwarpはSinatraみたいな雰囲気?
- actix-webはRailsほどフルスタックではないけど…
- actix-webがRailsだとしたらwarpはSinatraみたいな雰囲気?
- どうでもいいことだけど、どうしても
wrap
ってtypoする。。。 - actix-webとパフォーマンスを比較してみたい
- 次回は
sqlx
と組み合わせて使ってみたい