🦀

OpenAPIからRustのクライアントコードを自動生成したい!

2023/12/02に公開

はじめに

こんにちは〜!皆様いかがお過ごしでしょうか? no plan inc. CTOの @serinuntius です。
この記事はRust Advent Calendar 2023の2日目の記事です。

今回はOpenAPIの定義書からモックAPIを作成しRustのクライアントコードを自動生成する方法について書いていきます。

OpenAPIの基礎:API仕様の理解

今回はサンプルとしてOpenAPI SpecificationのペットショップのAPI定義書を使用します。

https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v3.0/petstore.yaml

どんなAPIがあるかを確認してみましょう。

/pets エンドポイント

GET メソッド

  • 概要: このメソッドは、登録されている全てのペットのリストを提供します。
  • パラメータ: limit(オプション)- 一度に返すペットの最大数を指定します(最大100)。
  • レスポンス: 成功すると、ペットの配列と次のページへのリンクを含む200レスポンスを返します。エラーが発生した場合は、エラーの詳細を含むデフォルトレスポンスが返されます。

POST メソッド

  • 概要: 新しいペットを作成します。
  • レスポンス: ペットの作成に成功すると、201レスポンスが返されます。エラーが発生した場合は、エラーの詳細を含むデフォルトレスポンスが返されます。

/pets/{petId} エンドポイント

GET メソッド

  • 概要: 特定のペットの詳細情報を取得します。
  • パラメータ: petId - 取得したいペットのIDを指定します。
  • レスポンス: ペットの情報を含む200レスポンスが返されます。ペットが見つからない場合やその他のエラーが発生した場合は、エラーの詳細を含むデフォルトレスポンスが返されます。

コンポーネントスキーマ

このAPI仕様では、PetPetsErrorの3つのスキーマが定義されています。Petスキーマは個々のペットのデータ構造を定義し、Petsスキーマはペットの配列を定義します。ErrorスキーマはAPIがエラーを返した際のレスポンスの形式を定義します。

環境構築

cargo init openapi-gen
cd openapi-gen
pnpm init
pnpm add -D @openapitools/openapi-generator-cli @stoplight/prism-cli

Prismを使用したモックサーバーの設定

wget https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml

好きなエディタで package.json を開き、以下のように修正します。

...
"scripts": {
    "prism": "prism mock ./petstore.yaml"
}
...

Prismを起動します。

pnpm prism

別窓でcurlを実行してみましょう。

$ curl localhost:4010/pets
[{"id":-9007199254740991,"name":"string","tag":"string"}]%

こんな感じで味気ないペットのデータが返ってきました。
流石にもうちょっといい感じのペットのデータを返して欲しいので、petstore.yaml を修正します。

components:
  schemas:
    Pet:
      type: object
      required:
        - id
        - name
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          x-faker: name.firstName # ここを追加
        tag:
          type: string
          x-faker: name.lastName # ここを追加

さらにHeaderに Prefer: dynamic=true を追加する必要があるようです。

いい感じになりました!

