paild tech blog

法人カードのクラウド型発行・管理サービスpaild

OpenAPIドキュメントを基にRust API clientを自動生成できるProgenitorを試す

こんにちは、お手伝いの大櫛です。

RustにもOpenAPI関連のツールやライブラリなどが増えてきており、開発需要の高まりを感じます。 Rustに限った話ではないですが、OpenAPIとのインテグレーションパターンについては、サーバーのアプリケーションコードからドキュメントを生成するもの、ドキュメントからクライアントを生成するものなどなど色々なバリエーションが存在します。

そんな中で今回は「OpenAPIドキュメントからRust用API clientを自動生成」してくれるProgenitorをお試ししてみます。 GitHub repositoryはこちら:

https://github.com/oxidecomputer/progenitorgithub.com

概要

ProgenitorはOxide Computerにより公開されています。ただしMPL-2.0によりライセンスされている表記がCargo.tomlにはありますが、repositoryのCargo workspace内のcrateでライセンスファイルを含んでいるのはprogenitor crateだけです(2023年11月18日時点)。

OxideというとがっつりRustを使って開発が行われている印象で、個人的にはREST API serverを構築できるDropshotの印象も強いです:

github.com

ただし、progenitorにもDropshotにも共通して言えることですが、これらcrateはあくまでOxide社内での利用が第一となっており、第三者による利用が最優先に捉えられているわけではないことに注意が必要です*1

Progenitorはマクロやbuild.rsなどを通して利用できます。今回はサポートされているユースケースそれぞれを簡単に見ていきます。

API serverについては、utoipaのexampleの一つである以下のコードを利用します:

https://github.com/juhaku/utoipa/tree/048d8984637c86152e0e57eed11f3b4968b20957/examples/todo-actixgithub.com

actix-webによるtodo appをベースに、utoipaによるOpenAPIドキュメントの公開と生成を行っています。 utoipaはとても便利なので、気になった方は以下もご覧ください:

https://github.com/juhaku/utoipagithub.com

今回のサンプルコードでもSwagger UI、Redoc、そしてRapiDocによるドキュメント公開を簡単に行えています。とても便利。

全体的なサンプルコードは以下で公開しています:

https://github.com/JohnTitor/tryout-progenitorgithub.com

動作確認はRust 1.74を使って行っています:

rustc 1.74.0 (79e9716c9 2023-11-13)

最後に、今回todo appとして公開されているエンドポイントは以下の通りです:

GET /todo
POST /todo
GET /todo/search
GET /todo/{id}
PUT /todo/{id} # 認証が必要
DELETE /todo/{id} # 認証が必要

マクロによる生成

このセクションでのサンプルコードはこちらです:

github.com

セットアップの手間という観点ではマクロによる生成が最もステップ少なく利用できます。 ただし、エラーを追跡しづらい点には注意が必要です。

ミニマムな例だと、以下のように利用できます。

pub use reqwest::Client;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    progenitor::generate_api!("./docs/openapi.json");

    let client = Client::new("http://localhost:8080");
    let api_version = client.api_version();
    println!("api version: {api_version}");
    Ok(())
}

reqwestをベースにクライアントを作成しており、デフォルトではこの Client のメソッドとして各APIコールが自動で実装されます。

// GET /todo
client
    .create_todo(&types::Todo {
        id: 10,
        value: "Eat breakfast".to_string(),
        checked: true,
    })
    .await?;

// POST /todo
client.get_todos().await?;

// DELETE /todo/{id}
client.delete_todo(todo.id).await?;

メソッド名にはOpenAPIドキュメントの operationId が使われているようです。 また description fieldもdoc commentとして使われているため、使いやすさの観点でも嬉しいです:

create_todoのdoc comment

事前にカスタマイズされたreqwestの Client を渡すこともできます。以下はデフォルトヘッダーを設定する例です:

const API_KEY: &str = "utoipa-rocks";

progenitor::generate_api!("./docs/openapi.json");

let mut headers = reqwest::header::HeaderMap::new();
headers.insert("todo_apikey", API_KEY.parse().unwrap());
let default_client = reqwest::ClientBuilder::new()
    .default_headers(headers)
    .build()?;
