reqwestとserdeで堅牢で型安全なRust APIクライアントを構築する
Wenhao Wang
Dev Intern · Leapcell

はじめに
今日の相互接続されたソフトウェア環境では、アプリケーションはAPIを通じて外部サービスと頻繁にやり取りします。HTTPリクエストの送信とレスポンスの解析は基本的なタスクですが、それを信頼性高く、データの整合性に自信を持って行うことは、驚くほど困難な場合があります。手動での解析は、定型的なコード、データ構造に関する誤った仮定による潜在的な実行時エラー、デバッグの困難さにつながることがよくあります。これは、型安全性が設計思想の根幹であるRustのような言語では特に顕著です。
この記事では、2つの著名なクレートの強みを活用して、Rustで堅牢な型安全なAPIクライアントを構築する方法を掘り下げます。reqwest
はHTTP通信の処理に、serde
は効率的で信頼性の高いデータシリアライゼーションとデシリアライゼーションに使用します。これらの強力なツールを組み合わせることで、パフォーマンスの高いクライアントを作成できるだけでなく、外部APIとの間で交換されるデータに関するコンパイル時保証を提供し、実行時エラーの可能性を大幅に減らし、開発者の生産性を向上させることができます。
コアコンセプト解説
実装に入る前に、APIクライアントの基盤となる中心的な概念を簡単に説明しましょう。
- HTTPクライアント: コアとして、APIクライアントはリモートサーバーにHTTPリクエスト(GET、POST、PUT、DELETEなど)を送信し、HTTPレスポンスを受信します。
reqwest
はRust向けの、人気があり、人間工学的で、非同期優先のHTTPクライアントです。低レベルのネットワーク詳細を処理し、アプリケーションロジックに集中できるようにします。 - シリアライゼーション: これは、メモリ内のデータ構造(Rustの
struct
など)を、ネットワーク経由での送信またはストレージ(JSON、YAML、XMLなど)に適した形式に変換するプロセスです。APIにデータを送信する際には、RustのデータをAPIが期待する形式にシリアライズします。 - デシリアライゼーション: シリアライゼーションの逆で、これは送信/ストレージ形式のデータをメモリ内のデータ構造に変換するプロセスです。APIからレスポンスを受信すると、そのコンテンツをRustの型にデシリアライズします。
serde
: これはRustの事実上のシリアライゼーション/デシリアライゼーションフレームワークです。開発者が手動で解析ロジックを書くことなく、カスタムデータ型を簡単にシリアライズおよびデシリアライズできるようにする、deriveマクロシステムを提供します。エコシステムのクレート(例:serde_json
、serde_yaml
)を通じて多数の形式をサポートします。- 型安全性: Rustでは、型安全性とは、コンパイラが変数が宣言された型に従って使用されていることを検証することを意味します。これにより、数値に対して文字列操作を実行しようとするような、クラスのエラー全体を防ぐことができます。APIとのやり取りにおいて、型安全性は、送信するデータと受信するデータが期待に沿っていることを保証し、実行時ではなくコンパイル時に不一致を検出します。
- エラーハンドリング: 堅牢なAPIクライアントは、ネットワークの問題、無効なサーバーレスポンス、またはAPI固有のエラーメッセージから生じるエラーを、優雅に処理する必要があります。Rustの
Result
enumはこれに最適であり、潜在的な失敗パスを明示的に管理できます。
クライアントの構築:原則と実践
私たちの目標は、APIの入力と出力構造をRustの型として明確に定義するAPIクライアントを構築することです。これにより、強力なコンパイル時保証が得られ、コードの理解と保守がはるかに容易になります。
簡単な「Todo」APIのクライアントを構築していると想像してみましょう。
1. プロジェクトセットアップ
まず、新しいRustプロジェクトを作成し、Cargo.toml
に必要な依存関係を追加します。
[package] name = "todo_api_client" version = "0.1.0" edition = "2021" [dependencies] reqwest = { version = "0.12", features = ["json"] } # reqwestにJSON機能を追加 serde = { version = "1.0", features = ["derive"] } # serdeにderive機能を追加 serde_json = "1.0" tokio = { version = "1.0", features = ["full"] } # 非同期ランタイム用 thiserror = "1.0" # 堅牢なエラーハンドリング用
json
機能を備えたreqwest
により、JSONの送受信が容易になります。derive
機能を備えたserde
は、強力な#[derive(Serialize, Deserialize)]
マクロを有効にします。serde_json
はJSONの特定のserde
実装です。tokio
はreqwest
に必要な非同期ランタイムを提供します。thiserror
は、boilerplateを少なくしてカスタムエラー型を作成するのに役立ちます。
2. データ構造の定義
Todo APIのJSON構造をミラーリングするRust struct
を定義します。これらの構造体はserde
を使用してSerialize
およびDeserialize
されます。
use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct Todo { pub id: Option<u32>, // 作成時にIDが存在しない場合があるため `Option` pub title: String, pub completed: bool, pub user_id: u32, } #[derive(Debug, Serialize)] pub struct CreateTodo { pub title: String, pub completed: bool, pub user_id: u32, } #[derive(Debug, Deserialize)] pub struct ApiError { pub message: String, pub code: u16, }
Todo
: APIから取得したtodoアイテムを表します。id
は、APIが作成時にIDを割り当てることが多いため、Option<u32>
です。CreateTodo
: 新しいtodoを作成するために必要なデータ表します。id
フィールドがないことに注意してください。ApiError
: APIからのエラーレスポンスをキャプチャするための汎用構造体です。
3. カスタムエラーハンドリング
クライアント固有のエラータイプを定義して、さまざまな潜在的な障害をカプセル化することは非常に重要です。
use thiserror::Error; #[derive(Debug, Error)] pub enum TodoClientError { #[error("HTTPリクエストが失敗しました: {0}")] Reqwest(#[from] reqwest::Error), #[error("JSONレスポンスの解析に失敗しました: {0}")] Serde(#[from] serde_json::Error), #[error("APIがエラーを返しました: {message} (コード: {code})")] Api { message: String, code: u16, }, #[error("無効なベースURLです")] InvalidBaseUrl, }
thiserror
を使用して、エラーenumのDisplay
およびFrom
トレイトを自動的に実装します。#[from]
により、reqwest::Error
およびserde_json::Error
からの変換が自動化され、エラー伝搬が簡素化されます。Api
バリアントは、構造化されたAPIエラーレスポンス用で、コンテキストを含めることができます。
4. クライアント構造の構築
次に、TodoClient
構造体とそのメソッドを作成しましょう。
use reqwest::Client; use std::fmt::Display; pub struct TodoClient { base_url: String, http_client: Client, } impl TodoClient { pub fn new(base_url: &str) -> Result<Self, TodoClientError> { let parsed_url = url::Url::parse(base_url) .map_err(|_| TodoClientError::InvalidBaseUrl)?; Ok(Self { base_url: parsed_url.to_string(), http_client: Client::new(), }) } // 完全なURLを構築するためのヘルパー fn get_url<P: Display>(&self, path: P) -> String { format!( "{}/{}", self.base_url.trim_end_matches('/'), path) } pub async fn get_all_todos(&self) -> Result<Vec<Todo>, TodoClientError> { let url = self.get_url("todos"); let response = self.http_client.get(&url).send().await?; if !response.status().is_success() { let api_error: ApiError = response.json().await?; return Err(TodoClientError::Api { message: api_error.message, code: api_error.code, }); } let todos: Vec<Todo> = response.json().await?; Ok(todos) } pub async fn get_todo_by_id(&self, id: u32) -> Result<Todo, TodoClientError> { let url = self.get_url(format!("todos/{}", id)); let response = self.http_client.get(&url).send().await?; if !response.status().is_success() { let api_error: ApiError = response.json().await?; return Err(TodoClientError::Api { message: api_error.message, code: api_error.code, }); } let todo: Todo = response.json().await?; Ok(todo) } pub async fn create_todo(&self, new_todo: &CreateTodo) -> Result<Todo, TodoClientError> { let url = self.get_url("todos"); let response = self .http_client .post(&url) .json(new_todo) // `reqwest` は `serde_json` で自動的にシリアライズします .send() .await?; if !response.status().is_success() { let api_error: ApiError = response.json().await?; return Err(TodoClientError::Api { message: api_error.message, code: api_error.code, }); } let created_todo: Todo = response.json().await?; Ok(created_todo) } // 更新、削除などのメソッドを追加できます。 }
解説:
-
TodoClient::new
: ベースURLを受け取り、reqwest::Client
を初期化するコンストラクタです。基本的なURL検証を行います。 -
get_url
: 相対パスから完全なAPIエンドポイントを構築するためのプライベートヘルパーメソッドです。 -
get_all_todos
/get_todo_by_id
:- 完全なURLを構築します。
- GETリクエストを行うために
self.http_client.get(&url).send().await?
を使用します。?
演算子はreqwest::Error
を伝搬させます。 - エラーハンドリング:
response.status().is_success()
をチェックします。成功しない場合、レスポンスボディをApiError
構造体にデシリアライズしようとし、TodoClientError::Api
を返します。これは構造化されたAPIエラーを処理するための堅牢な方法です。 - 成功した場合、
response.json().await?
はJSONレスポンスを直接Vec<Todo>
またはTodo
構造体にデシリアライズします。?
演算子はserde_json::Error
を処理します。
-
create_todo
:- POSTリクエストに
post(&url)
を使用します。 json(new_todo)
は強力なreqwest
メソッドで、Serialize
可能な型(この場合はCreateTodo
)を受け取り、serde_json
を使用してJSONにシリアライズし、Content-Type: application/json
ヘッダーを設定します。- エラーハンドリングと成功レスポンスのデシリアライゼーションは、GETリクエストと同様です。
- POSTリクエストに
5. アプリケーション例
クライアントを使ってみましょう!
#[tokio::main] async fn main() -> Result<(), TodoClientError> { // テスト用の一般的な公開ダミーAPI。 // あなたの実際のAPIベースURLに置き換えてください。 let base_url = "https://jsonplaceholder.typicode.com"; let client = TodoClient::new(base_url)?; println!("---"); println!("---"); println!("---"); println!("10件のTodoを取得中 ---"); match client.get_all_todos().await { Ok(todos) => { for todo in todos.iter().take(5) { // 短縮のために最初の5件を表示 println!("{:?}", todo); } } Err(e) => eprintln!("Todoの取得中にエラーが発生しました: {}", e), } println!("\n---"); println!("ID 1のTodoを取得中 ---"); match client.get_todo_by_id(1).await { Ok(todo) => println!("{:?}", todo), Err(e) => eprintln!("IDによるTodoの取得中にエラーが発生しました: {}", e), } println!("\n---"); println!("新しいTodoを作成中 ---"); let new_todo = CreateTodo { title: "Rust APIクライアントを学ぶ".to_string(), completed: false, user_id: 1, }; match client.create_todo(&new_todo).await { Ok(created_todo) => println!("作成されたTodo: {:?}", created_todo), Err(e) => eprintln!("Todoの作成中にエラーが発生しました: {}", e), } // 予期されるAPIエラーの処理例(例:存在しないID) println!("\n---"); println!("存在しないTodo(ID 99999)を取得中 ---"); match client.get_todo_by_id(99999).await { Ok(todo) => println!("存在しないTodoが見つかりました: {:?}", todo), // 発生しないはず Err(e) => { eprintln!("存在しないTodoの取得中に予期されるエラー: {}", e); if let TodoClientError::Api { message, code } = e { println!("APIエラー詳細: Message='{}', Code={}", message, code); } } } Ok(()) }
このmain
関数は、以下を行う方法を示しています。
TodoClient
をインスタンス化します。- 非同期メソッドを呼び出します。
match
を使用して、成功したOk
結果とさまざまなErr
バリアントの両方を処理します。- 構造化されたAPIエラーレスポンスを検査するために、特に
TodoClientError::Api
を処理します。
このアプローチの主な利点
- 型安全性: すべてのAPIリクエストとレスポンスは強く型付けされています。APIが変更された場合、Rustコンパイラは、
struct
定義と実際のJSON構造との間の不一致をコンパイル時にフラグ付けし、微妙な実行時バグを防ぎます。 - 堅牢なエラーハンドリング: 明示的なエラータイプと
Result
は、ネットワークの問題から不正なJSONやAPI固有のエラーまで、すべての潜在的な障害パスが考慮されることを保証します。 - 可読性と保守性: コードは、APIとの予想されるデータ形状とやり取りを明確に定義するため、他の人が理解しやすく、将来の変更が容易になります。
- boilerplateの削減:
serde
のderiveマクロとreqwest
の.json()
ヘルパーは、手動で解析およびシリアライズするコードの量を大幅に削減します。 - 非同期設計
の async/await
を活用して非同期I/Oを実現し、応答性の高いアプリケーションに不可欠です。
結論
Rustで堅牢で型安全なAPIクライアントを構築することは可能であり、非常に有益です。serde
でデータ構造を綿密に定義し、reqwest
で強力で人間工学的なHTTP通信を活用することで、変更に強く、強力なコンパイル時保証を提供し、開発者の生産性を大幅に向上させるクライアントを構築できます。このアプローチにより、ネットワークレイヤーからアプリケーションロジックまで、データの整合性が維持されていることを知って、自信を持って外部サービスを統合することができます。