iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Rust

Rust 實戰專案集:30 個漸進式專案從工具到服務系列 第 10

天氣查詢 API 客戶端 - 整合第三方天氣服務

  • 分享至 

  • xImage
  •  

前言

在現代軟體開發中,整合第三方 API 服務是家常便飯。
今天我們要建立一個天氣查詢 API 客戶端,學習如何使用 Rust 呼叫外部 REST API,處理 JSON 回應,以及優雅地處理可能發生的錯誤。

專案目標

-> (工程面)

  • HTTP 客戶端請求
  • JSON 序列化/反序列化
  • 錯誤處理最佳實踐
  • 環境變數管理
  • 命令列參數解析

-> 目標

  • 根據城市名稱查詢當前天氣
  • 顯示溫度、濕度、天氣描述等資訊
  • 支援不同的溫度單位(攝氏/華氏)
  • 優雅地處理網路錯誤和無效輸入

開始專案

cargo new weather_client
cd weather_client

依賴

[package]
name = "weather_client"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
clap = { version = "4.0", features = ["derive"] }
dotenv = "0.15"
anyhow = "1.0"

簡單依賴介紹

  • reqwest - HTTP 客戶端
  • serde - JSON 序列化/反序列化
  • tokio - 異步運行時
  • clap - 命令列參數解析
  • dotenv - 環境變數載入

資料結構

use serde::Deserialize;

#[derive(Deserialize, Debug)]
pub struct WeatherResponse {
    pub main: MainWeather,
    pub weather: Vec<Weather>,
    pub name: String,
    pub sys: Sys,
}

#[derive(Deserialize, Debug)]
pub struct MainWeather {
    pub temp: f64,
    pub feels_like: f64,
    pub temp_min: f64,
    pub temp_max: f64,
    pub pressure: u32,
    pub humidity: u32,
}

#[derive(Deserialize, Debug)]
pub struct Weather {
    pub main: String,
    pub description: String,
}

#[derive(Deserialize, Debug)]
pub struct Sys {
    pub country: String,
}

建立 weather client

use anyhow::{Context, Result};
use reqwest::Client;

pub struct WeatherClient {
    client: Client,
    api_key: String,
    base_url: String,
}

impl WeatherClient {
    pub fn new(api_key: String) -> Self {
        Self {
            client: Client::new(),
            api_key,
            base_url: "https://api.openweathermap.org/data/2.5".to_string(),
        }
    }

    pub async fn get_weather(&self, city: &str, units: &str) -> Result<WeatherResponse> {
        let url = format!(
            "{}/weather?q={}&appid={}&units={}",
            self.base_url, city, self.api_key, units
        );

        let response = self
            .client
            .get(&url)
            .send()
            .await
            .context("Failed to send request")?;

        if !response.status().is_success() {
            let status = response.status();
            let error_text = response.text().await.unwrap_or_default();
            return Err(anyhow::anyhow!(
                "API request failed with status {}: {}",
                status,
                error_text
            ));
        }

        response
            .json::<WeatherResponse>()
            .await
            .context("Failed to parse JSON response")
    }
}

完整的 weather_client.rs

use anyhow::{Context, Result};
use reqwest::Client;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
pub struct WeatherResponse {
    pub main: MainWeather,
    pub weather: Vec<Weather>,
    pub name: String,
    pub sys: Sys,
}

#[derive(Deserialize, Debug)]
pub struct MainWeather {
    pub temp: f64,
    pub feels_like: f64,
    pub temp_min: f64,
    pub temp_max: f64,
    pub pressure: u32,
    pub humidity: u32,
}

#[derive(Deserialize, Debug)]
pub struct Weather {
    pub main: String,
    pub description: String,
}

#[derive(Deserialize, Debug)]
pub struct Sys {
    pub country: String,
}

pub struct WeatherClient {
    client: Client,
    api_key: String,
    base_url: String,
}

