ブログに戻る

Rust で通貨為替レート API を扱う — 完全な統合チュートリアル

V
Vlado Grigirov
August 04, 2026
Rust Currency API Exchange Rates Tutorial reqwest Tokio Fintech

フィンテックのインフラ、価格計算エンジン、CLI ツール、あるいは高スループットのバックエンドを構築しているなら、遅かれ早かれリアルタイムの外国為替データが必要になります。Rust で通貨為替レート API を組み込むのは、この言語が提供する中でも最もすっきりしたネットワーク処理のひとつです。reqwesttokioserde を使えば、非同期 HTTP クライアント、自動的な JSON デシリアライズ、そしてレート処理コードが実行される前にその正しさをコンパイル時に保証する仕組みが手に入ります。本チュートリアルでは、最初の GET リクエストから、再利用可能なクライアント、型付きモデル、カスタムエラー処理、小数点まで安全な金額計算、そして無料枠に収まり続けるためのインメモリキャッシュを備えた、本番仕様の通貨コンバータまで、道のり全体をたどります。

読み終える頃には、Finexly API ドキュメント の上に構築された、小さくて再利用可能な Rust モジュールが手に入り、170 種類以上の通貨のあいだをリアルタイムレートで変換できるようになります。すでに私たちの Go 通貨 API チュートリアル をお読みなら、これはそれと同じアーキテクチャ——型付きクライアント、キャッシュ、そしてきれいなエラー伝播——を持つ Rust 版です。

なぜ Rust は通貨データに最適なのか

通貨データは I/O バウンドで、レイテンシに敏感であり、そして——金銭に触れるあらゆるシステムにおいて——正しさが決定的に重要です。Rust はこの 3 つすべてに並外れて適しています。

  • デフォルトで非同期。 reqwesthypertokio の上に構築されているため、並行してのレート取得は安価かつノンブロッキングです。OS スレッドを生成することなく、数十もの通貨ペアを並列にリクエストできます。
  • 型システムが誤りを早期に捕まえる。 serde から派生させた struct であれば、不正な形式のレスポンスや欠落したフィールドは、本番環境の 3 層奥に潜む謎の null ではなく、コンパイル時またはデシリアライズ時のエラーになります。
  • ガベージコレクタがなく、レイテンシが予測可能。 負荷のもとで外国為替レートを提示するサービスにとって、GC の停止がないことは重要です。
  • 小数点まで安全な金額。 rust_decimal crate を使えば固定小数点演算が手に入るので、€100.00 を €99.999999 に変えてしまうような丸め誤差のバグを出荷することは決してありません。

トレードオフとして、スクリプト言語よりも初期セットアップがわずかに険しくなりますが、その結果として、サービスにそのまま組み込んで信頼できる通貨クライアントが得られます。

何を構築するのか

次のことができる、再利用可能な FinexlyClient です。

  1. 通貨為替レート API から、ある基準通貨の最新レートを取得する。
  2. serde を使って JSON を型付きの Rust struct にデシリアライズする。
  3. どちらも基準通貨でない場合でも(クロスレート)、任意の 2 通貨のあいだを変換する。
  4. カスタムエラー型を用いて、エラー——ネットワーク障害、200 以外のレスポンス、レート制限——を処理する。
  5. TTL 付きでレスポンスをメモリにキャッシュし、無料プランに十分収まるようにする。

前提条件とプロジェクトのセットアップ

比較的新しい安定版の Rust ツールチェイン(rustup 経由でインストール)と、無料の Finexly API key が必要です。1 分もかからずに 無料で登録 できます——クレジットカードは不要で、無料枠は月あたり 1,000 リクエストです。

新しいプロジェクトを作成し、依存関係を追加します。

cargo new finexly-rates
cd finexly-rates
cargo add tokio --features full
cargo add reqwest --features json
cargo add serde --features derive
cargo add thiserror
cargo add rust_decimal rust_decimal_macros

