openmeteo-rs 1.0.0

Rust client for the Open-Meteo weather API.
Documentation
//! Air-quality endpoint builder.

use reqwest::Url;
use time::{Date, PrimitiveDateTime};

use super::url::{
    QueryParam, build_endpoint_url, push_api_param, push_api_values, push_display_param,
};
use super::validation::{
    format_hour, is_non_zero_u8, is_non_zero_u16, validate_at_most, validate_coordinates,
    validate_fixed_range_windows,
};
use crate::response::decode_forecast_json;
use crate::units::{CellSelection, TimeFormat, Timezone};
use crate::variables::{AirQualityDomain, AirQualityHourlyVar};
use crate::{AirQualityResponse, Client, Error, Result};

const MAX_FORECAST_DAYS: u8 = 7;
const MAX_PAST_DAYS: u8 = 92;
const MAX_FORECAST_HOURS: u16 = 7 * 24;
const MAX_PAST_HOURS: u16 = 92 * 24;

/// Builder for the `/v1/air-quality` endpoint.
#[derive(Debug)]
#[must_use = "air-quality builders do nothing until `.send().await` is called"]
pub struct AirQualityBuilder<'a> {
    client: &'a Client,
    latitude: f64,
    longitude: f64,
    hourly: Vec<AirQualityHourlyVar>,
    current: Vec<AirQualityHourlyVar>,
    domain: Option<AirQualityDomain>,
    timeformat: Option<TimeFormat>,
    timezone: Option<Timezone>,
    past_days: Option<u8>,
    forecast_days: Option<u8>,
    past_hours: Option<u16>,
    forecast_hours: Option<u16>,
    date_range: Option<(Date, Date)>,
    hour_range: Option<(PrimitiveDateTime, PrimitiveDateTime)>,
    cell_selection: Option<CellSelection>,
}

impl<'a> AirQualityBuilder<'a> {
    pub(crate) fn new(client: &'a Client, latitude: f64, longitude: f64) -> Self {
        Self {
            client,
            latitude,
            longitude,
            hourly: Vec::new(),
            current: Vec::new(),
            domain: None,
            timeformat: None,
            timezone: None,
            past_days: None,
            forecast_days: None,
            past_hours: None,
            forecast_hours: None,
            date_range: None,
            hour_range: None,
            cell_selection: None,
        }
    }

    /// Adds hourly air-quality variables to the request.
    pub fn hourly<I>(mut self, variables: I) -> Self
    where
        I: IntoIterator<Item = AirQualityHourlyVar>,
    {
        self.hourly.extend(variables);
        self
    }

    /// Adds current-condition air-quality variables to the request.
    pub fn current<I>(mut self, variables: I) -> Self
    where
        I: IntoIterator<Item = AirQualityHourlyVar>,
    {
        self.current.extend(variables);
        self
    }

    /// Selects the air-quality model domain.
    pub fn domain(mut self, domain: AirQualityDomain) -> Self {
        self.domain = Some(domain);
        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
    }

    /// Requests a number of past days.
    pub fn past_days(mut self, days: u8) -> Self {
        self.past_days = Some(days);
        self
    }

    /// Requests a number of forecast days.
    pub fn forecast_days(mut self, days: u8) -> Self {
        self.forecast_days = Some(days);
        self
    }

    /// Requests a number of past hourly timesteps.
    pub fn past_hours(mut self, hours: u16) -> Self {
        self.past_hours = Some(hours);
        self
    }

    /// Requests a number of forecast hourly timesteps.
    pub fn forecast_hours(mut self, hours: u16) -> Self {
        self.forecast_hours = Some(hours);
        self
    }

    /// Sets an inclusive date range using Open-Meteo's `start_date` and `end_date`.
    ///
    /// This is mutually exclusive with relative day/hour windows and with
    /// [`Self::hour_range`].
    pub fn date_range(mut self, start: Date, end: Date) -> Self {
        self.date_range = Some((start, end));
        self
    }

    /// Sets an hour-grained range using Open-Meteo's `start_hour` and `end_hour`.
    ///
    /// This is mutually exclusive with relative day/hour windows and with
    /// [`Self::date_range`].
    pub fn hour_range(mut self, start: PrimitiveDateTime, end: PrimitiveDateTime) -> Self {
        self.hour_range = Some((start, end));
        self
    }

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

