OpenAPI Generator で Rust/axum のコード生成
OpenAPI Generator で Rust/axum が使えるようになっていたので試してみました.
環境
OpenAPI Generator のコマンドラインツールを使うには Node.js と Java が必要です[1].
-
OpenAPI Generator 7.4.0
-
@openapitools/openapi-generator-cli[2] 2.13.2
- Node.js
- Java
-
Rust 1.76.0
- axum 0.7.4
OpenAPI Generator のインストール方法や Rust の環境構築の仕方については省略します.
サンプルリポジトリ
Web API の仕様
今回作成する Web API はいわゆるTODOアプリです. タスクのCRUD操作を行うAPIとユーザー登録を行うAPI, 認証用のJWTを発行するAPIがあります. Web API の詳細については以下のリンク先にあります.
OpenAPI Generator によるコード生成
openapi-generator-cli
を使って axum のコード生成を行います. generator name(-g
) として rust-axum
を指定すると axum のコードが生成されます[3]. 単体のクレートとして生成されるため, 実装を行うクレートとは別のディレクトリに生成します.
$ openapi-generator-cli generate -i reference/spec.yaml -g rust-axum -o ./openapi_gen
[main] INFO o.o.codegen.DefaultGenerator - Generating with dryRun=false
[main] INFO o.o.codegen.DefaultGenerator - OpenAPI Generator: rust-axum (server)
[main] INFO o.o.codegen.DefaultGenerator - Generator 'rust-axum' is considered beta.
[main] INFO o.o.c.l.RustAxumServerCodegen - Environment variable RUST_POST_PROCESS_FILE not defined.
~~~
[main] INFO o.o.codegen.TemplateManager - writing file /workspaces/todo-axum/./openapi_gen/.openapi-generator/VERSION
[main] INFO o.o.codegen.TemplateManager - writing file /workspaces/todo-axum/./openapi_gen/.openapi-generator/FILES
################################################################################
# Thanks for using OpenAPI Generator. #
# Please consider donation to help us maintain this project 🙏 #
# https://opencollective.com/openapi_generator/donate #
################################################################################
上のコマンドを実行すると openapi_gen/
ディレクトリにクレートが生成されます. Cargo.toml
は以下の通り.
[package]
name = "openapi"
version = "1.0.0"
authors = ["OpenAPI Generator team and contributors"]
description = "TODO application Web API example using by OpenAPI Specification"
license = "MIT"
edition = "2021"
[features]
default = ["server"]
server = []
conversion = [
"frunk",
"frunk_derives",
"frunk_core",
"frunk-enum-core",
"frunk-enum-derive",
]
[dependencies]
async-trait = "0.1"
axum = { version = "0.7" }
axum-extra = { version = "0.9", features = ["cookie", "multipart"] }
base64 = "0.21"
bytes = "1"
chrono = { version = "0.4", features = ["serde"] }
frunk = { version = "0.4", optional = true }
frunk-enum-core = { version = "0.3", optional = true }
frunk-enum-derive = { version = "0.3", optional = true }
frunk_core = { version = "0.4", optional = true }
frunk_derives = { version = "0.4", optional = true }
http = "1"
lazy_static = "1"
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
serde_urlencoded = "0.7"
tokio = { version = "1", default-features = false, features = [
"signal",
"rt-multi-thread",
] }
tracing = { version = "0.1", features = ["attributes"] }
uuid = { version = "1", features = ["serde"] }
validator = { version = "0.16", features = ["derive"] }
[dev-dependencies]
tracing-subscriber = "0.3"
3層アーキテクチャのプレゼンテーション層にあたる内容は src/server/mod.rs
として生成されています.
/// Setup API Server.
pub fn new<I, A>(api_impl: I) -> Router
where
I: AsRef<A> + Clone + Send + Sync + 'static,
A: Api + 'static,
{
// build our application with a route
Router::new()
.route("/auth",
post(post_auth::<I, A>)
)
.route("/tasks",
get(get_tasks::<I, A>).post(post_tasks::<I, A>)
)
.route("/tasks/:task_id",
delete(delete_tasks::<I, A>).put(put_tasks::<I, A>)
)
.route("/users",
post(post_users::<I, A>)
)
.with_state(api_impl)
}
リクエストのバリデーションとパースやレスポンス処理が実装されています. パラメータの不足といった400系のエラーに関してはここですでに実装されています. ありがたい.
/// PostUsers - POST /users
#[tracing::instrument(skip_all)]
async fn post_users<I, A>(
method: Method,
host: Host,
cookies: CookieJar,
State(api_impl): State<I>,
Json(body): Json<Option<models::UserCredentials>>,
) -> Result<Response, StatusCode>
where
I: AsRef<A> + Send + Sync,
A: Api,
{
#[allow(clippy::redundant_closure)]
let validation = tokio::task::spawn_blocking(move || post_users_validation(body))
.await
.unwrap();
let Ok((body,)) = validation else {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from(validation.unwrap_err().to_string()))
.map_err(|_| StatusCode::BAD_REQUEST);
};
let result = api_impl
.as_ref()
.post_users(method, host, cookies, body)
.await;
let mut response = Response::builder();
let resp = match result {
Ok(rsp) => match rsp {
PostUsersResponse::Status201(body) => {
let mut response = response.status(201);
{
let mut response_headers = response.headers_mut().unwrap();
response_headers.insert(
CONTENT_TYPE,
HeaderValue::from_str("application/json").map_err(|e| {
error!(error = ?e);
StatusCode::INTERNAL_SERVER_ERROR
})?,
);
}
let body_content = tokio::task::spawn_blocking(move || {
serde_json::to_vec(&body).map_err(|e| {
error!(error = ?e);
StatusCode::INTERNAL_SERVER_ERROR
})
})
.await
.unwrap()?;
response.body(Body::from(body_content))
}
PostUsersResponse::Status400_BadRequest => {
let mut response = response.status(400);
response.body(Body::empty())
}
},
Err(_) => {
// Application code returned an error. This should not happen, as the implementation should
// return a valid response.
response.status(500).body(Body::empty())
}
};
resp.map_err(|e| {
error!(error = ?e);
StatusCode::INTERNAL_SERVER_ERROR
})
}
実装すべきビジネスロジック層はトレイトとして src/lib.rs
に生成されています. パースされたリクエストパラメータなどを受け取って, Result
を使って返すメソッドを実装します. Web API のイベントハンドラをトレイトとして注入することになります.
/// API
#[async_trait]
#[allow(clippy::ptr_arg)]
pub trait Api {
/// ユーザー認証API.
///
/// PostAuth - POST /auth
async fn post_auth(
&self,
method: Method,
host: Host,
cookies: CookieJar,
body: Option<models::UserCredentials>,
) -> Result<PostAuthResponse, String>;
/// タスク削除API.
///
/// DeleteTasks - DELETE /tasks/{taskId}
async fn delete_tasks(
&self,
method: Method,
host: Host,
cookies: CookieJar,
header_params: models::DeleteTasksHeaderParams,
path_params: models::DeleteTasksPathParams,
) -> Result<DeleteTasksResponse, String>;
/// ユーザーのタスク一覧取得API.
///
/// GetTasks - GET /tasks
async fn get_tasks(
&self,
method: Method,
host: Host,
cookies: CookieJar,
header_params: models::GetTasksHeaderParams,
query_params: models::GetTasksQueryParams,
) -> Result<GetTasksResponse, String>;
/// タスク登録API.
///
/// PostTasks - POST /tasks
async fn post_tasks(
&self,
method: Method,
host: Host,
cookies: CookieJar,
header_params: models::PostTasksHeaderParams,
body: Option<models::PostTasksRequest>,
) -> Result<PostTasksResponse, String>;
/// タスク更新API.
///
/// PutTasks - PUT /tasks/{taskId}
async fn put_tasks(
&self,
method: Method,
host: Host,
cookies: CookieJar,
header_params: models::PutTasksHeaderParams,
path_params: models::PutTasksPathParams,
body: Option<models::PutTasksRequest>,
) -> Result<PutTasksResponse, String>;
/// ユーザー登録API.
///
/// PostUsers - POST /users
async fn post_users(
&self,
method: Method,
host: Host,
cookies: CookieJar,
body: Option<models::UserCredentials>,
) -> Result<PostUsersResponse, String>;
}
レスポンスはステータスコードごとに列挙型として定義されています. またレスポンスデータを返す場合は構造体として定義されています.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[must_use]
#[allow(clippy::large_enum_variant)]
pub enum PostUsersResponse {
/// 新しいユーザーが作成されました
Status201(models::PostUsers201Response),
/// Bad Request
Status400_BadRequest,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, validator::Validate)]
#[cfg_attr(feature = "conversion", derive(frunk::LabelledGeneric))]
pub struct PostUsers201Response {
/// ユーザーの一意な識別子
#[serde(rename = "id")]
#[serde(skip_serializing_if="Option::is_none")]
pub id: Option<i64>,
}
依存関係
自動生成されたコードを利用して Web API の実装します. まずは生成されたクレート (openapi_gen
) を本体のクレート (todo
) に追加します. 両者の位置関係は以下の通りです.
.
├─ openapi_gen
└─ todo
その他の依存関係として axum
, axum-extra
, tokio
, serde
を追加します.
[dependencies]
axum = "0.7.4"
axum-extra = { version = "0.9", features = ["cookie", "multipart"] }
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
openapi = {path = "../openapi_gen"}
生成コードの使い方
生成されたコードを使ってサーバーの実装を行います. サーバーの起動処理と Web API のイベントハンドラをそれぞれ実装します.
サーバーの起動処理
サーバーの起動には new
関数で得た Router
を使用します. ビジネスロジック層にあたる Api
トレイトの実装を new
関数に注入して使います.
let router = new(ApiImpl {});
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, router).await.unwrap();
イベントハンドラの実装
Api
トレイトの実装を作成します. 各エンドポイントのイベントハンドラがメソッドとして定義されているため, これらのメソッドの中身を書いていくことになります. またデータ層のような外部依存性はこのメンバとして注入するとよさそうです.
struct ApiImpl {}
impl AsRef<ApiImpl> for ApiImpl {
fn as_ref(&self) -> &ApiImpl {
self
}
}
#[async_trait]
impl Api for ApiImpl {
async fn post_users(
&self,
_method: Method,
_host: Host,
_cookies: CookieJar,
_body: Option<models::UserCredentials>,
) -> Result<PostUsersResponse, String> {
Err(())
}
}
あとはビジネスロジックを書いていくだけです. メソッドでのデータの受け渡しは構造体を使って行われるため, 構造体を受け取って構造体を返すだけです.
async fn post_users(
&self,
_method: Method,
_host: Host,
_cookies: CookieJar,
body: Option<models::UserCredentials>,
) -> Result<PostUsersResponse, String> {
let (email, password) = match body {
None => Err("body is required".to_string()),
Some(body) => Ok((body.email, body.password)),
}?;
Ok(PostUsersResponse::Status201(PostUsers201Response {
id: Some(0),
}))
}
イベントハンドラを実装したらあとはサーバーを起動するだけ.
NITS
触った中で気になった点を挙げます.
SecuritySchemesが使えない
OpenAPI Generator のAPIドキュメントでは SecuritySchemes も利用可能となっていますが, 2024年4月現在では使えないようです. 自前で実装するしかなさそうです.
まだベータ版ではあるものの, かなりコード量が減るので積極的に取り入れたいですね.
-
OpenAPITools/openapi-generator: OpenAPI Generator allows generation of API client libraries (SDK generation), server stubs, documentation and configuration automatically given an OpenAPI Spec (v2, v3) ↩︎
-
Documentation for the rust-axum Generator | OpenAPI Generator ↩︎
-
https://github.com/OpenAPITools/openapi-generator/pull/18621 ↩︎
Discussion