openmeteo-rs 1.0.0

Rust client for the Open-Meteo weather API.
Documentation
//! Historical archive endpoint builder.

use reqwest::Url;
use time::Date;

use super::url::{
    QueryParam, build_endpoint_url, push_api_param, push_api_values, push_float_param,
};
use super::validation::{validate_coordinates, validate_ordered_range};
use crate::response::decode_forecast_json;
use crate::units::{
    CellSelection, PrecipitationUnit, TemperatureUnit, TimeFormat, Timezone, WindSpeedUnit,
};
use crate::variables::{ArchiveModel, DailyVar, HourlyVar};
use crate::{ArchiveResponse, Client, Error, Result};

/// Builder for the `/v1/archive` endpoint.
#[derive(Debug)]
#[must_use = "archive builders do nothing until `.send().await` is called"]
pub struct ArchiveBuilder<'a> {
    client: &'a Client,
    latitude: f64,
    longitude: f64,
    start_date: Date,
    end_date: Date,
    hourly: Vec<HourlyVar>,
    daily: Vec<DailyVar>,
    temperature_unit: Option<TemperatureUnit>,
    wind_speed_unit: Option<WindSpeedUnit>,
    precipitation_unit: Option<PrecipitationUnit>,
    timeformat: Option<TimeFormat>,
    timezone: Option<Timezone>,
    cell_selection: Option<CellSelection>,
    elevation: Option<f64>,
    models: Vec<ArchiveModel>,
}

impl<'a> ArchiveBuilder<'a> {
    pub(crate) fn new(
        client: &'a Client,
        latitude: f64,
        longitude: f64,
        start_date: Date,
        end_date: Date,
    ) -> Self {
        Self {
            client,
            latitude,
            longitude,
            start_date,
            end_date,
            hourly: Vec::new(),
            daily: Vec::new(),
            temperature_unit: None,
            wind_speed_unit: None,
            precipitation_unit: None,
            timeformat: None,
            timezone: None,
            cell_selection: None,
            elevation: None,
            models: Vec::new(),
        }
    }

    /// Adds hourly archive variables to the request.
    ///
    /// Repeated calls accumulate variables rather than replacing the previous
    /// set.
    pub fn hourly<I>(mut self, variables: I) -> Self
    where
        I: IntoIterator<Item = HourlyVar>,
    {
        self.hourly.extend(variables);
        self
    }

    /// Adds daily archive variables to the request.
    ///
    /// Repeated calls accumulate variables rather than replacing the previous
    /// set.
    pub fn daily<I>(mut self, variables: I) -> Self
    where
        I: IntoIterator<Item = DailyVar>,
    {
        self.daily.extend(variables);
        self
    }

    /// Sets the temperature unit.
    pub fn temperature_unit(mut self, unit: TemperatureUnit) -> Self {
        self.temperature_unit = Some(unit);
        self
    }

    /// Sets the wind-speed unit.
    pub fn wind_speed_unit(mut self, unit: WindSpeedUnit) -> Self {
        self.wind_speed_unit = Some(unit);
        self
    }

    /// Sets the precipitation unit.
    pub fn precipitation_unit(mut self, unit: PrecipitationUnit) -> Self {
        self.precipitation_unit = Some(unit);
        self
    }

    /// Sets the response timestamp format.
    pub fn timeformat(mut self, format: TimeFormat) -> Self {
        self.timeformat = Some(format);
        self
    }

    /// Sets the response timezone.
    pub fn timezone(mut self, timezone: Timezone) -> Self {
        self.timezone = Some(timezone);
        self
    }

    /// Sets grid-cell selection near coastlines.
    pub fn cell_selection(mut self, selection: CellSelection) -> Self {
        self.cell_selection = Some(selection);
        self
    }

    /// Overrides the automatically detected elevation.
    pub fn elevation(mut self, meters: f64) -> Self {
        self.elevation = Some(meters);
        self
    }

    /// Pins one or more archive reanalysis models.
    pub fn models<I>(mut self, models: I) -> Self
    where
        I: IntoIterator<Item = ArchiveModel>,
    {
        self.models.extend(models);
        self
    }

    /// Sends the request and decodes the JSON archive response.
    pub async fn send(self) -> Result<ArchiveResponse> {
        let url = self.build_url()?;
        let body = self.client.execute(self.client.http.get(url)).await?;
        decode_forecast_json(&body)
    }

