commit f3ce23261f6d0a110371eb5b3c7b4cba4878a5a9
parent 1939e9b2049a806e877f2852034597b2eabf87cc
Author: Sylvia Ivory <git@sivory.net>
Date: Sun, 15 Jun 2025 22:04:50 -0700
Add forecast api
Diffstat:
5 files changed, 367 insertions(+), 32 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -149,6 +149,8 @@ version = "0.1.0"
dependencies = [
"reqwest",
"serde",
+ "serde_json",
+ "strum",
"thiserror",
"tokio",
]
@@ -277,6 +279,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
name = "http"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -961,6 +969,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
+name = "strum"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/weather/Cargo.toml b/crates/weather/Cargo.toml
@@ -6,6 +6,8 @@ edition = "2024"
[dependencies]
reqwest = { version = "0.12.20", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] }
+serde_json = "1.0.140"
+strum = { version = "0.27.1", features = ["derive"] }
thiserror = "2.0.12"
[dev-dependencies]
diff --git a/crates/weather/src/error.rs b/crates/weather/src/error.rs
@@ -4,8 +4,6 @@ pub enum Error {
Reqwest(#[from] reqwest::Error),
#[error("meteo error: {0}")]
Meteo(String),
- #[error("meteo returned nothing")]
- MeteoEmpty,
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
diff --git a/crates/weather/src/lib.rs b/crates/weather/src/lib.rs
@@ -9,8 +9,9 @@ use serde::{Serialize, de::DeserializeOwned};
mod error;
mod model;
-static USER_AGENT: &str = concat!("fjordgard/", env!("CARGO_PKG_VERSION"));
+const USER_AGENT: &str = concat!("fjordgard/", env!("CARGO_PKG_VERSION"));
const GEOCODING_API_HOST: &str = "geocoding-api.open-meteo.com";
+const FORECASTING_API_HOST: &str = "api.open-meteo.com";
pub struct MeteoClient {
api_key: Option<String>,
@@ -56,14 +57,9 @@ impl MeteoClient {
let resp: MeteoResponse<T> = req.send().await?.json().await?;
- if resp.error.unwrap_or_default() {
- return Err(Error::Meteo(resp.reason.unwrap_or_default()));
- }
-
- if let Some(res) = resp.results {
- Ok(res)
- } else {
- Err(Error::MeteoEmpty)
+ match resp {
+ MeteoResponse::Success(s) => Ok(s),
+ MeteoResponse::Error { error: _, reason } => Err(Error::Meteo(reason)),
}
}
@@ -72,16 +68,28 @@ impl MeteoClient {
&self,
name: &str,
opt: Option<GeocodeOptions>,
- ) -> Result<Vec<GeocodeResponse>> {
- let resp = self
+ ) -> Result<Vec<GeocodeResult>> {
+ let resp: GeocodeResponse = self
.request(GEOCODING_API_HOST, "search", Some(&[("name", name)]), opt)
- .await;
+ .await?;
- match resp {
- Err(Error::MeteoEmpty) => Ok(vec![]),
- Err(e) => Err(e),
- Ok(o) => Ok(o),
- }
+ Ok(resp.results)
+ }
+
+ /// Endpoint: `/forecast`
+ pub async fn forecast_single(
+ &self,
+ latitude: f64,
+ longitude: f64,
+ opt: Option<ForecastOptions>,
+ ) -> Result<ForecastResponse> {
+ self.request(
+ FORECASTING_API_HOST,
+ "forecast",
+ Some(&[("latitude", latitude), ("longitude", longitude)]),
+ opt,
+ )
+ .await
}
}
@@ -89,17 +97,40 @@ impl MeteoClient {
mod tests {
use super::*;
- #[tokio::test]
- async fn geocode() {
- let client = MeteoClient::new(None).unwrap();
+ async fn get_london(client: &MeteoClient) -> GeocodeResult {
let res = client
.geocode("London, United Kingdom", None)
.await
.unwrap();
- let london = res.get(0).unwrap();
+
+ res.get(0).unwrap().clone()
+ }
+
+ #[tokio::test]
+ async fn geocode() {
+ let client = MeteoClient::new(None).unwrap();
+ let london = get_london(&client).await;
assert_eq!(london.timezone, "Europe/London");
assert_eq!(london.admin1, Some("England".to_string()));
assert_eq!(london.country, "United Kingdom");
}
+
+ #[tokio::test]
+ async fn forecast_single() {
+ let client = MeteoClient::new(None).unwrap();
+ let london = get_london(&client).await;
+
+ client
+ .forecast_single(
+ london.latitude,
+ london.longitude,
+ Some(ForecastOptions {
+ hourly: Some(vec![HourlyVariable::Temperature2m]),
+ ..Default::default()
+ }),
+ )
+ .await
+ .unwrap();
+ }
}
diff --git a/crates/weather/src/model.rs b/crates/weather/src/model.rs
@@ -1,23 +1,25 @@
-use serde::{Deserialize, Serialize};
+use std::{collections::HashMap, fmt::Display};
+
+use serde::{Deserialize, Serialize, Serializer};
#[derive(Deserialize, Debug)]
-pub(crate) struct MeteoResponse<T> {
- pub(crate) results: Option<T>,
- pub(crate) error: Option<bool>,
- pub(crate) reason: Option<String>,
+#[serde(untagged)]
+pub(crate) enum MeteoResponse<T> {
+ Success(T),
+ Error { error: bool, reason: String },
}
-#[derive(Serialize)]
+#[derive(Serialize, Default)]
#[serde(default)]
pub struct GeocodeOptions {
pub count: Option<usize>,
pub language: Option<String>,
- pub api_key: Option<String>,
+ #[serde(rename = "countryCode")]
pub country_code: Option<String>,
}
-#[derive(Deserialize, Debug)]
-pub struct GeocodeResponse {
+#[derive(Deserialize, Debug, Clone)]
+pub struct GeocodeResult {
pub id: usize,
pub name: String,
pub latitude: f64,
@@ -49,3 +51,275 @@ pub struct GeocodeResponse {
#[serde(default)]
pub admin4_id: Option<usize>,
}
+
+#[derive(Deserialize, Debug, Clone)]
+pub struct GeocodeResponse {
+ pub results: Vec<GeocodeResult>,
+ pub generationtime_ms: f64,
+}
+
+#[derive(strum::Display)]
+#[strum(serialize_all = "snake_case")]
+pub enum HourlyVariable {
+ #[strum(to_string = "temperature_2m")]
+ Temperature2m,
+ #[strum(to_string = "temperature_{0}hPa")]
+ TemperaturePressureLevel(usize),
+ #[strum(to_string = "relative_humidity_2m")]
+ RelativeHumidity2m,
+ #[strum(to_string = "relative_humidity_{0}hPa")]
+ RelativeHumidityPressureLevel(usize),
+ #[strum(to_string = "dew_point_2m")]
+ DewPoint2m,
+ #[strum(to_string = "dew_point_{0}hPa")]
+ DewPointPressureLevel(usize),
+ ApparentTemperature,
+ PressureMsl,
+ SurfacePressure,
+ CloudCover,
+ CloudCoverLow,
+ CloudCoverMid,
+ CloudCoverHigh,
+ #[strum(to_string = "cloud_cover_{0}hPa")]
+ CloudCoverPressureLevel(usize),
+ #[strum(to_string = "wind_speed_10m")]
+ WindSpeed10m,
+ #[strum(to_string = "wind_speed_80m")]
+ WindSpeed80m,
+ #[strum(to_string = "wind_speed_120m")]
+ WindSpeed120m,
+ #[strum(to_string = "wind_speed_180m")]
+ WindSpeed180m,
+ #[strum(to_string = "wind_speed_{0}hPa")]
+ WindSpeedPressureLevel(usize),
+ #[strum(to_string = "wind_direction_10m")]
+ WindDirection10m,
+ #[strum(to_string = "wind_direction_80m")]
+ WindDirection80m,
+ #[strum(to_string = "wind_direction_120m")]
+ WindDirection120m,
+ #[strum(to_string = "wind_direction_180m")]
+ WindDIrection180m,
+ #[strum(to_string = "wind_direction_{0}hPa")]
+ WindDirectionPressureLevel(usize),
+ #[strum(to_string = "wind_gusts_10m")]
+ WindGusts10m,
+ ShortwaveRadiation,
+ DirectRadiation,
+ DirectNormalIrradiance,
+ DiffuseRadiation,
+ GlobalTiltedIrradiance,
+ VapourPressureDeficit,
+ Cape,
+ Evapotranspiration,
+ Et0FaoEvapotranspiration,
+ Precipitation,
+ Snowfall,
+ PrecipitationProbability,
+ Rain,
+ Showers,
+ WeatherCode,
+ SnowDepth,
+ FreezingLevelHeight,
+ Visibility,
+ #[strum(to_string = "soil_temperature_0cm")]
+ SoilTemperature0cm,
+ #[strum(to_string = "soil_temperature_6cm")]
+ SoilTemperature6cm,
+ #[strum(to_string = "soil_temperature_18cm")]
+ SoilTemperature18cm,
+ #[strum(to_string = "soil_temperature_54cm")]
+ SoilTemperature54cm,
+ #[strum(to_string = "soil_moisture_0_to_1cm")]
+ SoilMoisture0To1cm,
+ #[strum(to_string = "soil_moisture_1_to_3cm")]
+ SoilMoisture1To3cm,
+ #[strum(to_string = "soil_moisture_3_to_9cm")]
+ SoilMoisture3To9cm,
+ #[strum(to_string = "soil_moisture_9_to_27cm")]
+ SoilMoisture9To27cm,
+ #[strum(to_string = "soil_moisture_27_to_81cm")]
+ SoilMoisture28To81cm,
+ IsDay,
+ #[strum(to_string = "geopotential_height_{0}hPa")]
+ GeopotentialHeightPressureLevel(usize),
+}
+
+#[derive(strum::Display)]
+#[strum(serialize_all = "snake_case")]
+pub enum DailyVariable {
+ #[strum(to_string = "temperature_2m_max")]
+ Temperature2mMax,
+ #[strum(to_string = "temperature_2m_mean")]
+ Temperature2mMean,
+ #[strum(to_string = "temperature_2m_min")]
+ Temperature2mMin,
+ ApparentTemperatureMax,
+ ApparentTemperatureMean,
+ ApparentTemperatureMin,
+ PrecipitationSum,
+ RainSum,
+ ShowersSum,
+ SnowfallSum,
+ PrecipitationHours,
+ PrecipitationProbabilityMax,
+ PrecipitationProbabilityMean,
+ PrecipitationProbabilityMin,
+ WeatherCode,
+ Sunrise,
+ Sunset,
+ SunshineDuration,
+ DaylightDuration,
+ #[strum(to_string = "wind_speed_10m_max")]
+ WindSpeed10mMax,
+ #[strum(to_string = "wind_gusts_10m_max")]
+ WindGusts10mMax,
+ #[strum(to_string = "wind_direction_10m_dominant")]
+ WindDirection10mDominant,
+ ShortwaveRadiationSum,
+ Et0FaoEvapotranspiration,
+ UvIndexMax,
+ UvIndexClearSkyMax,
+}
+
+#[derive(strum::Display)]
+#[strum(serialize_all = "snake_case")]
+pub enum CurrentVariable {
+ #[strum(to_string = "temperature_2m")]
+ Temperature2m,
+ #[strum(to_string = "relative_humidity_2m")]
+ RelativeHumidity2m,
+ #[strum(to_string = "dew_point_2m")]
+ DewPoint2m,
+ ApparentTemperature,
+ ShortwaveRadiation,
+ DirectRadiation,
+ DirectNormalIrradiance,
+ GlobalTiltedIrradiance,
+ GlobalTiltedIrradianceInstant,
+ DiffuseRadiation,
+ SunshineDuration,
+ LightningPotential,
+ Precipitation,
+ Snowfall,
+ Rain,
+ Showers,
+ SnowfallHeight,
+ FreezingLevelHeight,
+ Cape,
+ #[strum(to_string = "wind_speed_10m")]
+ WindSpeed10m,
+ #[strum(to_string = "wind_speed_80m")]
+ WindSpeed80m,
+ #[strum(to_string = "wind_direction_10m")]
+ WindDirection10m,
+ #[strum(to_string = "wind_direction_80m")]
+ WindDirection80m,
+ #[strum(to_string = "wind_gusts_10m")]
+ WindGusts10m,
+ Visibility,
+ WeatherCode,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TemperatureUnit {
+ Celsius,
+ Fahrenheit,
+}
+
+#[derive(Serialize)]
+pub enum SpeedUnit {
+ #[serde(rename = "kmh")]
+ KilometersPerHour,
+ #[serde(rename = "ms")]
+ MetersPerSecond,
+ #[serde(rename = "mph")]
+ MilesPerHour,
+ #[serde(rename = "kn")]
+ Knots,
+}
+
+#[derive(Serialize)]
+pub enum PrecipitationUnit {
+ #[serde(rename = "mm")]
+ Millimeter,
+ #[serde(rename = "inch")]
+ Inch,
+}
+
+#[derive(Serialize)]
+#[serde(rename = "lowercase")]
+pub enum TimeFormat {
+ Iso8601,
+ UnixTime,
+}
+
+#[derive(Serialize)]
+#[serde(rename = "lowercase")]
+pub enum CellSelection {
+ Land,
+ Sea,
+ Nearest,
+}
+
+#[derive(Serialize, Default)]
+pub struct ForecastOptions {
+ pub elevation: Option<f64>,
+ #[serde(serialize_with = "csv")]
+ pub hourly: Option<Vec<HourlyVariable>>,
+ #[serde(serialize_with = "csv")]
+ pub daily: Option<Vec<DailyVariable>>,
+ #[serde(serialize_with = "csv")]
+ pub current: Option<Vec<CurrentVariable>>,
+ pub temperature_unit: Option<TemperatureUnit>,
+ pub wind_speed_unit: Option<SpeedUnit>,
+ pub precipitation_unit: Option<PrecipitationUnit>,
+ pub time_format: Option<TimeFormat>,
+ pub timezone: Option<String>,
+ pub past_days: Option<usize>,
+ pub past_hours: Option<usize>,
+ pub past_minutely_15: Option<usize>,
+ pub forecast_days: Option<usize>,
+ pub forecast_hours: Option<usize>,
+ pub forecast_minutely_15: Option<usize>,
+ pub start_date: Option<String>,
+ pub end_date: Option<String>,
+ pub start_hour: Option<String>,
+ pub end_hour: Option<String>,
+ pub start_minutely_15: Option<String>,
+ pub end_minutely_15: Option<String>,
+ #[serde(serialize_with = "csv")]
+ pub models: Option<Vec<String>>,
+ pub cell_selection: Option<CellSelection>,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+pub struct ForecastResponse {
+ pub latitude: f64,
+ pub longitude: f64,
+ pub elevation: f64,
+ #[serde(rename = "generationtime_ms")]
+ pub generation_time_ms: f64,
+ pub utc_offset_seconds: isize,
+ pub timezone: String,
+ pub timezone_abbreviation: String,
+ pub hourly: Option<HashMap<String, serde_json::Value>>,
+ pub hourly_units: Option<HashMap<String, String>>,
+ pub daily: Option<HashMap<String, serde_json::Value>>,
+ pub daily_units: Option<HashMap<String, String>>,
+}
+
+fn csv<S: Serializer, T: Display>(list: &Option<Vec<T>>, serializer: S) -> Result<S::Ok, S::Error> {
+ if let Some(list) = list {
+ let s: String = list
+ .iter()
+ .map(|v| v.to_string())
+ .collect::<Vec<String>>()
+ .join(",");
+
+ serializer.serialize_str(&s)
+ } else {
+ serializer.serialize_none()
+ }
+}