let client = Client::new_with_client("http://localhost:8080", default_client);

build.rs による生成

このセクションでのサンプルコードはこちらです:

https://github.com/JohnTitor/tryout-progenitor/tree/sec-02github.com

2つ目は build.rs 経由でビルド時に生成する方法です。READMEに記載されているような build.rs を用意すれば、あとはほとんどマクロのときと同じように使用できます。 差分は以下の通りです:

- pub use reqwest::Client;
- progenitor::generate_api!("./docs/openapi.json");
+ include!(concat!(env!("OUT_DIR"), "/codegen.rs"));

上記例では OUT_DIR を参照していますが、出力ディレクトリを調整すれば生成ファイルをバージョン管理下に置くこともできます。 後述するCLI commandによる生成との大きな差分として、ファイルとして出力するか、別crateとして出力したいかという部分があります。この辺りはユースケースに合わせて選択していくことになりそうです。

CLI commandによる生成

このセクションでのサンプルコードはこちらです:

github.com

cargo-progenitorというbinary crateが提供されているのでこれをインストールし、Cargo subcommandとして呼び出すことができます:

cargo install cargo-progenitor

例えば、actix-todoというdirectoryに同名のcrateを作成する際には以下のように実現できます:

cargo progenitor -i ./docs/openapi.json -o actix-todo -n actix-todo -v 0.1.0

結構細かく指定できる他、後述する自動生成の挙動についてのオプションもあります。

このcrate内に Client が生成されるので、生成されたcrateをimportすれば他の方法と同様に利用・実装できます。

自動生成の挙動

生成方法を問わず、自動生成の挙動についてinterfaceとtagを指定できます。

interface

interfaceは positionalbuilder の2パターンを選択できます。デフォルトはpositionalで、先に例示したコードではこちらを使用しています。挙動としては、 ClientAPIコールするメソッドを直接生やす形になります。

builderパターンは、 Client から各APIコールのためのメソッドを呼び出すとそれぞれの構造体が返ってくる形となります。例えばTODOを作成する際は builder::CreateTodo が返却され、このメソッドを使ってparamsを渡したり実際にAPIコールを行ったりする必要があります:

client
    .create_todo()
    .body(&types::Todo {
        id: 2,
        value: "Attend a daily standup".to_string(),
        checked: false,
    })
    .send()
    .await?;

これだけ見ると少し冗長になっただけのような気がしますが、Optionalな、OpenAPI的に言うと required: false なparamsを渡すときに便利です。

例えば、search todosについてqueryをoptionalとし、渡されなかった場合には全件返すような修正を加えたとします。この場合、positionalパターンだと明示的に None を渡す必要があります:

client.search_todos(None).await?;

それに対し、builderパターンだと None を渡すことなく呼び出せます:

client
        .search_todos()
        .send()
        .await?

明示的にすべて指定した方が分かりやすいという見方もありそうですが、利用者側でこれら実装パターンを選択できる余地があるのはメリットです。APIの数や形などでもどちらがいいか変わってきそうですね。

tag

OpenAPIドキュメントのタグごとにtraitを分けるか( Separate )、 Client 一つにすべてのメソッドを実装するか( Merged )を選択できます。デフォルトの挙動は後者の Merged です。

今回の例だとtodoというタグ一つだけなので ClientTodoExt というextension traitが一つ生成されるのみですが、例えば認証が必要なものについてタグを分けると Client に対してそれらへのメソッドを生やすかどうかを選択できるようになります。

traitを実装(インポート)しなければ、誤って未認証のまま認証が必要なAPIコールをしてしまう恐れがないので安心です。このようにAPIコールをある程度グルーピングしたいときには有用なオプションです。

まとめ

今回はOpenAPIドキュメントをもとにAPI clientを生成できるProgenitorを紹介しました。

Oxide内部で利用されているものを公開した形なので、第三者が使うには少しドキュメント不足な部分もありそうですが、doc commentもうまく自動生成してくれるなど、使い勝手はとてもいいように感じました。 今回は試しませんでしたが、wasm環境でも使えるようです。Oxideでの使われ方が気になりますね。

またreqwestという既存資産を利用する形になっているのも、利用者にとっては馴染み深くありがたいです。