これで Cargo.toml の依存関係は次のようになっているはずです。

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
rust_decimal = "1"
rust_decimal_macros = "1"

各 crate について簡単に補足します。reqwest は Rust における事実上の非同期 HTTP クライアントです。json 機能は serde_json を取り込み、.json() ヘルパーを有効にします。derive 付きの serde#[derive(Deserialize)] を使えるようにします。thiserror は使い勝手のよいカスタムエラー列挙型を作れるようにします。rust_decimal は金額のための固定小数点を提供します。

key はソース管理に決して入り込まないよう、環境変数に保存します。

export FINEXLY_API_KEY="your_api_key_here"

Finexly API のレスポンス構造

コードを書き始める前に、このエンドポイントが何を返すのかを見ておきましょう。最新レートエンドポイントへのリクエスト:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=YOUR_KEY"

は、小さく予測可能な JSON オブジェクトを返します。

{
  "success": true,
  "base": "USD",
  "timestamp": 1753660800,
  "rates": {
    "EUR": 0.9213,
    "GBP": 0.7847,
    "JPY": 161.42
  }
}

以下のコードを形づくるのは 2 つの点です。第一に、rates はフラットなマップで、ISO 4217 の通貨コードから浮動小数点数への対応です——これは Rust の HashMap<String, f64> にきれいに対応します。第二に、base はそれらのレートが何を基準としているかを教えてくれます。USD → EUR を変換するには rates["EUR"] を掛けます。2 つの非基準通貨のあいだを変換するには、基準通貨を経由します。片方で割り、もう片方を掛けるのです。

reqwest で最初のリクエストを送る

考えられる限り最もシンプルな非同期リクエストから始めましょう。src/main.rs を次のもので置き換えます。

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let api_key = std::env::var("FINEXLY_API_KEY")
        .expect("Set FINEXLY_API_KEY in your environment");

    let url = format!(
        "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey={api_key}"
    );

    let body = reqwest::get(&url).await?.text().await?;
    println!("{body}");

    Ok(())
}

cargo run で実行すると、生の JSON が表示されます。#[tokio::main] マクロが非同期ランタイムを用意し、reqwest::get がリクエストを実行し、そして各 .await? は、あらゆるエラーを伝播しつつ、ネットワークが応答するまでタスクを中断します。これは動きますが、reqwest::get は呼び出しのたびにまっさらなクライアントを作成します——単発なら問題ありませんが、実際のアプリケーションには不向きです。これはまもなく修正します。

Serde で JSON をモデル化する

文字列を表示しても役に立ちません。欲しいのは型付きのデータです。レスポンスを写し取る struct を定義し、パースは serde に任せましょう。

use serde::Deserialize;
use std::collections::HashMap;

#[derive(Debug, Deserialize)]
pub struct RatesResponse {
    pub success: bool,
    pub base: String,
    pub timestamp: i64,
    pub rates: HashMap<String, f64>,
}

次に .text().json() に差し替えます。これはボディを直接あなたの struct にデシリアライズします。

let data: RatesResponse = reqwest::get(&url).await?.json().await?;
println!("1 USD = {} EUR", data.rates["EUR"]);

json() メソッドはレスポンスボディを読み取り、内部で serde_json を走らせます。フィールドが欠落していたり型が違ったりすれば、静かな null ではなく、明快なデシリアライズエラーが得られます。ratesHashMap なので、返ってきた任意の通貨を ISO 4217 コードで引くことができます。

再利用可能なクライアントを構築する

最もよくある reqwest の間違いは、リクエストごとに新しい Client を作成することです。各クライアントは自身のコネクションプールを持つため、作り直すたびに keep-alive 接続や TLS ハンドシェイクを捨ててしまいます。Client をひとつ作成し、それを安価にクローンして(内部的には Arc です)、どこでも再利用してください。 それを URL と key を隠す小さな struct で包みます。

use reqwest::Client;

