こんにちは、お手伝いの大櫛です。
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の印象も強いです:
ただし、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} # 認証が必要
マクロによる生成
このセクションでのサンプルコードはこちらです:
セットアップの手間という観点ではマクロによる生成が最もステップ少なく利用できます。 ただし、エラーを追跡しづらい点には注意が必要です。
ミニマムな例だと、以下のように利用できます。
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として使われているため、使いやすさの観点でも嬉しいです:
事前にカスタマイズされた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による生成
このセクションでのサンプルコードはこちらです:
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は positional
と builder
の2パターンを選択できます。デフォルトはpositionalで、先に例示したコードではこちらを使用しています。挙動としては、 Client
にAPIコールするメソッドを直接生やす形になります。
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という既存資産を利用する形になっているのも、利用者にとっては馴染み深くありがたいです。