openmeteo-rs 1.0.0

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

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

use super::url::{
    QueryParam, build_endpoint_url, push_api_param, push_api_values, push_display_param,
};
use super::validation::{
    is_non_zero_u16, validate_at_most, validate_coordinates, validate_ordered_range,
};
use crate::response::decode_forecast_json;
use crate::units::{CellSelection, TimeFormat, Timezone};
use crate::variables::{FloodDailyVar, FloodModel};
use crate::{Client, Error, FloodResponse, Result};

const MAX_FORECAST_DAYS: u16 = 210;

/// Builder for the `/v1/flood` endpoint.
#[derive(Debug)]
#[must_use = "flood builders do nothing until `.send().await` is called"]
pub struct FloodBuilder<'a> {
    client: &'a Client,
    latitude: f64,
    longitude: f64,
    daily: Vec<FloodDailyVar>,
    models: Vec<FloodModel>,
    timeformat: Option<TimeFormat>,
    timezone: Option<Timezone>,
    past_days: Option<u16>,
    forecast_days: Option<u16>,
    date_range: Option<(Date, Date)>,
    ensemble: Option<bool>,
    cell_selection: Option<CellSelection>,
}

impl<'a> FloodBuilder<'a> {
    pub(crate) fn new(client: &'a Client, latitude: f64, longitude: f64) -> Self {
        Self {
            client,
            latitude,
            longitude,
            daily: Vec::new(),
            models: Vec::new(),
            timeformat: None,
            timezone: None,
            past_days: None,
            forecast_days: None,
            date_range: None,
            ensemble: None,
            cell_selection: None,
        }
    }

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

    /// Pins one or more flood models.
    pub fn models<I>(mut self, models: I) -> Self
    where
        I: IntoIterator<Item = FloodModel>,
    {
        self.models.extend(models);
        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: u16) -> Self {
        self.past_days = Some(days);
        self
    }

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

    /// Sets an inclusive date range using Open-Meteo's `start_date` and `end_date`.
    pub fn date_range(mut self, start: Date, end: Date) -> Self {
        self.date_range = Some((start, end));
        self
    }

    /// Requests all flood ensemble members when upstream supports them.
    pub fn ensemble(mut self, enabled: bool) -> Self {
        self.ensemble = Some(enabled);
        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 flood response.
    pub async fn send(self) -> Result<FloodResponse> {
        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, "daily", &self.daily);
        push_api_values(&mut params, "models", &self.models);
        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);
        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(ensemble) = self.ensemble {
            params.push(("ensemble", ensemble.to_string()));
        }
        push_api_param(&mut params, "cell_selection", self.cell_selection.as_ref());

        build_endpoint_url(
            &self.client.flood_base,
            "flood_base_url",
            "v1/flood",
            self.client.api_key.as_deref(),
            params,
        )
    }

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

        if self.date_range.is_some() && is_non_zero_u16(self.past_days) {
            return Err(Error::MutuallyExclusive {
                first: "date_range",
                second: "past_days",
            });
        }
        if self.date_range.is_some() && self.forecast_days.is_some() {
            return Err(Error::MutuallyExclusive {
                first: "date_range",
                second: "forecast_days",
            });
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{FloodDailyVar, FloodModel, Timezone};
    use time::macros::date;

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

        let url = client
            .flood(47.3769, 8.5417)
            .daily([
                FloodDailyVar::RiverDischarge,
                FloodDailyVar::RiverDischargeMean,
            ])
            .models([FloodModel::GlofasV4Seamless])
            .forecast_days(3)
            .ensemble(true)
            .timezone(Timezone::Iana("Europe/Zurich".to_owned()))
            .build_url()
            .unwrap();

        assert_eq!(
            url.as_str(),
            "https://example.com/flood/v1/flood?token=abc&latitude=47.3769&longitude=8.5417&daily=river_discharge%2Criver_discharge_mean&models=seamless_v4&timezone=Europe%2FZurich&forecast_days=3&ensemble=true"
        );
    }

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

        let url = client
            .flood(47.3769, 8.5417)
            .daily([FloodDailyVar::RiverDischarge])
            .date_range(date!(2025 - 01 - 01), date!(2025 - 01 - 03))
            .build_url()
            .unwrap();

        assert!(url.as_str().contains("start_date=2025-01-01"));
        assert!(url.as_str().contains("end_date=2025-01-03"));
    }

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

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

    #[test]
    fn rejects_flood_date_range_with_relative_window() {
        let client = Client::new();
        let err = client
            .flood(47.3769, 8.5417)
            .daily([FloodDailyVar::RiverDischarge])
            .forecast_days(3)
            .date_range(date!(2025 - 01 - 01), date!(2025 - 01 - 03))
            .build_url()
            .unwrap_err();

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

    #[test]
    fn rejects_out_of_range_flood_forecast_days() {
        let client = Client::new();
        let err = client
            .flood(47.3769, 8.5417)
            .daily([FloodDailyVar::RiverDischarge])
            .forecast_days(211)
            .build_url()
            .unwrap_err();

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