    pub(crate) fn build_url(&self) -> Result<Url> {
        self.validate()?;

        let mut params: Vec<QueryParam> = vec![
            ("latitude", self.latitude.to_string()),
            ("longitude", self.longitude.to_string()),
            ("start_date", self.start_date.to_string()),
            ("end_date", self.end_date.to_string()),
        ];
        push_api_values(&mut params, "hourly", &self.hourly);
        push_api_values(&mut params, "daily", &self.daily);
        push_api_param(
            &mut params,
            "temperature_unit",
            self.temperature_unit.as_ref(),
        );
        push_api_param(
            &mut params,
            "wind_speed_unit",
            self.wind_speed_unit.as_ref(),
        );
        push_api_param(
            &mut params,
            "precipitation_unit",
            self.precipitation_unit.as_ref(),
        );
        push_api_param(&mut params, "timeformat", self.timeformat.as_ref());
        push_api_param(&mut params, "timezone", self.timezone.as_ref());
        push_api_param(&mut params, "cell_selection", self.cell_selection.as_ref());
        push_float_param(&mut params, "elevation", self.elevation);
        push_api_values(&mut params, "models", &self.models);

        build_endpoint_url(
            &self.client.archive_base,
            "archive_base_url",
            "v1/archive",
            self.client.api_key.as_deref(),
            params,
        )
    }

    fn validate(&self) -> Result<()> {
        validate_coordinates(self.latitude, self.longitude)?;
        if self.hourly.is_empty() && self.daily.is_empty() {
            return Err(Error::InvalidParam {
                field: "variables",
                reason: "set at least one hourly or daily variable".into(),
            });
        }
        validate_ordered_range(
            "date_range",
            Some((self.start_date, self.end_date)),
            "start date must be before or equal to end date",
        )?;
        self.validate_hourly_variables()
    }

    fn validate_hourly_variables(&self) -> Result<()> {
        for var in &self.hourly {
            var.validate_weather_request()?;
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{ArchiveModel, DailyVar, HourlyVar, SoilMoistureDepth, TemperatureUnit, Timezone};
    use time::macros::date;

    #[test]
    fn build_archive_url_with_hourly_and_daily_variables() {
        let client = Client::builder()
            .archive_base_url("https://example.com/archive?token=abc")
            .unwrap()
            .build()
            .unwrap();

        let url = client
            .archive(
                47.3769,
                8.5417,
                date!(2024 - 01 - 01),
                date!(2024 - 01 - 02),
            )
            .hourly([HourlyVar::Temperature2m, HourlyVar::Precipitation])
            .daily([DailyVar::Temperature2mMax, DailyVar::PrecipitationSum])
            .timezone(Timezone::Iana("Europe/Zurich".to_owned()))
            .temperature_unit(TemperatureUnit::Fahrenheit)
            .models([ArchiveModel::Era5Land])
            .build_url()
            .unwrap();

        assert_eq!(
            url.as_str(),
            "https://example.com/archive/v1/archive?token=abc&latitude=47.3769&longitude=8.5417&start_date=2024-01-01&end_date=2024-01-02&hourly=temperature_2m%2Cprecipitation&daily=temperature_2m_max%2Cprecipitation_sum&temperature_unit=fahrenheit&timezone=Europe%2FZurich&models=era5_land"
        );
    }

    #[test]
    fn build_archive_url_with_api_key() {
        let client = Client::builder()
            .archive_base_url("https://example.com")
            .unwrap()
            .api_key("secret")
            .build()
            .unwrap();

        let url = client
            .archive(
                47.3769,
                8.5417,
                date!(2024 - 01 - 01),
                date!(2024 - 01 - 01),
            )
            .hourly([HourlyVar::Temperature2m])
            .build_url()
            .unwrap();

        assert_eq!(
            url.as_str(),
            "https://example.com/v1/archive?latitude=47.3769&longitude=8.5417&start_date=2024-01-01&end_date=2024-01-01&hourly=temperature_2m&apikey=secret"
        );
    }

    #[test]
    fn rejects_empty_archive_variable_set() {
        let client = Client::new();
        let err = client
            .archive(
                47.3769,
                8.5417,
                date!(2024 - 01 - 01),
                date!(2024 - 01 - 01),
            )
            .build_url()
            .unwrap_err();

        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "variables",
                ..
            }
        ));
    }

    #[test]
    fn rejects_reversed_archive_date_range() {
        let client = Client::new();
        let err = client
            .archive(
                47.3769,
                8.5417,
                date!(2024 - 01 - 02),
                date!(2024 - 01 - 01),
            )
            .hourly([HourlyVar::Temperature2m])
            .build_url()
            .unwrap_err();

        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "date_range",
                ..
            }
        ));
    }

    #[test]
    fn rejects_reversed_archive_soil_moisture_depth_range() {
        let client = Client::new();
        let err = client
            .archive(
                47.3769,
                8.5417,
                date!(2024 - 01 - 01),
                date!(2024 - 01 - 01),
            )
            .hourly([HourlyVar::SoilMoisture {
                from: SoilMoistureDepth::Cm27,
                to: SoilMoistureDepth::Cm3,
            }])
            .build_url()
            .unwrap_err();

        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "hourly.soil_moisture",
                ..
            }
        ));
    }
}