#[derive(Clone)]
pub struct FinexlyClient {
    http: Client,
    api_key: String,
    base_url: String,
}

impl FinexlyClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            http: Client::builder()
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .expect("failed to build HTTP client"),
            api_key: api_key.into(),
            base_url: "https://api.finexly.com/v1".to_string(),
        }
    }

    pub async fn latest(
        &self,
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/latest", self.base_url);
        let symbols_csv = symbols.join(",");

        let resp = self
            .http
            .get(&url)
            .query(&[
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?;

        let resp = resp.error_for_status()?;
        let data = resp.json::<RatesResponse>().await?;
        Ok(data)
    }
}

いくつか指摘しておく価値のある点があります。.query(&[...]) ビルダーが URL エンコードを代わりに処理してくれるので、クエリ文字列を手で連結する必要は一切ありません。ビルダー上の .timeout(...) は、ハングした接続がサービスを止めてしまうのを防ぎます。そして error_for_status() は、2xx 以外のあらゆる HTTP レスポンスを Err に変えます——ここからエラー処理の話につながります。

堅牢なエラー処理

本番コードは次の問いに答えなければなりません。ネットワークがダウンしたとき、key が無効なとき(403)、あるいはレート制限に達したとき(429)に何が起きるのか、と。thiserror を使ったカスタムエラー列挙型で、それらのケースをモデル化します。

use thiserror::Error;

#[derive(Debug, Error)]
pub enum FinexlyError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    #[error("API returned an unsuccessful response")]
    Unsuccessful,

    #[error("currency '{0}' was not present in the response")]
    MissingCurrency(String),
}

#[from] reqwest::Error の行は、reqwest のあらゆる失敗——DNS、TLS、タイムアウト、あるいは error_for_status() が捕捉した 2xx 以外のステータス——が、? 演算子を通じて自動的に FinexlyError::Http へ変換されることを意味します。これこそ、latest() の中のすべての .await? がそのまま機能する理由です。

あつらえの挙動が欲しいときには、ステータスコードを明示的に調べることもできます——たとえば 429 のときにバックオフするなど。

if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
    // sleep and retry, or fall back to a cached rate
}

リトライ、バックオフ、キャッシュ戦略のより踏み込んだ扱いについては、私たちの 通貨 API のキャッシュとエラー処理のベストプラクティス ガイドをご覧ください。

任意の 2 通貨のあいだを変換する

単一のレートを掛けるやり方が通用するのは、変換元の通貨が基準通貨であるときだけです。実際のアプリケーションでは、どちらの側も基準通貨でない任意のペア——EUR → JPYGBP → CAD——が必要になります。コツは、基準通貨を経由してルーティングすることです。金額を変換元のレートで割って基準通貨換算を求め、それから目標のレートを掛けます。

impl FinexlyClient {
    /// Convert `amount` from `from` to `to`, using `from` as the request base.
    pub async fn convert(
        &self,
        amount: f64,
        from: &str,
        to: &str,
    ) -> Result<f64, FinexlyError> {
        let data = self.latest(from, &[to]).await?;

        if !data.success {
            return Err(FinexlyError::Unsuccessful);
        }

        let rate = data
            .rates
            .get(to)
            .ok_or_else(|| FinexlyError::MissingCurrency(to.to_string()))?;

        Ok(amount * rate)
    }
}

ここでは単に from を基準通貨としてレートをリクエストしているので、返ってくるレートはすでに直接の from → to 変換です。ひとつの基準通貨(たとえば USD)をキャッシュしていてクロスレートが必要なら、代わりにクライアント側で計算しましょう:amount / rates[from] * rates[to]。端から端まで試してみます。

#[tokio::main]
async fn main() -> Result<(), FinexlyError> {
    let client = FinexlyClient::new(
        std::env::var("FINEXLY_API_KEY").expect("set FINEXLY_API_KEY"),
    );

    let usd = client.convert(250.0, "EUR", "USD").await?;
    println!("250 EUR = {usd:.2} USD");

    Ok(())
}

