🥃

Rust | Axum で API のバージョンを管理する(API Versioning)

2024/01/22に公開

Axum API Versioning

https://api.example.com/v1/usersv1 のように、パスパラメータにバージョン情報を付与していきます。

サンプルコード

以下の記事で作成した Axum プロジェクトに API Versoning の処理を実装します。

https://zenn.dev/collabostyle/articles/76f1c87b743e97

cargo.toml

async-trait を追加しました。

cargo.toml
[dependencies]
axum = "0.7.4"
tokio = { version = "1.0", features = ["full"] }
async-trait = { version = "0.1.77", features = [] }
serde = { version = "1.0.195", features = ["derive"] }
serde_json = "1.0.96"

main.rs

今回のコード全体です。

以下のエンドポイントが実装されています。

  • /v1/hc
  • /v1/mountains
main.rs
use axum::{
    async_trait,
    extract::{FromRequestParts, Path},
    http::{request::Parts, StatusCode},
    response::{IntoResponse, Response},
    routing::get,
    Json, RequestPartsExt, Router,
};
use serde::Serialize;
use std::collections::HashMap;

#[tokio::main]
async fn main() {
    let hc_router = Router::new().route("/", get(health_check));
    let mountain_router = Router::new().route("/", get(find_sacred_mountains));

    let app = Router::new()
        .nest("/:version/hc", hc_router)
        .nest("/:version/mountains", mountain_router);

    // run it
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
        .await
        .unwrap();
    println!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app)
        .await
        .unwrap_or_else(|_| panic!("Server cannot launch."));
}

async fn health_check(version: ApiVersion) -> StatusCode {
    println!("received request with version {:?}", version);

    StatusCode::OK
}

async fn find_sacred_mountains(version: ApiVersion) -> (StatusCode, Json<JsonResponse>) {
    println!("received request with version {:?}", version);

    let response: JsonResponse = Default::default();
    (StatusCode::OK, Json(response))
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JsonResponse {
    mountains: Vec<EightThousander>,
    total: usize,
}

impl Default for JsonResponse {
    fn default() -> Self {
        let mountains = vec![
            EightThousander::new(1, "エベレスト".to_string(), 8848),
            EightThousander::new(2, "K2".to_string(), 8611),
            EightThousander::new(3, "カンチェンジェンガ".to_string(), 8586),
            EightThousander::new(4, "ローツェ".to_string(), 8516),
            EightThousander::new(5, "マカルー".to_string(), 8463),
            EightThousander::new(6, "チョ・オユー".to_string(), 8188),
            EightThousander::new(7, "ダウラギリ".to_string(), 8167),
            EightThousander::new(8, "マナスル".to_string(), 8163),
            EightThousander::new(9, "ナンガ・パルバット".to_string(), 8126),
            EightThousander::new(10, "アンナプルナ".to_string(), 8091),
            EightThousander::new(11, "ガッシャーブルⅠ峰".to_string(), 8080),
            EightThousander::new(12, "ブロード・ピーク".to_string(), 8051),
            EightThousander::new(13, "ガッシャーブルムⅡ峰".to_string(), 8035),
            EightThousander::new(14, "シシャパンマ".to_string(), 8027),
        ];
        let total = mountains.len();

        Self { mountains, total }
    }
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EightThousander {
    id: i32,
    name: String,
    elevation: i32,
}

impl EightThousander {
    fn new(id: i32, name: String, elevation: i32) -> Self {
        Self {
            id,
            name,
            elevation,
        }
    }
}

#[derive(Debug)]
enum ApiVersion {
    V1,
    V2,
    V3,
}

#[async_trait]
impl<S> FromRequestParts<S> for ApiVersion
where
    S: Send + Sync,
{
    type Rejection = Response;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let params: Path<HashMap<String, String>> =
            parts.extract().await.map_err(IntoResponse::into_response)?;

        let version = params
            .get("version")
            .ok_or_else(|| (StatusCode::NOT_FOUND, "version param missing").into_response())?;

        match version.as_str() {
            "v1" => Ok(ApiVersion::V1),
            "v2" => Ok(ApiVersion::V2),
            "v3" => Ok(ApiVersion::V3),
            _ => Err((StatusCode::NOT_FOUND, "unknown version").into_response()),
        }
    }
}

FromRequestParts

Versioning に関する処理を見ていきます。

まず、バージョンを enum で定義します。

#[derive(Debug)]
enum ApiVersion {
    V1,
    V2,
    V3,
}

axum::extract::FromRequestParts trait を実装することで、
パスパラメータで渡ってきた version を検証します。

#[async_trait]
impl<S> FromRequestParts<S> for ApiVersion
where
    S: Send + Sync,
{
    type Rejection = Response;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let params: Path<HashMap<String, String>> =
            parts.extract().await.map_err(IntoResponse::into_response)?;

        let version = params
            .get("version")
            .ok_or_else(|| (StatusCode::NOT_FOUND, "version param missing").into_response())?;

        match version.as_str() {
            "v1" => Ok(ApiVersion::V1),
            "v2" => Ok(ApiVersion::V2),
            "v3" => Ok(ApiVersion::V3),
            _ => Err((StatusCode::NOT_FOUND, "unknown version").into_response()),
        }
    }
}

ハンドラ関数の引数に、実装した ApiVersion を追加します。
引数を追加することで、バージョンの検証処理を通すことができます。

- async fn find_sacred_mountains() -> (StatusCode, Json<JsonResponse>) {
+ async fn find_sacred_mountains(version: ApiVersion) -> (StatusCode, Json<JsonResponse>) {
    println!("received request with version {:?}", version);

    let response: JsonResponse = Default::default();
    (StatusCode::OK, Json(response))
}

println!version を出力していますが、
特に処理をしない場合は _ に変えておくと、未使用の warning が出なくなります。

async fn find_sacred_mountains(_: ApiVersion) -> (StatusCode, Json<JsonResponse>) {
    println!("received request with version {:?}", version);

    let response: JsonResponse = Default::default();
    (StatusCode::OK, Json(response))
}

まとめ

Axum で API Versioning する方法を試してみました。

大事なのは... 公式の Examples を見ること!!

参考

コラボスタイル Developers

Discussion