git.lucas.co / cce-weather
weather: now, the next 24 hours and the week
git clone https://git.lucas.co/cce-weather.git

src/api.rs (6.6K)

  1 //! Open-Meteo: the forecast and the geocoder. Both are keyless HTTPS JSON
  2 //! endpoints, called from worker threads (blocking reqwest) — never from the
  3 //! frame loop.
  4 
  5 use serde::{Deserialize, Serialize};
  6 
  7 use crate::config::{Location, Units};
  8 
  9 const FORECAST_URL: &str = "https://api.open-meteo.com/v1/forecast";
 10 const GEOCODE_URL: &str = "https://geocoding-api.open-meteo.com/v1/search";
 11 const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
 12 
 13 /// The fields asked for, per block. Each name is also a key of the response
 14 /// struct below, so the two lists move together.
 15 const CURRENT: &str = "temperature_2m,apparent_temperature,relative_humidity_2m,is_day,\
 16                        weather_code,wind_speed_10m,wind_direction_10m,precipitation";
 17 const HOURLY: &str = "temperature_2m,precipitation_probability,weather_code,is_day";
 18 const DAILY: &str = "weather_code,temperature_2m_max,temperature_2m_min,\
 19                      precipitation_probability_max,sunrise,sunset";
 20 
 21 #[derive(Debug, Clone, Serialize, Deserialize)]
 22 pub struct Forecast {
 23     /// Local times are in this zone (`timezone=auto`); every time string in
 24     /// the response is local wall-clock time without an offset.
 25     pub timezone: String,
 26     pub utc_offset_seconds: i32,
 27     pub current: Current,
 28     pub hourly: Hourly,
 29     pub daily: Daily,
 30     /// Units the request asked for, so a cached forecast is never shown
 31     /// under the other unit's suffix.
 32     #[serde(default)]
 33     pub units: Units,
 34 }
 35 
 36 #[derive(Debug, Clone, Serialize, Deserialize)]
 37 pub struct Current {
 38     pub time: String,
 39     pub temperature_2m: f32,
 40     pub apparent_temperature: f32,
 41     pub relative_humidity_2m: f32,
 42     pub is_day: u8,
 43     pub weather_code: u8,
 44     pub wind_speed_10m: f32,
 45     pub wind_direction_10m: f32,
 46     pub precipitation: f32,
 47 }
 48 
 49 #[derive(Debug, Clone, Serialize, Deserialize)]
 50 pub struct Hourly {
 51     pub time: Vec<String>,
 52     pub temperature_2m: Vec<Option<f32>>,
 53     pub precipitation_probability: Vec<Option<f32>>,
 54     pub weather_code: Vec<Option<u8>>,
 55     pub is_day: Vec<Option<u8>>,
 56 }
 57 
 58 #[derive(Debug, Clone, Serialize, Deserialize)]
 59 pub struct Daily {
 60     pub time: Vec<String>,
 61     pub weather_code: Vec<Option<u8>>,
 62     pub temperature_2m_max: Vec<Option<f32>>,
 63     pub temperature_2m_min: Vec<Option<f32>>,
 64     pub precipitation_probability_max: Vec<Option<f32>>,
 65     pub sunrise: Vec<String>,
 66     pub sunset: Vec<String>,
 67 }
 68 
 69 fn client() -> Result<reqwest::blocking::Client, String> {
 70     reqwest::blocking::Client::builder()
 71         .timeout(TIMEOUT)
 72         .user_agent(concat!("cce-weather/", env!("CARGO_PKG_VERSION")))
 73         .build()
 74         .map_err(|e| e.to_string())
 75 }
 76 
 77 pub fn fetch_forecast(loc: &Location, units: Units) -> Result<Forecast, String> {
 78     let lat = format!("{:.4}", loc.latitude);
 79     let lon = format!("{:.4}", loc.longitude);
 80     let mut query: Vec<(&str, &str)> = vec![
 81         ("latitude", &lat),
 82         ("longitude", &lon),
 83         ("current", CURRENT),
 84         ("hourly", HOURLY),
 85         ("daily", DAILY),
 86         ("timezone", "auto"),
 87         ("forecast_days", "7"),
 88     ];
 89     if units == Units::Imperial {
 90         query.extend([
 91             ("temperature_unit", "fahrenheit"),
 92             ("wind_speed_unit", "mph"),
 93             ("precipitation_unit", "inch"),
 94         ]);
 95     }
 96     let resp = client()?
 97         .get(FORECAST_URL)
 98         .query(&query)
 99         .send()
100         .map_err(|e| format!("network: {e}"))?;
101     if !resp.status().is_success() {
102         return Err(format!("forecast: HTTP {}", resp.status()));
103     }
104     let mut f: Forecast = resp.json().map_err(|e| format!("forecast: {e}"))?;
105     f.units = units;
106     Ok(f)
107 }
108 
109 #[derive(Deserialize)]
110 struct GeoResponse {
111     #[serde(default)]
112     results: Vec<GeoResult>,
113 }
114 
115 #[derive(Deserialize)]
116 struct GeoResult {
117     name: String,
118     latitude: f64,
119     longitude: f64,
120     #[serde(default)]
121     admin1: Option<String>,
122     #[serde(default)]
123     country: Option<String>,
124 }
125 
126 /// Up to `count` places matching a free-text name, best match first.
127 pub fn geocode(query: &str, count: usize) -> Result<Vec<Location>, String> {
128     let count = count.to_string();
129     let resp = client()?
130         .get(GEOCODE_URL)
131         .query(&[("name", query), ("count", &count), ("format", "json")])
132         .send()
133         .map_err(|e| format!("network: {e}"))?;
134     if !resp.status().is_success() {
135         return Err(format!("search: HTTP {}", resp.status()));
136     }
137     let geo: GeoResponse = resp.json().map_err(|e| format!("search: {e}"))?;
138     Ok(geo
139         .results
140         .into_iter()
141         .map(|r| Location {
142             name: r.name,
143             region: [r.admin1, r.country].into_iter().flatten().collect::<Vec<_>>().join(", "),
144             latitude: r.latitude,
145             longitude: r.longitude,
146         })
147         .collect())
148 }
149 
150 #[cfg(test)]
151 mod tests {
152     use super::*;
153 
154     /// A trimmed real response: the shape serde must accept, including the
155     /// nulls Open-Meteo sends past the end of a model's horizon.
156     const SAMPLE: &str = r#"{
157         "latitude": 40.71, "longitude": -74.0, "timezone": "America/New_York",
158         "utc_offset_seconds": -14400,
159         "current": {"time": "2026-10-05T14:00", "interval": 900, "temperature_2m": 18.4,
160             "apparent_temperature": 17.1, "relative_humidity_2m": 61, "is_day": 1,
161             "weather_code": 2, "wind_speed_10m": 11.2, "wind_direction_10m": 225,
162             "precipitation": 0.0},
163         "hourly": {"time": ["2026-10-05T00:00", "2026-10-05T01:00"],
164             "temperature_2m": [15.0, null], "precipitation_probability": [10, null],
165             "weather_code": [3, null], "is_day": [0, 0]},
166         "daily": {"time": ["2026-10-05"], "weather_code": [61],
167             "temperature_2m_max": [19.2], "temperature_2m_min": [12.0],
168             "precipitation_probability_max": [70],
169             "sunrise": ["2026-10-05T07:01"], "sunset": ["2026-10-05T18:37"]}
170     }"#;
171 
172     #[test]
173     fn parses_forecast_with_nulls() {
174         let f: Forecast = serde_json::from_str(SAMPLE).unwrap();
175         assert_eq!(f.current.weather_code, 2);
176         assert_eq!(f.hourly.temperature_2m[1], None);
177         assert_eq!(f.daily.precipitation_probability_max[0], Some(70.0));
178         assert_eq!(f.units, Units::Metric);
179     }
180 
181     /// Against the real service: `cargo test -p cce-weather -- --ignored`.
182     #[test]
183     #[ignore]
184     fn live_geocode_and_forecast() {
185         let places = geocode("Brooklyn", 3).unwrap();
186         assert!(!places.is_empty());
187         let f = fetch_forecast(&places[0], Units::Imperial).unwrap();
188         assert!(f.hourly.time.len() >= 24 * 7);
189         assert_eq!(f.daily.time.len(), 7);
190         assert_eq!(f.units, Units::Imperial);
191     }
192 }