meteosuisse 0.1.0

A reverse engineered wrapper around MétéoSuisse / MeteoSchweiz / MeteoSvizzera / MeteoSwiss
Documentation
use std::sync::RwLock;

use chrono::{DateTime, Utc};
use reqwest::header::{
    ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CONNECTION, COOKIE, HeaderMap, HeaderValue, REFERER,
    USER_AGENT,
};

pub mod ts;

#[derive(Debug, serde::Deserialize)]
pub struct ForecastDay {
    pub day_string: String,
    #[serde(with = "chrono::serde::ts_milliseconds")]
    pub min_date: DateTime<Utc>,
    #[serde(with = "chrono::serde::ts_milliseconds")]
    pub max_date: DateTime<Utc>,

    pub new_day: Option<i64>,

    pub current_time_string: Option<String>,
    pub current_time: Option<i64>,

    #[serde(deserialize_with = "ts::deserialize_ts")]
    pub rainfall: ts::TimeSeries,

    #[serde(
        deserialize_with = "ts::deserialize_ts_variance",
        rename = "variance_rain"
    )]
    pub rainfall_variance: ts::TimeSeriesVariance,

    #[serde(with = "chrono::serde::ts_milliseconds")]
    pub sunrise: DateTime<Utc>,
    #[serde(with = "chrono::serde::ts_milliseconds")]
    pub sunset: DateTime<Utc>,

    #[serde(deserialize_with = "ts::deserialize_ts")]
    pub sunshine: ts::TimeSeries,

    #[serde(deserialize_with = "ts::deserialize_ts")]
    pub temperature: ts::TimeSeries,

    #[serde(
        deserialize_with = "ts::deserialize_ts_variance",
        rename = "variance_range"
    )]
    pub temperature_variance: ts::TimeSeriesVariance,

    #[serde(deserialize_with = "ts::deserialize_ts_inside_data")]
    pub wind: ts::TimeSeries,

    #[serde(deserialize_with = "ts::deserialize_ts_variance")]
    pub wind_speed_variance: ts::TimeSeriesVariance,

    #[serde(deserialize_with = "ts::deserialize_ts_inside_data")]
    pub wind_gust_peak: ts::TimeSeries,

    #[serde(deserialize_with = "ts::deserialize_ts_variance")]
    pub wind_gust_speed_variance: ts::TimeSeriesVariance,
}

#[derive(Debug, serde::Deserialize)]
pub struct Forecast {
    #[serde(flatten)]
    pub forecasts: Vec<ForecastDay>,
}

#[derive(serde::Deserialize)]
struct ApiVersions {
    #[serde(rename = "forecast-chart")]
    forecast_chart: String,
}

pub struct MeteoSuisseClient {
    root_url: String,
    api_versions: RwLock<Option<ApiVersions>>,
    last_versions_fetch: RwLock<Option<DateTime<Utc>>>,
}

impl MeteoSuisseClient {
    pub fn new(root_url: &str) -> Self {
        MeteoSuisseClient {
            root_url: root_url.to_string(),
            api_versions: RwLock::new(None),
            last_versions_fetch: RwLock::new(None),
        }
    }
    pub fn default() -> Self {
        MeteoSuisseClient::new("https://www.meteosuisse.admin.ch/product/output/")
    }

    async fn get_api_versions(&self) -> Result<ApiVersions, Box<dyn std::error::Error>> {
        let data = self.get("versions.json").await?;
        let versions: ApiVersions = serde_json::from_str(&data)?;
        Ok(versions)
    }

    async fn refresh_api_versions(&self) -> Result<(), Box<dyn std::error::Error>> {
        let mut last_fetch = self.last_versions_fetch.write().unwrap();
        if let Some(last) = *last_fetch {
            if chrono::Utc::now().signed_duration_since(last).num_minutes() < 10 {
                return Ok(());
            }
        }

        let versions = self.get_api_versions().await?;
        let mut api_versions = self.api_versions.write().unwrap();
        *api_versions = Some(versions);
        *last_fetch = Some(chrono::Utc::now());

        Ok(())
    }

    async fn get(&self, endpoint: &str) -> Result<String, Box<dyn std::error::Error>> {
        let url = format!("{}{}", self.root_url, endpoint);

        let mut headers = HeaderMap::new();
        headers.insert(
            USER_AGENT,
            HeaderValue::from_static(
                "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
            ),
        );
        headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
        headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("fr-CH,en;q=0.5"));
        headers.insert(
            ACCEPT_ENCODING,
            HeaderValue::from_static("gzip, deflate, br, zstd"),
        );
        headers.insert(
            REFERER,
            HeaderValue::from_static(
                "https://www.meteosuisse.admin.ch/previsions-locales/jussy/1254.html",
            ),
        );
        headers.insert("Sec-GPC", HeaderValue::from_static("1"));
        headers.insert(
            "Alt-Used",
            HeaderValue::from_static("www.meteosuisse.admin.ch"),
        );
        headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("empty"));
        headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("cors"));
        headers.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));

        let client = reqwest::Client::builder()
            .default_headers(headers)
            .gzip(true) // enables gzip
            .deflate(true) // enables deflate
            .build()?;

        let resp = client.get(url).send().await?;

        let body = resp.text().await?;
        Ok(body)
    }

    pub fn get_from_file(&self, path: &str) -> Result<String, std::io::Error> {
        std::fs::read_to_string(path)
    }

    /// Returns the weather forecast for the given location ID.
    /// The location ID is basically the postal code + "00" (e.g. "125400" for Jussy).
    pub async fn get_forecast(
        &self,
        location_id: &str,
    ) -> Result<Vec<ForecastDay>, Box<dyn std::error::Error>> {
        self.refresh_api_versions().await?;
        let version_forecast = self.api_versions.read().unwrap();
        let version = &version_forecast.as_ref().unwrap().forecast_chart.clone();

        let data = self
            .get(&format!(
                "forecast-chart/version__{}/fr/{}.json",
                version, location_id
            ))
            .await?;
        //let data = self.get_from_file("data2.json").unwrap();
        let s = serde_json::from_str(&data)?;
        Ok(s)
    }
}