5
crates/adapters/weather/src/lib.rs
Normal file
5
crates/adapters/weather/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod open_meteo;
|
||||
mod wmo;
|
||||
|
||||
pub use open_meteo::OpenMeteoWeatherLookup;
|
||||
pub use wmo::condition_for;
|
||||
122
crates/adapters/weather/src/open_meteo.rs
Normal file
122
crates/adapters/weather/src/open_meteo.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::location::Coordinates;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::weather::{Celsius, Weather};
|
||||
|
||||
use super::wmo::condition_for;
|
||||
|
||||
const ARCHIVE_URL: &str = "https://archive-api.open-meteo.com/v1/archive";
|
||||
const FORECAST_URL: &str = "https://api.open-meteo.com/v1/forecast";
|
||||
const PROVIDER: &str = "open-meteo";
|
||||
const HOURLY_FIELDS: &str = "temperature_2m,weather_code";
|
||||
const DAYS_THE_ARCHIVE_LAGS_BEHIND: i64 = 6;
|
||||
|
||||
pub struct OpenMeteoWeatherLookup {
|
||||
http: reqwest::Client,
|
||||
provider: ProviderName,
|
||||
}
|
||||
|
||||
impl OpenMeteoWeatherLookup {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self {
|
||||
http,
|
||||
provider: ProviderName::from_persistence(PROVIDER.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct HourlyResponse {
|
||||
hourly: Option<Hourly>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Hourly {
|
||||
time: Vec<String>,
|
||||
#[serde(rename = "temperature_2m")]
|
||||
temperature: Vec<Option<f64>>,
|
||||
#[serde(rename = "weather_code")]
|
||||
code: Vec<Option<u8>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::WeatherLookupPort for OpenMeteoWeatherLookup {
|
||||
fn provider(&self) -> &ProviderName {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
async fn observed_at(
|
||||
&self,
|
||||
coordinates: &Coordinates,
|
||||
instant: &DateTime<FixedOffset>,
|
||||
) -> Result<Option<Weather>, DomainError> {
|
||||
let day = instant.date_naive().to_string();
|
||||
let url = url_for(instant);
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[
|
||||
("latitude", coordinates.latitude().value().to_string()),
|
||||
("longitude", coordinates.longitude().value().to_string()),
|
||||
("start_date", day.clone()),
|
||||
("end_date", day),
|
||||
("hourly", HOURLY_FIELDS.to_string()),
|
||||
("timezone", "UTC".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
DomainError::InvalidInput(format!("weather lookup failed: {error}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"the weather provider answered {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let hourly: HourlyResponse = response.json().await.map_err(|error| {
|
||||
DomainError::InvalidInput(format!(
|
||||
"the weather provider sent something unreadable: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(self.nearest_hour(hourly.hourly, instant))
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenMeteoWeatherLookup {
|
||||
fn nearest_hour(
|
||||
&self,
|
||||
hourly: Option<Hourly>,
|
||||
instant: &DateTime<FixedOffset>,
|
||||
) -> Option<Weather> {
|
||||
let hourly = hourly?;
|
||||
let wanted = instant.naive_utc().format("%Y-%m-%dT%H:00").to_string();
|
||||
|
||||
let at = hourly.time.iter().position(|hour| hour == &wanted)?;
|
||||
|
||||
let code = (*hourly.code.get(at)?)?;
|
||||
let degrees = (*hourly.temperature.get(at)?)?;
|
||||
|
||||
Some(Weather::new(
|
||||
condition_for(code)?,
|
||||
Celsius::new(degrees).ok()?,
|
||||
self.provider.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn url_for(instant: &DateTime<FixedOffset>) -> &'static str {
|
||||
let lag = chrono::Utc::now() - instant.with_timezone(&chrono::Utc);
|
||||
|
||||
if lag.num_days() >= DAYS_THE_ARCHIVE_LAGS_BEHIND {
|
||||
return ARCHIVE_URL;
|
||||
}
|
||||
|
||||
FORECAST_URL
|
||||
}
|
||||
14
crates/adapters/weather/src/wmo.rs
Normal file
14
crates/adapters/weather/src/wmo.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use domain::weather::Condition;
|
||||
|
||||
pub fn condition_for(code: u8) -> Option<Condition> {
|
||||
match code {
|
||||
0..=1 => Some(Condition::Clear),
|
||||
2..=3 => Some(Condition::Cloudy),
|
||||
45..=48 => Some(Condition::Fog),
|
||||
51..=57 => Some(Condition::Drizzle),
|
||||
61..=67 | 80..=82 => Some(Condition::Rain),
|
||||
71..=77 | 85..=86 => Some(Condition::Snow),
|
||||
95..=99 => Some(Condition::Thunderstorm),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user