    /// Sends the request and decodes the JSON air-quality response.
    pub async fn send(self) -> Result<AirQualityResponse> {
        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()),
        ];
        push_api_values(&mut params, "hourly", &self.hourly);
        push_api_values(&mut params, "current", &self.current);
        push_api_param(&mut params, "domains", self.domain.as_ref());
        push_api_param(&mut params, "timeformat", self.timeformat.as_ref());
        push_api_param(&mut params, "timezone", self.timezone.as_ref());
        push_display_param(
            &mut params,
            "past_days",
            self.past_days.filter(|days| *days > 0),
        );
        push_display_param(&mut params, "forecast_days", self.forecast_days);
        push_display_param(
            &mut params,
            "past_hours",
            self.past_hours.filter(|hours| *hours > 0),
        );
        push_display_param(
            &mut params,
            "forecast_hours",
            self.forecast_hours.filter(|hours| *hours > 0),
        );
        if let Some((start, end)) = self.date_range {
            params.push(("start_date", start.to_string()));
            params.push(("end_date", end.to_string()));
        }
        if let Some((start, end)) = self.hour_range {
            params.push(("start_hour", format_hour(start)?));
            params.push(("end_hour", format_hour(end)?));
        }
        push_api_param(&mut params, "cell_selection", self.cell_selection.as_ref());

        build_endpoint_url(
            &self.client.air_quality_base,
            "air_quality_base_url",
            "v1/air-quality",
            self.client.api_key.as_deref(),
            params,
        )
    }

    fn validate(&self) -> Result<()> {
        validate_coordinates(self.latitude, self.longitude)?;
        if self.hourly.is_empty() && self.current.is_empty() {
            return Err(Error::InvalidParam {
                field: "variables",
                reason: "set at least one hourly or current variable".into(),
            });
        }
        validate_at_most("past_days", self.past_days, MAX_PAST_DAYS)?;
        validate_at_most("forecast_days", self.forecast_days, MAX_FORECAST_DAYS)?;
        validate_at_most("past_hours", self.past_hours, MAX_PAST_HOURS)?;
        validate_at_most("forecast_hours", self.forecast_hours, MAX_FORECAST_HOURS)?;
        validate_fixed_range_windows(
            self.date_range,
            self.hour_range,
            &[
                ("past_days", is_non_zero_u8(self.past_days)),
                ("forecast_days", self.forecast_days.is_some()),
                ("past_hours", is_non_zero_u16(self.past_hours)),
                ("forecast_hours", is_non_zero_u16(self.forecast_hours)),
            ],
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{AirQualityDomain, AirQualityHourlyVar, CellSelection, Timezone};
    use time::macros::{date, datetime};

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

        let url = client
            .air_quality(47.3769, 8.5417)
            .hourly([
                AirQualityHourlyVar::Pm10,
                AirQualityHourlyVar::Pm2_5,
                AirQualityHourlyVar::EuropeanAqi,
            ])
            .current([
                AirQualityHourlyVar::Pm10,
                AirQualityHourlyVar::UsAqi,
                AirQualityHourlyVar::CarbonDioxide,
            ])
            .domain(AirQualityDomain::CamsEurope)
            .timezone(Timezone::Iana("Europe/Zurich".to_owned()))
            .forecast_days(1)
            .cell_selection(CellSelection::Nearest)
            .build_url()
            .unwrap();

        assert_eq!(
            url.as_str(),
            "https://example.com/air/v1/air-quality?token=abc&latitude=47.3769&longitude=8.5417&hourly=pm10%2Cpm2_5%2Ceuropean_aqi&current=pm10%2Cus_aqi%2Ccarbon_dioxide&domains=cams_europe&timezone=Europe%2FZurich&forecast_days=1&cell_selection=nearest"
        );
    }

    #[test]
    fn build_air_quality_url_with_date_and_hour_ranges() {
        let client = Client::builder()
            .air_quality_base_url("https://example.com")
            .unwrap()
            .build()
            .unwrap();

        let date_url = client
            .air_quality(47.3769, 8.5417)
            .hourly([AirQualityHourlyVar::Pm10])
            .date_range(date!(2026 - 04 - 29), date!(2026 - 04 - 30))
            .build_url()
            .unwrap();
        assert_eq!(
            date_url.as_str(),
            "https://example.com/v1/air-quality?latitude=47.3769&longitude=8.5417&hourly=pm10&start_date=2026-04-29&end_date=2026-04-30"
        );

        let hour_url = client
            .air_quality(47.3769, 8.5417)
            .hourly([AirQualityHourlyVar::Pm10])
            .hour_range(datetime!(2026-04-29 06:00), datetime!(2026-04-29 18:00))
            .build_url()
            .unwrap();
        assert_eq!(
            hour_url.as_str(),
            "https://example.com/v1/air-quality?latitude=47.3769&longitude=8.5417&hourly=pm10&start_hour=2026-04-29T06%3A00&end_hour=2026-04-29T18%3A00"
        );
    }

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

        let url = client
            .air_quality(47.3769, 8.5417)
            .hourly([AirQualityHourlyVar::Pm10])
            .build_url()
            .unwrap();

        assert_eq!(
            url.as_str(),
            "https://example.com/v1/air-quality?latitude=47.3769&longitude=8.5417&hourly=pm10&apikey=secret"
        );
    }

    #[test]
    fn rejects_empty_air_quality_variable_set() {
        let client = Client::new();
        let err = client.air_quality(47.3769, 8.5417).build_url().unwrap_err();

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

    #[test]
    fn rejects_out_of_range_air_quality_forecast_days() {
        let client = Client::new();
        let err = client
            .air_quality(47.3769, 8.5417)
            .hourly([AirQualityHourlyVar::Pm10])
            .forecast_days(8)
            .build_url()
            .unwrap_err();

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

    #[test]
    fn rejects_out_of_range_air_quality_hours() {
        let client = Client::new();
        let err = client
            .air_quality(47.3769, 8.5417)
            .hourly([AirQualityHourlyVar::Pm10])
            .forecast_hours(169)
            .build_url()
            .unwrap_err();

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

    #[test]
    fn rejects_air_quality_date_range_with_relative_window() {
        let client = Client::new();
        let err = client
            .air_quality(47.3769, 8.5417)
            .hourly([AirQualityHourlyVar::Pm10])
            .date_range(date!(2026 - 04 - 29), date!(2026 - 04 - 30))
            .forecast_days(1)
            .build_url()
            .unwrap_err();

        assert!(matches!(
            err,
            Error::MutuallyExclusive {
                first: "date_range",
                second: "forecast_days"
            }
        ));
    }
}