金額には rust_decimal を使う

f64 は表示用の変換には問題ありませんが、残高の保存、請求、あるいはセント単位まで帳尻を合わせなければならない何かにとっては、誤った型です。二進浮動小数点は 0.1 を正確に表現できず、それらの誤差は積み重なっていきます。金額には固定小数点を使いましょう。

use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal_macros::dec;

pub fn convert_decimal(amount: Decimal, rate: f64) -> Decimal {
    let rate = Decimal::from_f64(rate).unwrap_or_default();
    (amount * rate).round_dp(2)
}

// usage
let total = convert_decimal(dec!(1999.99), 0.9213);
println!("{total}"); // rounded to 2 decimal places

ひとつ重要な注意点があります。すべての通貨が 2 つの補助単位を持つわけではありません。JPY と KRW は小数点以下が 0 桁です。BHD や KWD のように 3 桁の通貨もあります。2 をハードコードするのではなく、(ISO 4217 の補助単位テーブルに基づいて)通貨ごとに正しい小数桁数に丸めてください。私たちの 通貨コンバータAPI ドキュメント は、いずれもこれらの通貨ごとのルールを反映しています。

無料枠に収まるためのキャッシュ

為替レートは秒ごとに意味のある動きをするわけではありません。ほとんどの表示、チェックアウト、レポートのユースケースでは、1 時間に一度更新すれば十分です——そしてキャッシュは、どれだけトラフィックが来ようとも、あなたを余裕をもって無料プランの内側に留めてくれます。Mutex で守られたシンプルなインメモリ TTL キャッシュを追加します。

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

struct CacheEntry {
    data: RatesResponse,
    fetched_at: Instant,
}

pub struct CachedClient {
    inner: FinexlyClient,
    ttl: Duration,
    cache: Mutex<HashMap<String, CacheEntry>>,
}

impl CachedClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            inner: FinexlyClient::new(api_key),
            ttl: Duration::from_secs(3600), // 1 hour
            cache: Mutex::new(HashMap::new()),
        }
    }

    pub async fn latest(&self, base: &str) -> Result<RatesResponse, FinexlyError> {
        {
            let cache = self.cache.lock().unwrap();
            if let Some(entry) = cache.get(base) {
                if entry.fetched_at.elapsed() < self.ttl {
                    return Ok(entry.data.clone());
                }
            }
        }

        let fresh = self.inner.latest(base, &[]).await?;
        let mut cache = self.cache.lock().unwrap();
        cache.insert(
            base.to_string(),
            CacheEntry { data: fresh.clone(), fetched_at: Instant::now() },
        );
        Ok(fresh)
    }
}

これがコンパイルできるようにするには、RatesResponse#[derive(Clone)] を付けておくとよいでしょう。1 時間の TTL があれば、ユーザーがどれだけ多くの変換を実行しようとも、ひとつの基準通貨が費やすのは 1 日あたり多くても 24 リクエストです——1,000 リクエストの無料枠に対しては丸め誤差のようなものです。もしそれを上回るようになったら、料金プラン が線形にスケールします。stale-while-revalidate やインスタンス横断の共有キャッシュといったパターンについては、私たちの キャッシュとエラー処理のガイド をご覧ください。

過去のレートを取得する

日付をさかのぼった請求書、レポート、チャートには過去データが必要です。Finexly は過去データのエンドポイントを公開しており、/v1/historical を叩くメソッドを追加すれば、同じクライアントから呼び出せます。

