2025/05/07
はじめに
Rustで実装するAPIを、OpenAPIを介してClient実装に活用したいことがある。
そこで、RustコードからOpenAPIを自動生成するutoipaというクレートがあげられ、便利なので将来使うときにすぐに使えるようにメモを残しておく。
新しいTipsなどがあれば、随時更新していきたい。
環境やバージョン
$ rustc --version
rustc 1.87.0-nightly (8c392966a 2025-03-01)
$ cargo --version
cargo 1.87.0-nightly (2622e844b 2025-02-28)
$ cat Cargo.toml
[package]
name = "sample-axum-utoipa"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.4"
tokio = { version = "1.45.0", features = ["full"] }
utoipa = "5.3.1"
utoipa-axum = "0.2.0"
ここではweb application frameworkとしてaxumを使うためutoipa-axumを用いているが、他のフレームワークを使う場合、axtix-webではutoipa-actix-webで同様のことが、rocketではrocket-extras featureで類似のこと(こっちはRoutingは違うかも)ができると思われる。
概要
utoipaは、Rustを使ってコードファーストのアプローチでOpenAPIドキュメント(以下、簡単のためoapi-specとする)を生成するためのクレートである。関連クレートも一つのリポジトリにまとめられているため、何かを探すときはutoipaを参照するのが良いだろう。
中心となるクレートの utoipaは、基本的な機能やマクロなどを提供している。実際、oapi-specを生成でいうと、こちらだけで行うことも可能である。
utoipa-axumは、axumのためのAPIを提供しており、axumのルータやハンドラに対してoapi-specを生成するためのマクロや機能を提供している。特に、axum::Router
との連携が特に有用で、コードファーストのアプローチの利点を増やすことが可能。
本記事内においては、 utoipa_axum::router::OpenApiRouter
を用いるものとする。この目的として、コードファーストにするからには、Routing設定とoapi-spec設定の両方を一つの場所で管理し、ドリフトを防ぐという意図がある。
基本形となるスニペット
router, oapi-specを一元的に定義
以下の記述で、routeを定義する。これにより、router
, oapi-specの両方を同条件で生成することができる。
OpenApiRouter::new()
のrouterはaxum::serve
を直下に書かない限り、型を指定する必要がある。
let (router, mut openapi) = utoipa_axum::router::OpenApiRouter::new()
.routes(utoipa_axum::routes!(f))
.split_for_parts();
println!("{}", openapi.to_json());
let listener = tokio::net::TcpListener::bind("localhost:8080").await?;
axum::serve(listener, router).await?;
handler定義 GET
簡素化した最小限の形が以下のようになる。
operation_id
は全体で一意であればよく、命名記法なども特に制約はない。
#[utoipa::path(
get, path = "/hello",
operation_id = "hello_get",
responses(
(status = 200, description = "Greet", body = String),
(status = 500, description = "Internal Server Error", body = String)
)
)]
pub async fn hello() -> String {
String::from("Hello, world!")
}
型定義して、それをbodyとするhandler定義 POST
oapi-specに含ませたい型定義は、以下のようにutoipa::ToSchema
トレイトを実装することで、oapi-specに含めることができる。
bodyの場合は、RequestからJsonにするため、serde::Deserialize
トレイトも実装する必要がある。反対に、Responseの場合は、serde:::Serialize
トレイトを実装する必要がある。
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct EchoInput {
pub message: String,
}
#[utoipa::path(
post, path = "/echo",
operation_id = "echo_post",
request_body = String,
responses(
(status = 200, description = "Echo", body = String),
(status = 400, description = "Bad Request", body = String)
)
)]
pub async fn echo(axum::extract::Json(body): axum::extract::Json<EchoInput>) -> String {
body.message
}
tips
クエリパラメータを指定する
こちら結構設定が煩雑で、他にもっと良い方法があるかもしれないが、以下のようにすることで、クエリパラメータを指定することができる。
#[derive(utoipa::IntoParams, serde::Deserialize)]
#[into_params(style = Form, parameter_in = Query)]
pub struct MeParams {
#[param(style = Form, value_type = String, example = "name")]
pub name: String,
}
#[utoipa::path(
get, path = "/me",
operation_id = "me_get",
params(MeParams),
responses(
(status = 200, description = "Me", body = String),
(status = 500, description = "Internal Server Error", body = String)
)
)]
pub async fn me(query: axum::extract::Query<MeParams>) -> String {
query.name.clone()
}
oapi-specをYAMLで出力する
utoipa
の yaml
featureによって、YAML形式でoapi-specを出力することができる。
$ cargo add utoipa --features yaml
を実行するか、
utoipa = { version="5.3.1", features=["yaml"] }
と書き直し、
let (_, mut openapi) = utoipa_axum::router::OpenApiRouter::new()
.routes(utoipa_axum::routes!(f))
.split_for_parts();
println!("{}", openapi.to_yaml());
とすると、YAML形式でoapi-specを出力することができる。
oapi-spec閲覧および実行UIのホスティング
OpenAPI定義に基づいた仕様を使うことの利点の一つとして、Swagger UIなどを用いて、APIを簡単に動作できることが挙げられる。
utoipaを使う場合でもその利点を享受でき、さらに、公式が提供しているクレートがそれを手助けしてくれる。
提供されているクレートおよびインストールコマンドは以下のとおり。
- utoipa-rapidoc :
cargo add utoipa-rapidoc --features axum
- utoipa-redoc :
cargo add utoipa-redoc --features axum
- utoipa-scalar :
cargo add utoipa-scalar --features axum
- utoipa-swagger-ui :
cargo add utoipa-swagger-ui --features axum
すべてをホスティングする場合は、router
に以下のように .merge()
をする。(使いたいもののみを選んで、mergeしても良い)
let (mut router, openapi) = utoipa_axum::router::OpenApiRouter::new()
// ここにrouteを記述
.split_for_parts();
router = router
.merge(utoipa_scalar::Scalar::with_url(
"/docs/scalar",
openapi.clone(),
))
.merge(
utoipa_swagger_ui::SwaggerUi::new("/docs/swagger-ui")
.url("/docs/openapi.yml", openapi.clone()),
)
.merge(
utoipa_rapidoc::RapiDoc::with_openapi("/docs/rapidoc/openapi.yml", openapi.clone())
.path("/docs/rapidoc"),
)
.merge(utoipa_redoc::Redoc::with_url("/docs/redoc", openapi));
let listener = tokio::net::TcpListener::bind("localhost:8080").await?;
axum::serve(listener, router).await?;
おわりに
コードファーストのOpenAPI定義のなかでも、utoipaは特にコードとの距離が近く、一元的にroutesを定義できるため、実装したけど漏れているなどの人為ミスを減らしやすい印象。さらに、コードとAPI定義の距離も近いため、レビュワーにとって優しいかもしれない。
ただし、OpenAPI定義とhandlerの実装は手書きのため、この部分でのドリフトは発生してしまうかもしれない。
参考
- https://github.com/juhakuutoipa
- https://docs.rs/utoipa-axum/latest/utoipa-axum
- https://docs.rs/utoipa/latest/utoipaderive.IntoParams.html
- https://docs.rs/utoipa/latest/utoipaattr.path.html#rocket-extras-feature-support-for-rocket
- https://docs.rs/utoipa-rapidoc/latest/utoipa-rapidoc
- https://docs.rs/utoipa-redoc/latest/utoipa-redoc
- https://docs.rs/utoipa-rapidoc/latest/utoipa-rapidoc
- https://docs.rs/utoipa-scalar/latest/utoipa-scalar
- https://docs.rs/utoipa-swagger-ui/latest/utoipa-swagger-ui
- https://docs.rs/axum/latest/axum