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の
Resultenumはこれに最適であり、潜在的な失敗パスを明示的に管理できます。
クライアントの構築:原則と実践
私たちの目標は、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通信を活用することで、変更に強く、強力なコンパイル時保証を提供し、開発者の生産性を大幅に向上させるクライアントを構築できます。このアプローチにより、ネットワークレイヤーからアプリケーションロジックまで、データの整合性が維持されていることを知って、自信を持って外部サービスを統合することができます。