impl FinexlyClient {
    pub async fn historical(
        &self,
        date: &str, // "YYYY-MM-DD"
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/historical", self.base_url);
        let symbols_csv = symbols.join(",");

        let data = self
            .http
            .get(&url)
            .query(&[
                ("date", date),
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?
            .error_for_status()?
            .json::<RatesResponse>()
            .await?;

        Ok(data)
    }
}

レスポンスの構造は最新レートエンドポイントと同一なので、既存のパースおよび変換のコードはすべてそのまま機能します。完全なパラメータ一覧と、日付の範囲を扱う /v1/timeseries エンドポイントについては API ドキュメント をご覧ください。

避けるべきよくある落とし穴

  • リクエストごとに Client を作成する。 ひとつだけ構築し、それをクローンして(安価です)、再利用することで、コネクションプールを温かく保ちます。
  • 保存する残高に f64 を使う。 表示には問題ありませんが、台帳には不向きです——帳尻を合わせなければならないものには rust_decimal を使いましょう。
  • 小数点以下 2 桁をハードコードする。 JPY と KRW は 0 桁、いくつかの通貨は 3 桁です。ISO 4217 の補助単位を使い、通貨ごとに丸めましょう。
  • ステータスチェックを飛ばす。 403429 のボディをレートデータとしてデシリアライズすると、紛らわしいバグを生みます。error_for_status() は一行でこれを処理します。
  • 変換のたびに取得する。 TTL でキャッシュし、ユーザー入力をデバウンスして、割り当てを使い果たさないようにしましょう。
  • API key をコミットする。 環境変数やシークレットマネージャから読み取り、main.rs の中の文字列リテラルには決してしないでください。

まだプロバイダを比較している段階なら、私たちの 通貨 API の比較 が精度、通貨のカバレッジ、無料枠の制限を詳しく分解しており、無料通貨 API ガイド がゼロコストの選択肢をより深く扱っています。

よくある質問

Rust で通貨 API を呼び出すのに最適な HTTP クライアントは何ですか?

reqwest は非同期 Rust の標準的な選択肢です。hypertokio の上に構築され、コネクションプール、TLS、JSON を最初から処理し、型付きレスポンスのために serde と自然に組み合わさります。json 機能を有効にし、アプリケーション全体で単一の Client インスタンスを再利用しましょう。

Rust で JSON の為替レートレスポンスをどうパースしますか?

レスポンスを写し取る struct を定義し、それに serde::Deserialize を派生させます。rates フィールドは通貨コードからレートへのフラットなマップなので HashMap<String, f64> としてモデル化し、それからレスポンスに対して .json::<RatesResponse>() を呼べば、reqwest が代わりにデシリアライズしてくれます。

Rust での通貨変換には f64 と Decimal のどちらを使うべきですか?

手早い表示用の変換には f64 を使い、保存したり帳尻を合わせたりするもの——残高、請求書、台帳——には rust_decimal::Decimal を使いましょう。二進浮動小数点は十進の小数を正確に表現できないため、丸め誤差が積み重なります。rust_decimal は固定小数点の精度を与えてくれます。

Rust のサービスではどのくらいの頻度で為替レートを取得すべきですか?

ほとんどのチェックアウト、表示、レポートのユースケースでは、1 時間に一度で十分です——レートは 1 時間のうちに問題となるほど動きません。1 時間の TTL でレスポンスをキャッシュすれば、基準通貨あたり 1 日およそ 24 回の呼び出しに抑えられ、余裕をもって無料プランの内側に収まります。

Rust で過去の為替レートを取得できますか?

はい。同じクライアント上で /v1/historical?date=YYYY-MM-DD エンドポイントを呼び出すメソッドを追加します。JSON の構造は最新レートエンドポイントと一致するので、既存の struct と変換ロジックは変更なしで機能します。これは日付をさかのぼった請求書、レポート、チャートに役立ちます。

構築を始めよう

リアルタイムの為替レートを Rust プロジェクトに統合する準備はできましたか?無料の Finexly API key を取得 しましょう——クレジットカードは不要です。月あたり 1,000 回の無料リクエストから始め、成長に合わせてアップグレードできます。完全なエンドポイントのリファレンスは API ドキュメント を確認し、多通貨機能をスケールさせる準備ができたら 料金プラン を検討してみてください。

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →

この記事を共有する