Pavex – Rust API構築のための新しいWebフレームワーク
Introduction
actix-web、axum、Loco、Rocket、Poem、Warpなど、
RustのWebフレームワークはいろいろとありますが、
一暼すると、ある程度似た作りに見えます。
しかし、ここで紹介するPavexは、
他のフレームワークとはアプローチが異なっており、
pavex_cliと呼ばれるトランスパイラにBluePrint(アプリの設計図)を渡すことで
APIサーバ用のコードを生成します。
このフレームワークはZero To Production In Rustの著者である、
Luca Palmieri氏が作成したフレームワークです。
以前から気になっており、先日closed betaが開始されました。
本稿ではPavexの基本情報について解説し、
ドキュメントにそってデモを動かしてみます。
Pavex?
Pavexって何?とかなんで新しくFWを作るの?といった疑問に対する回答は
ここらへんに詳細があります。
↑によると、新たにフレームワークをつくる理由は
「RustのWEBアプリ開発体験をもっと向上させるため」
とのことです。
既存がRust用フレームワークは、
多くの人が簡単に使いやすいAPIを開発できるようにしている。
しかしその一方、高性能でコンパイル段階で誤りが検出できるような
インターフェイスを提供もしたいと考えている。
「簡単に使いやすい」と「コンパイル段階での安全性」を
両立させるのはなかなか難しい問題です。
↑の記事ではaxumを例にして誤使用したときの
エラーの難解さとその回避方法について述べています。
たしかにメッセージはわかりにくいですが、
debug_handlerマクロを使うことでメッセージが改善されます。
axumの#[debug_handler]は、axum-macros featureで使うことができます。
これによりaxumがエラー検出時にその処理を差し替えて、
わかりやすくエラーを伝えることができます。
その結果エラーのコンテキストが豊富になり、原因の特定がしやすくなるため、
デバッグが簡素化されます。
Pavexはこのアプローチを先へ進めようとしています。
ユーザー向けの複雑さを取り除き、Webアプリ用に設計された
pavex_cliにアプリの仕様を渡します。
そして、pavex_cliがWebサーバ用ソースコードを生成します。
問題があれば、わかりやすいメッセージでエラーを教えてくれます。
Pavex Features
↑でいったように、Pavexはアプリの仕様を記述したBlueprintを入力として受け取ります。
route情報の登録と、DIを利用したコンストラクタの登録をしています。
//src/blueprint.rs pub fn blueprint() -> Blueprint { let mut bp = Blueprint::new(); register_common_constructors(&mut bp); add_telemetry_middleware(&mut bp); bp.route(GET, "/api/hello/:message", f!(crate::routes::hello::say)); bp } /// Common constructors used by all routes. fn register_common_constructors(bp: &mut Blueprint) { // Route parameters bp.constructor( f!(pavex::request::route::RouteParams::extract), Lifecycle::RequestScoped, ) .error_handler(f!( pavex::request::route::errors::ExtractRouteParamsError::into_response )); ・・・ }
DIするとき、そのインスタンスのライフサイクルを決めることができます。
上記RouteParamsはRequestScopedなので、リクエストごとにインスタンスが作成されます。
他にSingletonとTransientがあり、用途に応じて設定します。
このへんはSpringなどのDIコンテナ使用した経験があれば
イメージしやすいかと思います。
routes/hello.rsでは下記のようにsay関数が定義されています。
RouteParamsがDIされているので、
say関数でRoute parameterを使うことができます。
use pavex::response::Response; use pavex::request::route::RouteParams; #[RouteParams] pub struct HelloParams { pub message: String, } pub fn say(params: RouteParams<HelloParams>) -> Response { let HelloParams { message }= params.0; Response::ok() .set_typed_body(format!("{message}!!!")) .box_body() }
これをpavex_cliに渡すことで、APIサーバ用のSDKクレートを生成します。
実際に起動するのは、生成されたSDKコードです。
↑で記述したBlueprintはコンパイル時たけ存在し、実行時には使われません。
pavex_cliがこのBlueprintを実行可能なAPI SDKにトランスパイルします。
このように、Pavexがいい具合に中間コードを生成するので、
綿密な静的解析を行うことができます。
そうすることでコンパイル時にわかりやすいエラーメッセージを提示することができます。
あとは必要に応じて独自の要件に対応できるよう、柔軟に設計されています。
フレームワークのコンポーネント実装を置き換えたり、
好きなライブラリと組み合わせたりもできます。
ざっとPavexの概要について説明しましたが、
さらなる詳細はここやここを参考にしてください。
Environment
- MacBook Pro (13-inch, M1, 2020)
- OS : MacOS 13.5.2
- Rust : 1.74.0
- Docker : 24.0.7
Setup
では、Pavexのセットアップをしましょう。
cargo-pxやnightlyのツールなど、必要なものをインストールしましょう。
% cargo install --locked cargo-px --version="~0.1" % rustup toolchain install nightly % rustup component add --toolchain nightly rust-docs-json
cargo-pxはPavexと同じ作者によるツールです。
build.rsを拡張するような機能をもっており、Pavexでコード生成する際に使用します。
nightly用のツールはインストールしていますが、
Pavexはがnightlyを使用してアプリをコンパイルするわけではありません。
実行するコードは、stable toolcahinでコンパイルされます。
nightlyに依存しているのはPavexのコード生成と
コンパイル時のリフレクションに関連する部分です。
自分の環境のバージョンは下記です。
% rustup --version && \ cargo --version && \ cargo px --version rustup 1.26.0 (5af9b9484 2023-04-05) info: This is the version for the rustup toolchain manager, not the rustc compiler. info: The currently active `rustc` version is `rustc 1.74.0 (79e9716c9 2023-11-13)` cargo 1.74.0 (ecb9851af 2023-10-18) cargo 1.74.0 (ecb9851af 2023-10-18)
Docs Server
クイックスタート、チュートリアルなどのガイド用ドキュメントが見たい場合、
下記手順でドキュメントを見ることができます。
% git clone https://github.com/LukeMathWalker/pavex % cd pavex/docs % docker build -t pavex-docs . % cd ../ % docker run --rm -it -p 8001:8000 -v ${PWD}:/docs pavex-docs
localhost:8001でWebサーバが起動するので、
(現時点では未完の部分もありますが)ドキュメントを確認できます。
なお、ドキュメントを更新したい場合は下記コマンドを実行しましょう。
% cd /path/your/pavex % cd libs && cargo doc --no-deps --package pavex
Try
では、↑のドキュメントのQuickstartに沿って(多少端折る)demoアプリを作成してみます。
pavex_cliをインストールしてpavexコマンドを使えるようにしましょう。
% cargo install --locked \ --git "https://github.com/LukeMathWalker/pavex.git" \ --branch "main" \ pavex_cli % pavex --version pavex_cli 0.1.0 (422359f)
pavex newコマンドでdemoプロジェクトの雛形を作成します。
% pavex new demo && cd demo
buildコマンドでビルド。
% cargo px build
checkコマンドで、ローカルパッケージとその依存関係すべてに
エラーがないかチェックします。
% cargo px check
testsディレクトリにあるテストモジュールを実行します。
cargo px test
runコマンドでWebサーバが起動します。
cargo px run
デフォルトでは8000番ポートで起動します。
変えたい人はdemo_server/configuration/dev.ymlで変更しましょう。
demoプロジェクには、デフォルトでHealth checkのAPI(GET /api/ping)があります。
curlでアクセスしてみます。
% curl -v http://localhost:8000/api/ping * Trying ::1:8000... * Connection failed * connect to ::1 port 8000 failed: Connection refused * Trying 127.0.0.1:8000... * Connected to localhost (127.0.0.1) port 8000 (#0) > GET /api/ping HTTP/1.1 > Host: localhost:8000 > User-Agent: curl/7.71.1 > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/1.1 200 OK < content-length: 0 < date: Thu, 07 Dec 2023 08:54:16 GMT < * Connection #0 to host localhost left intact
アクセス成功しました。
このプロジェクトのコードをいじってみましょう。
crateの追加
あとで使用するので、thiserror crateをインストールしておきます。
% cd /path/your/demo % cd demo % cargo add thiserror
インストールする場所に注意。
Brueprint
Brueprintでrouteとコンストラクタを追加します。
demo/src/bluerint.rsを下記のように修正しましょう。
pub fn blueprint() -> Blueprint { let mut bp = Blueprint::new(); register_common_constructors(&mut bp); //追加 bp.constructor( f!(crate::user_agent::UserAgent::extract), Lifecycle::RequestScoped, ).error_handler(f!(crate::user_agent::invalid_user_agent)); add_telemetry_middleware(&mut bp); bp.route(GET, "/api/ping", f!(crate::routes::status::ping)); //追加 bp.route(GET, "/api/greet/:name", f!(crate::routes::greet::greet)); bp }
コンストラクタは独自にDIするUserAgentに関するモジュールです。
bp.routeにパスとhandler関数を指定します。
handlerを作成
では、新しいhandlerを追加してみましょう。
まずはdemo/src/lib.rsに下記コードを追加します。
・ ・ pub mod user_agent;
demo/src/routes/mod.rsにも下記コードを追加します。
・ ・ pub mod greet; //追加
さきほどコンストラクタで指定した、UserAgentを作成します。
UserAgentが不正(UTF-8でない)場合と
見つからない場合はエラーにします。
//src/user_agent.rs use std::fmt; use pavex::http::header::{ToStrError, USER_AGENT}; use pavex::request::RequestHead; use pavex::response::Response; use thiserror::Error; pub enum UserAgent { Unknown, Known(String), } impl fmt::Display for UserAgent { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { UserAgent::Unknown => write!(f, "Unknown"), UserAgent::Known(value) => write!(f, "{}", value), } } } #[derive(Debug, Error)] pub enum MyError { #[error("{0}")] InvalidHeaderValue(String), #[error("{0}")] NotFound(String), } impl UserAgent { pub fn extract(request_head: &RequestHead) -> Result<Self, MyError> { match request_head.headers.get(USER_AGENT) { Some(user_agent) => { user_agent.to_str() .map(|s| UserAgent::Known(s.into())) .map_err(|e| MyError::InvalidHeaderValue(format!("{}{}", "User-Agent:" ,e))) }, None => return Err(MyError::NotFound("User-Agent is Required".into())), }} } pub fn invalid_user_agent(e: &MyError) -> Response { match e { MyError::InvalidHeaderValue(_) => { Response::bad_request() .set_typed_body(e.to_string()) .box_body() }, MyError::NotFound(_) => { Response::not_found() .set_typed_body(e.to_string()) .box_body() } } }
demo/src/routes/greet.rsも作成。
パスで指定されたnameとUserAgentを取得して返します。
use pavex::response::Response; use pavex::request::route::RouteParams; use crate::user_agent::UserAgent; #[RouteParams] pub struct GreetParams { pub name: String, } pub fn greet(params: RouteParams<GreetParams>, user_agent: UserAgent) -> Response { let GreetParams { name } = params.0; println!("{:?}",user_agent.to_string()); Response::ok() .set_typed_body(format!("Hello, {name}!({user_agent})")) .box_body() }
動かしてみます。
% cargo px run
User-Agentを渡せば200OK。
% curl -H "User-Agent: MyUserAgent" -v http://localhost:8000/api/greet/taro ・ ・ Hello, taro!(MyUserAgent)%
User-Agentなしだと404が返ってきます。
% curl -H "User-Agent:" -v http://localhost:8000/api/greet/taro ・ ・ User-Agent is Required%
テストを追加する
テストも追加してみましょう。
demo_server/tests/integration/main.rsにモジュールを追加します。
mod ping; mod helpers; mod greet; //追加
テスト用モジュールを作成します。
greetの成功パターン、UserAgentが見つからないパターン、
UserAgentがUTF8でないパターンのテストケースを作成しています。
//demo_server/tests/integration/greet.rs use crate::helpers::TestApi; use pavex::http::StatusCode; #[tokio::test] async fn greet_happy_path() { let api = TestApi::spawn().await; let name = "Ursula"; let response = api .api_client .get(&format!("{}/api/greet/{name}", &api.api_address)) .header("User-Agent", "Test runner") .send() .await .expect("Failed to execute request."); assert_eq!(response.status().as_u16(), StatusCode::OK.as_u16()); assert_eq!(response.text().await.unwrap(), "Hello, Ursula!(Test runner)"); } #[tokio::test] async fn non_utf8_user_agent_is_rejected() { let api = TestApi::spawn().await; let name = "Ursula"; let response = api .api_client .get(&format!("{}/api/greet/{name}", &api.api_address)) .header("User-Agent", b"hello\xfa".as_slice()) .send() .await .expect("Failed to execute request."); assert_eq!(response.status().as_u16(), StatusCode::BAD_REQUEST.as_u16()); assert_eq!( response.text().await.unwrap(), "User-Agent:failed to convert header to a str" ); } #[tokio::test] async fn notfound_user_agent() { let api = TestApi::spawn().await; let name = "Ursula"; let response = api .api_client .get(&format!("{}/api/greet/{name}", &api.api_address)) .send() .await .expect("Failed to execute request."); assert_eq!(response.status().as_u16(), StatusCode::NOT_FOUND.as_u16()); assert_eq!( response.text().await.unwrap(), "User-Agent is Required" ); }
testコマンドでテストします。
% cargo px test running 4 tests test ping::ping_works ... ok test greet::notfound_user_agent ... ok test greet::non_utf8_user_agent_is_rejected ... ok test greet::greet_happy_path ... ok test result: ok. 4 passed; ・・・・
無事パスしました。
Summary
今回はRust用の最新フレームワーク、Pavexについて紹介しました。
既存のフレームワークと比較すると、
Pavexは他のそれと一線を画しています。
とても興味深いプロダクトなので、
これからもチェックしていきたいところです。