$ curl localhost:4010/pets -H "Prefer: dynamic=true"
[{"id":6434444831111209,"name":"Lorine","tag":"Abshire"},{"id":-6917212062297783,"name":"Gregoria","tag":"Kessler"},{"id":3198439147638889,"name":"Hope","tag":"Paucek"},{"id":-2720091641140911,"name":"Nikki","tag":"Renner"},{"id":-75873064202935,"name":"Dameon","tag":"Kling"},{"id":8821452548480297,"name":"Haylie","tag":"Gerhold"},{"id":1220346173630553,"name":"Dakota","tag":"Lang"},{"id":7943904249935705,"name":"Kole","tag":"Gusikowski"},{"id":6397775688514805,"name":"Sandrine","tag":"Moore"},{"id":-3192328224529287,"name":"Einar","tag":"Boehm"},{"id":-5269481972228867,"name":"Marc","tag":"Mohr"},{"id":-1196169157532435,"name":"Waldo","tag":"Prohaska"},{"id":382402961760605,"name":"Daphney","tag":"Daniel"},{"id":5737578741193737,"name":"Mireya","tag":"Yundt"},{"id":5552614277185193,"name":"Lucy","tag":"Muller"},{"id":5994926263362637,"name":"Elian","tag":"Wisozk"},{"id":-8685276069652907,"name":"Ottis","tag":"Hauck"},{"id":6998548240129661,"name":"Aron","tag":"Graham"},{"id":3447244124062585,"name":"Janessa","tag":"Lehner"},{"id":-126042515372323,"name":"Cleveland","tag":"Willms"},{"id":-1535414615852871,"name":"Larry","tag":"Sporer"},{"id":-3884626498029511,"name":"Aisha","tag":"Hickle"},{"id":-62864875187123,"name":"Erna","tag":"Breitenberg"},{"id":-4529704642166651,"name":"Antonina","tag":"Stokes"},{"id":-8709946353240331,"name":"Lyda","tag":"Kohler"},{"id":1576672702016889,"name":"Isabelle","tag":"Witting"},{"id":1491912517659265,"name":"Magdalen","tag":"Bernier"},{"id":5612660677712093,"name":"Alessandra","tag":"Reichel"},{"id":-4326028071744395,"name":"Oswaldo","tag":"Dietrich"},{"id":2089655298148077,"name":"Caden","tag":"Waelchi"},{"id":2376153574856501,"name":"Loma","tag":"Homenick"},{"id":4135479582232353,"name":"Kaley","tag":"Turcotte"},{"id":-8973834849086155,"name":"Ottis","tag":"Bins"},{"id":-4742893635705675,"name":"Crawford","tag":"Treutel"},{"id":2863951656386065,"name":"Ali","tag":"Bailey"},{"id":-8518308455602767,"name":"Carolyn","tag":"Ratke"},{"id":2734956108648521,"name":"Eliza","tag":"Homenick"},{"id":8188560924229177,"name":"Mariana","tag":"Zboncak"},{"id":5438627973044189,"name":"Henriette","tag":"Rowe"},{"id":-1332235468976667,"name":"Giovanna","tag":"Spinka"},{"id":-8712578627922479,"name":"Damaris","tag":"Gutkowski"},{"id":4164585918473653,"name":"Sigrid","tag":"Hauck"},{"id":-3328404122896507,"name":"Tavares","tag":"Schultz"},{"id":-7526045792752251,"name":"Imelda","tag":"Rogahn"},{"id":607631701405501,"name":"Eloy","tag":"Corwin"},{"id":-2128768902316175,"name":"Ernestine","tag":"Dickinson"},{"id":-2560493300577811,"name":"Eliseo","tag":"Bernier"},{"id":76696219314037,"name":"Gus","tag":"Upton"},{"id":-5983164546117839,"name":"Clement","tag":"Fadel"},{"id":8544660048041465,"name":"Chanel","tag":"Willms"},{"id":3373011494885005,"name":"Vita","tag":"West"},{"id":1073442677445557,"name":"Juliet","tag":"McGlynn"},{"id":-7119643117445863,"name":"Trey","tag":"Mills"},{"id":-8625502121434695,"name":"Moriah","tag":"Ferry"},{"id":-6882957502964655,"name":"Monserrat","tag":"Metz"},{"id":-8398370814159091,"name":"Aidan","tag":"Bednar"},{"id":2900840607953937,"name":"Khalil","tag":"Hodkiewicz"},{"id":2644347685027561,"name":"Kellen","tag":"Balistreri"},{"id":615002744108373,"name":"Geo","tag":"Cummings"},{"id":6952105999079573,"name":"Penelope","tag":"Morar"},{"id":-7526415498579467,"name":"Karson","tag":"Rau"},{"id":8851447348052761,"name":"Eda","tag":"Brakus"},{"id":6220485738480145,"name":"Theodora","tag":"Schinner"},{"id":5099140087109749,"name":"Alessandro","tag":"White"},{"id":4505282930417813,"name":"Duncan","tag":"Stanton"},{"id":-3297202129914999,"name":"Carlee","tag":"Herzog"},{"id":4282223473452493,"name":"Jayson","tag":"Cruickshank"},{"id":-1801193353458443,"name":"Lonny","tag":"Haag"},{"id":-7513293952462587,"name":"Julio","tag":"Kovacek"},{"id":8556578150453721,"name":"Kathryne","tag":"Will"},{"id":-6645154000691575,"name":"Lisa","tag":"Wuckert"},{"id":8425159408455953,"name":"Kay","tag":"Dooley"},{"id":1501742821196077,"name":"Ruben","tag":"Altenwerth"},{"id":8123459667936233,"name":"Benny","tag":"Kertzmann"},{"id":1830224405387545,"name":"Kip","tag":"Nader"},{"id":5848753421919885,"name":"Carole","tag":"Cummerata"},{"id":5384289099462913,"name":"Jamil","tag":"Homenick"},{"id":7233610415886773,"name":"Sarina","tag":"Lehner"},{"id":-1039109859733191,"name":"Ahmed","tag":"Kemmer"},{"id":5269928734464049,"name":"Jorge","tag":"Marks"},{"id":-3214501187679819,"name":"Kendrick","tag":"Bayer"},{"id":-2607592443213567,"name":"Milan","tag":"Stanton"},{"id":-3980098798137767,"name":"Lori","tag":"Gleichner"},{"id":5223564098533665,"name":"Cortney","tag":"Dickens"},{"id":-7676876783040339,"name":"Faustino","tag":"Mohr"},{"id":-3707555554005387,"name":"Cecilia","tag":"Schneider"},{"id":8701954899491505,"name":"Zaria","tag":"Grady"},{"id":-4345378678674739,"name":"Jamil","tag":"Von"},{"id":-3060525399491583,"name":"Wellington","tag":"Ritchie"},{"id":-1232292974478587,"name":"Ariel","tag":"Konopelski"},{"id":-3518674475737475,"name":"Ivy","tag":"Larson"},{"id":4505826437718261,"name":"Francis","tag":"Kub"},{"id":5569583946064801,"name":"Kade","tag":"Fisher"},{"id":-7959488046914443,"name":"Eveline","tag":"Hoeger"},{"id":-4362689616842059,"name":"Judy","tag":"Purdy"},{"id":-3106989878620275,"name":"Sylvester","tag":"Breitenberg"},{"id":-6167595592427183,"name":"Baby","tag":"Mann"},{"id":6521242684926261,"name":"Elliot","tag":"Altenwerth"},{"id":-2647845104741835,"name":"Lorena","tag":"Torp"},{"id":-8787252923796875,"name":"Reyes","tag":"Bernhard"}]%

