この記事では、Smithy-rsを使ってRust APIサーバーを作る方法をレポジトリの例を使って解説します。
Smithy-rsは、AWSが開発したAPIモデリング言語「Smithy」を使って、Rustのクライアントとサーバーコードを自動生成するツールです。
Smithyとは
Smithyは、AWSが開発したプロトコルに依存しないAPIモデリング言語です。AWSでは10年以上前から内部で使用されており、数万のサービス構築に活用されてきました。2019年にオープンソース化され、現在は誰でも利用できます。
Smithyの主な特徴は以下の通りです:
- プロトコル非依存 - 様々なプログラミング言語、環境、トランスポート層、シリアライゼーション形式と連携できます
- リソース指向 - リソースとオペレーションを中心にAPIを定義することで、より自然なAPI設計が可能です
- 拡張性 - トレイト(trait)と呼ばれる仕組みで容易に拡張できます
- 検証機能 - モデルの検証ツールにより、高品質なAPIを設計できます
- コード生成 - 様々な言語のクライアント、サーバー、ドキュメントを生成できます
Smithyモデルは、シェイプ(Shape)とトレイト(Trait)から構成されます。シェイプには基本的な型(string, integerなど)、構造体(structure)、サービス(service)、オペレーション(operation)などがあります。トレイトはシェイプに追加情報を与えるメタデータです。
現在、AWSではAWS CDK、AWS SDK、AWS CLIなどの多くのツールやライブラリがSmithyをベースに開発されています。
必要な環境
まずは開発環境を準備します:
- JDK 17(コード生成のために必要)
- Rust開発環境(rustc 1.68.0以上推奨)
- Git
1. リポジトリをクローンする
まずはSmitty-rsのリポジトリをクローンします:
git clone https://github.com/smithy-lang/smithy-rs.git
cd smithy-rs
2. Gradleの初期化
リポジトリのルートディレクトリで以下のコマンドを実行し、Gradleを初期化します:
./gradlew
これによりGradleのラッパーが初期化され、必要な依存関係がダウンロードされます。初回は少し時間がかかります。
3. サンプルアプリケーションを探索する
リポジトリには「ポケモンサービス」というサンプルアプリケーションが含まれています。このアプリはSmithyモデルから生成されたコードを使用して構築されています。
cd examples
サンプルアプリの構成は以下のようになっています:
examples/
├── Cargo.toml # workspaceの設定
├── Makefile # ビルド用のMakeコマンド
├── pokemon-service/ # HTTP版の実装
├── pokemon-service-tls/ # HTTPS版の実装
├── pokemon-service-lambda/ # Lambda版の実装
├── pokemon-service-common/ # 共通コード
重要なのは、これらのサービス実装が全てSmithyモデルから生成されたコードを使用している点です。そのモデルは以下にあります:
cat ../codegen-core/common-test-models/pokemon.smithy
4. Smithyモデルを確認する
pokemon.smithy
ファイルを見てみましょう。これはAPIの全てを定義するファイルです:
$version: "2"
namespace com.aws.example
use aws.protocols#restJson1
// 他のインポート省略
/// The Pokémon Service allows you to retrieve information about Pokémon species.
@title("Pokémon Service")
@restJson1
service PokemonService {
version: "2024-03-18"
resources: [
PokemonSpecies
Storage
]
operations: [
GetServerStatistics
DoNothing
CapturePokemon
CheckHealth
StreamPokemonRadio
]
}
/// Retrieve information about your Pokédex.
@readonly
@http(uri: "/pokedex/{user}", method: "GET")
operation GetStorage {
input := @sensitive @documentation("A request to access Pokémon storage.") {
@required
@httpLabel
user: String
@required
@httpHeader("passcode")
passcode: String
}
output := @documentation("Contents of the Pokémon storage.") {
@required
collection: SpeciesCollection
}
errors: [
ResourceNotFoundException
StorageAccessNotAuthorized
ValidationException
]
}
// 他の操作定義...
このファイルがAPIの設計図となっています。エンドポイント、メソッド、入出力パラメータ、エラー型などが全て定義されています。
5. コード生成を実行する
ではいよいよ、Smithyモデルからコードを生成しましょう!examplesディレクトリで以下のコマンドを実行します:
make codegen
このコマンドは内部的に以下のGradleコマンドを実行しています:
../gradlew --project-dir .. -P modules='pokemon-service-server-sdk,pokemon-service-client' :codegen-client-test:assemble :codegen-server-test:assemble
このGradleコマンドを詳しく解説してみましょう:
-
../gradlew
- 親ディレクトリにあるGradleラッパースクリプトを実行します。Gradleラッパーは、特定のバージョンのGradleをダウンロードして実行するスクリプトで、チーム内でGradleのバージョンを統一できます。 -
--project-dir ..
- プロジェクトディレクトリとして親ディレクトリを指定します。これにより、コマンドを実行する場所に関係なく、プロジェクトのルートを正しく参照できます。 -
-P modules='pokemon-service-server-sdk,pokemon-service-client'
- Gradleプロジェクトのプロパティを設定します。ここではmodules
というプロパティに生成したいモジュール名を指定しています。このプロパティは、ビルドスクリプト内で条件分岐などに使われ、指定したモジュールのみをコード生成の対象とします。 -
:codegen-client-test:assemble
-codegen-client-test
サブプロジェクトのassemble
タスクを実行します。このタスクは、クライアントSDKのコード生成を行います。 -
:codegen-server-test:assemble
-codegen-server-test
サブプロジェクトのassemble
タスクを実行します。このタスクは、サーバーSDKのコード生成を行います。
つまり、このコマンド一つで、Smithyモデルからクライアントとサーバー両方のRustコードを同時に生成しています。コード生成プロセスはKotlinで書かれたプラグインによって実行され、指定されたSmithyモデル(ここでは pokemon.smithy
)を解析して、対応するRustコードを出力します。
生成されたコードは以下のディレクトリに出力されます:
- サーバーSDK:
codegen-server-test/build/smithyprojections/codegen-server-test/pokemon-service-server-sdk/rust-server-codegen/
- クライアントSDK:
codegen-client-test/build/smithyprojections/codegen-client-test/pokemon-service-client/rust-client-codegen/
6. 生成されたコードを確認する
生成されたサーバーSDKを見てみましょう:
ls pokemon-service-server-sdk/src/
生成されたファイルには以下のようなものがあります:
- APIの入出力型の定義
- エラー型の定義
- モデル型の定義
- サーバーフレームワークコード
特に重要なファイルは lib.rs
で、ここにはAPI全体の構造が定義されています。
7. サーバー実装を確認する
次に、生成されたSDKを使った実際のサーバー実装を見てみましょう:
cat pokemon-service/src/main.rs
ここで重要なのは、各APIエンドポイント(操作)に対応するハンドラー関数を登録している部分です:
let app = PokemonService::builder(config)
// 各操作に実装を登録
.get_pokemon_species(get_pokemon_species)
.get_storage(get_storage_with_local_approved)
.get_server_statistics(get_server_statistics)
.capture_pokemon(capture_pokemon)
.do_nothing(do_nothing_but_log_request_ids)
.check_health(check_health)
.stream_pokemon_radio(stream_pokemon_radio)
.build()
.expect("failed to build an instance of PokemonService");
各ハンドラー関数の実装は pokemon-service-common/src/lib.rs
にあります:
cat pokemon-service-common/src/lib.rs
例えば、get_pokemon_species
関数の実装は以下のようになっています:
pub async fn get_pokemon_species(
input: input::GetPokemonSpeciesInput,
state: Extension<Arc<State>>,
) -> Result<output::GetPokemonSpeciesOutput, error::GetPokemonSpeciesError> {
// カウンターをインクリメント
state
.0
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// ポケモン情報を取得
let pokemon = state.0.pokemons_translations.get(&input.name);
match pokemon.as_ref() {
Some(pokemon) => {
// 成功レスポンスを構築
let flavor_text_entries = vec![
model::FlavorText {
flavor_text: pokemon.en.to_owned(),
language: model::Language::English,
},
// 他の言語...
];
let output = output::GetPokemonSpeciesOutput {
name: String::from("pikachu"),
flavor_text_entries,
};
Ok(output)
}
None => {
// エラーレスポンスを返す
Err(error::GetPokemonSpeciesError::ResourceNotFoundException(
error::ResourceNotFoundException {
message: String::from("Requested Pokémon not available"),
},
))
}
}
}
8. アプリケーションをビルドして実行する
では、サンプルアプリをビルドして実行してみましょう:
make build
特定のサーバー実装を指定して実行してみます:
cargo run --bin pokemon-service
デフォルトでは、サーバーは http://localhost:13734
で起動します。
9. APIをテストする
別のターミナルを開いて、起動したサーバーにリクエストを送ってみましょう:
# ポケモン情報の取得
curl http://localhost:13734/pokemon-species/pikachu
# ストレージの取得(認証が必要)
curl -H "passcode: pikachu123" http://localhost:13734/pokedex/ash
まとめ
Smithy-rsを使ったAPIサーバーの作成プロセスを体験しました:
- Smithyモデルの定義: APIの全ての機能を宣言的に定義
- コード生成: Gradleを使ったコード生成
- サーバー実装: 生成されたSDKを使ったハンドラー関数の実装
- 実行とテスト: 実際のサーバーの起動とAPI呼び出し
Smithy-rsの主なメリットは以下の通りです:
- 型安全: 生成されたコードは完全に型安全
- ボイラープレートの削減: 手作業でのAPI実装に比べてコード量を大幅に削減
- 一貫性: クライアントとサーバーが同じモデルから生成されるため一貫性が高い
- 高性能: Rustのゼロコスト抽象化を活かした高性能な実装
より複雑なAPIを作成したり、AWS Lambdaへのデプロイなども可能です。