impl WeatherClient {
    pub fn new(api_key: String) -> Self {
        Self {
            client: Client::new(),
            api_key,
            base_url: "https://api.openweathermap.org/data/2.5".to_string(),
        }
    }

    pub async fn get_weather(&self, city: &str, units: &str) -> Result<WeatherResponse> {
        let url = format!(
            "{}/weather?q={}&appid={}&units={}",
            self.base_url, city, self.api_key, units
        );

        let response = self
            .client
            .get(&url)
            .send()
            .await
            .context("Failed to send request")?;

        if !response.status().is_success() {
            let status = response.status();
            let error_text = response.text().await.unwrap_or_default();
            return Err(anyhow::anyhow!(
                "API request failed with status {}: {}",
                status,
                error_text
            ));
        }

        response
            .json::<WeatherResponse>()
            .await
            .context("Failed to parse JSON response")
    }
}

一樣我們用 clap 建立 cli

src/cli.rs

use clap::Parser;

#[derive(Parser)]
#[command(name = "weather")]
#[command(about = "A simple weather API client")]
pub struct Cli {
    /// The city to get weather for
    #[arg(short, long)]
    pub city: String,

    /// Temperature unit (metric, imperial, kelvin)
    #[arg(short, long, default_value = "metric")]
    pub units: String,

    /// Display verbose output
    #[arg(short, long)]
    pub verbose: bool,
}

main.rs

mod weather_client;
mod cli;

use anyhow::Result;
use clap::Parser;
use cli::Cli;
use weather_client::{WeatherClient, WeatherResponse};

#[tokio::main]
async fn main() -> Result<()> {
    // 載入環境變數
    dotenv::dotenv().ok();
    
    let cli = Cli::parse();
    
    // 從環境變數取得 API 金鑰
    let api_key = std::env::var("OPENWEATHER_API_KEY")
        .expect("Please set OPENWEATHER_API_KEY environment variable");

    let client = WeatherClient::new(api_key);

    match client.get_weather(&cli.city, &cli.units).await {
        Ok(weather) => {
            display_weather(&weather, &cli.units, cli.verbose);
        }
        Err(e) => {
            eprintln!("Error: {}", e);
            std::process::exit(1);
        }
    }

    Ok(())
}

fn display_weather(weather: &WeatherResponse, units: &str, verbose: bool) {
    let temp_unit = match units {
        "metric" => "°C",
        "imperial" => "°F",
        _ => "K",
    };

    println!("🌤️  Weather in {}, {}", weather.name, weather.sys.country);
    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    
    if let Some(weather_info) = weather.weather.first() {
        println!("☁️  Condition: {} ({})", weather_info.main, weather_info.description);
    }
    
    println!("🌡️  Temperature: {:.1}{}", weather.main.temp, temp_unit);
    println!("🤔  Feels like: {:.1}{}", weather.main.feels_like, temp_unit);
    
    if verbose {
        println!("📊  Detailed Information:");
        println!("   • Min Temperature: {:.1}{}", weather.main.temp_min, temp_unit);
        println!("   • Max Temperature: {:.1}{}", weather.main.temp_max, temp_unit);
        println!("   • Pressure: {} hPa", weather.main.pressure);
        println!("   • Humidity: {}%", weather.main.humidity);
    }
}

環境設定 .env

OPENWEATHER_API_KEY=your_api_key_here

如何取得 api key

跑起來

# 基本查詢
cargo run -- --city "Taipei"

# 使用華氏溫度
cargo run -- --city "New York" --units imperial

# 顯示詳細資訊
cargo run -- --city "Tokyo" --verbose

# 查看幫助
cargo run -- --help

錯誤處理

$ cargo run -- --city "InvalidCity"

Error: API request failed with status 404: {"cod":"404","message":"city not found"}


上一篇
HTTP 下載器 - 支援斷點續傳的檔案下載工具
系列文
Rust 實戰專案集:30 個漸進式專案從工具到服務10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言