POSTでの作成も試してみましょう。

$ curl localhost:4010/pets -X POST -v

*   Trying 127.0.0.1:4010...
* Connected to localhost (127.0.0.1) port 4010 (#0)
> POST /pets HTTP/1.1
> Host: localhost:4010
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 201 Created
< Access-Control-Allow-Origin: *
< Access-Control-Allow-Headers: *
< Access-Control-Allow-Credentials: true
< Access-Control-Expose-Headers: *
< Date: Fri, 01 Dec 2023 04:29:31 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Content-Length: 0
<
* Connection #0 to host localhost left intact

パラメーターなしでPOSTで作成ってなんなんだって感じですけど、一応201で成功しています。

GETで pets/{petId} を試してみましょう。

$ curl localhost:4010/pets/1 -H "Prefer: dynamic=true"
{"id":3331585163813937,"name":"Amie","tag":"Hagenes"}

いい感じですね!

Rustのクライアントコードを自動生成する

package.jsonを編集してgenerateコマンドを作成します。

    "generate": "openapi-generator-cli generate -g rust -i petstore.yaml -o petstore",

pnpm generate を実行すると、petstoreディレクトリが作成され、その中にRustのクライアントコードが自動生成されます。

Rustクライアントのテストコードを書いていく

まずは <PROJECT_ROOT>/Cargo.toml を編集します。

# 他の要素は全て消して大丈夫です
[workspaces]
members = [
    "petstore"
]
cd petstore

<PROJECT_ROOT>/petstore/Cargo.toml を編集します。

[package]
name = "petstore_api"

# .....中略
[dependencies]
tokio = { version = "1.34.0", features = ["full"] }
mkdir tests
touch tests/api.rs

tests/api.rs を編集します。

申し訳程度のテストコードですが、こんな感じで書けます。

extern crate petstore_api;

#[cfg(test)]
mod tests {
    use petstore_api::apis::configuration::Configuration;
    use petstore_api::apis::pets_api::{create_pets, list_pets, show_pet_by_id};

    fn test_config() -> Configuration {
        let client = reqwest::ClientBuilder::new();
        let mut headers = reqwest::header::HeaderMap::new();
        headers.insert(
            reqwest::header::CONTENT_TYPE,
            reqwest::header::HeaderValue::from_static("application/json"),
        );
        headers.insert(
            reqwest::header::HeaderName::from_static("prefer"),
            reqwest::header::HeaderValue::from_static("dynamic=true"),
        );

        let client = client
            .default_headers(headers)
            .build()
            .expect("failed to build reqwest client");

        Configuration {
            base_path: "http://localhost:4010".to_string(),
            client,
            ..Default::default()
        }
    }

    #[tokio::test]
    async fn test_create_pets() {
        let config = test_config();
        let result = create_pets(&config).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn test_list_pets() {
        let config = test_config();
        let result = list_pets(&config, None).await;

        assert!(result.is_ok());

        let pets = result.unwrap();
        assert_eq!(pets.len(), 100);

        let pet = &pets[0];
        assert!(!pet.name.is_empty());
    }

    #[tokio::test]
    async fn test_show_pet_by_id() {
        let config = test_config();
        let result = show_pet_by_id(&config, "1").await;

        assert!(result.is_ok());

        let pet = result.unwrap();
        assert!(!pet.name.is_empty());
    }
}

テストを実行してみましょう。

cargo test
   Compiling petstore_api v1.0.0 (/Users/serinuntius/src/github.com/serinuntius/openapi-gen/petstore)
    Finished test [unoptimized + debuginfo] target(s) in 1.65s
     Running unittests src/lib.rs (target/debug/deps/petstore_api-1d787ec708584cd2)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/api.rs (target/debug/deps/api-abfc6b295cc8b796)

running 3 tests
test tests::test_create_pets ... ok
test tests::test_show_pet_by_id ... ok
test tests::test_list_pets ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s

   Doc-tests petstore_api

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

無事にテストが通りました!

成果物はこちら

https://github.com/serinuntius/openapi-generate-rust-client

まとめ:OpenAPIとRustで効率的なAPI開発

  1. OpenAPIを使用したモックAPIの作成: OpenAPIの定義書を用いて、Prismを使ってモックサーバーを設定し、ペットショップAPIのモックバージョンを作成しました!

  2. Rustのクライアントコードの自動生成: OpenAPI Generator CLIを使用して、OpenAPIの定義書からRustのクライアントコードを自動生成しました!

  3. テストコードの作成と実行: RustでAPIクライアントのテストコードを作成し、モックサーバーに対する各APIメソッド(ペットの作成、一覧表示、IDによる検索)のテストを実行!

このプロセスを通じて、APIの開発とテストが効率的に行われ、RustにおけるAPIクライアントの実装が容易にできます!

no plan株式会社について

  • no plan株式会社は 「テクノロジーの力でZEROから未来を創造する、精鋭クリエイター集団」 です。
  • ブロックチェーン/AI技術をはじめとした、Webサイト開発、ネイティブアプリ開発、チーム育成、などWebサービス全般の開発から運用や教育、支援なども行っています。よくわからない、ふわふわしたノープラン状態でも大丈夫!ご一緒にプランを立てていきましょう!
  • no plan株式会社について
  • no plan株式会社 | web3実績
  • no plan株式会社 | ブログ一覧

エンジニアの採用も積極的に行なっていますので、興味がある方は是非ご連絡ください!

参考文献

https://zenn.dev/horitaka/articles/openapi-prism-mock-server
https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v3.0/petstore.yaml

Discussion