🦀

OpenAPI Generator で Rust/axum のコード生成

2024/04/26に公開

OpenAPI Generator で Rust/axum が使えるようになっていたので試してみました.

https://github.com/OpenAPITools/openapi-generator/pull/17549

環境

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 の環境構築の仕方については省略します.

サンプルリポジトリ

https://github.com/toms74209200/todo-axum

Web API の仕様

今回作成する Web API はいわゆるTODOアプリです. タスクのCRUD操作を行うAPIとユーザー登録を行うAPI, 認証用のJWTを発行するAPIがあります. Web API の詳細については以下のリンク先にあります.

openapi-todo-example
https://toms74209200.github.io/openapi-todo-example/

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 は以下の通り.

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 として生成されています.

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 のイベントハンドラをトレイトとして注入することになります.

src/lib.rs
/// 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>;

}

レスポンスはステータスコードごとに列挙型として定義されています. またレスポンスデータを返す場合は構造体として定義されています.

src/lib.rs
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[must_use]
#[allow(clippy::large_enum_variant)]
pub enum PostUsersResponse {
    /// 新しいユーザーが作成されました
    Status201(models::PostUsers201Response),
    /// Bad Request
    Status400_BadRequest,
}
src/models.rs
#[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 を追加します.

Cargo.toml
[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"}

https://github.com/toms74209200/todo-axum/blob/master/todo/Cargo.toml

生成コードの使い方

生成されたコードを使ってサーバーの実装を行います. サーバーの起動処理と 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月現在では使えないようです. 自前で実装するしかなさそうです.


まだベータ版ではあるものの, かなりコード量が減るので積極的に取り入れたいですね.

脚注
  1. 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) ↩︎

  2. @openapitools/openapi-generator-cli - npm ↩︎

  3. Documentation for the rust-axum Generator | OpenAPI Generator ↩︎

Discussion