Pavex – Rust API構築のための新しいWebフレームワーク

2023.12.12

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

pavex-1

クイックスタート、チュートリアルなどのガイド用ドキュメントが見たい場合、
下記手順でドキュメントを見ることができます。

% 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は他のそれと一線を画しています。

とても興味深いプロダクトなので、
これからもチェックしていきたいところです。

References