use reqwest::Url;
use time::{Date, PrimitiveDateTime};
use super::url::{
build_endpoint_url, push_api_param, push_api_values, push_display_param, push_float_param,
};
use super::validation::{
format_hour, is_non_zero_u8, is_non_zero_u16, validate_at_most, validate_coordinates,
validate_fixed_range_windows, validate_optional_solar_angle,
};
use crate::response::{decode_forecast_json, decode_forecast_json_many};
use crate::units::{
CellSelection, PrecipitationUnit, TemperatureUnit, TimeFormat, Timezone, WindSpeedUnit,
};
use crate::variables::{CurrentVar, DailyVar, HourlyVar, Minutely15Var, WeatherModel};
use crate::{Client, Error, ForecastResponse, Result};
const MAX_FORECAST_DAYS: u8 = 16;
const MAX_PAST_DAYS: u8 = 92;
const MAX_FORECAST_HOURS: u16 = 16 * 24;
const MAX_FORECAST_MINUTELY_15: u16 = 16 * 24 * 4;
const MAX_FORECAST_LOCATIONS: usize = 1000;
#[derive(Debug)]
#[must_use = "forecast builders do nothing until `.send().await` is called"]
pub struct ForecastBuilder<'a> {
client: &'a Client,
latitude: f64,
longitude: f64,
hourly: Vec<HourlyVar>,
minutely_15: Vec<Minutely15Var>,
daily: Vec<DailyVar>,
current: Vec<CurrentVar>,
temperature_unit: Option<TemperatureUnit>,
wind_speed_unit: Option<WindSpeedUnit>,
precipitation_unit: Option<PrecipitationUnit>,
timeformat: Option<TimeFormat>,
timezone: Option<Timezone>,
past_days: Option<u8>,
forecast_days: Option<u8>,
past_hours: Option<u16>,
forecast_hours: Option<u16>,
past_minutely_15: Option<u16>,
forecast_minutely_15: Option<u16>,
date_range: Option<(Date, Date)>,
hour_range: Option<(PrimitiveDateTime, PrimitiveDateTime)>,
models: Vec<WeatherModel>,
cell_selection: Option<CellSelection>,
elevation: Option<f64>,
tilt: Option<f32>,
azimuth: Option<f32>,
}
impl<'a> ForecastBuilder<'a> {
pub(crate) fn new(client: &'a Client, latitude: f64, longitude: f64) -> Self {
Self {
client,
latitude,
longitude,
hourly: Vec::new(),
minutely_15: Vec::new(),
daily: Vec::new(),
current: Vec::new(),
temperature_unit: None,
wind_speed_unit: None,
precipitation_unit: None,
timeformat: None,
timezone: None,
past_days: None,
forecast_days: None,
past_hours: None,
forecast_hours: None,
past_minutely_15: None,
forecast_minutely_15: None,
date_range: None,
hour_range: None,
models: Vec::new(),
cell_selection: None,
elevation: None,
tilt: None,
azimuth: None,
}
}
pub fn hourly<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = HourlyVar>,
{
self.hourly.extend(variables);
self
}
pub fn minutely_15<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = Minutely15Var>,
{
self.minutely_15.extend(variables);
self
}
pub fn daily<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = DailyVar>,
{
self.daily.extend(variables);
self
}
pub fn current<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = CurrentVar>,
{
self.current.extend(variables);
self
}
pub fn temperature_unit(mut self, unit: TemperatureUnit) -> Self {
self.temperature_unit = Some(unit);
self
}
pub fn wind_speed_unit(mut self, unit: WindSpeedUnit) -> Self {
self.wind_speed_unit = Some(unit);
self
}
pub fn precipitation_unit(mut self, unit: PrecipitationUnit) -> Self {
self.precipitation_unit = Some(unit);
self
}
pub fn timeformat(mut self, format: TimeFormat) -> Self {
self.timeformat = Some(format);
self
}
pub fn timezone(mut self, timezone: Timezone) -> Self {
self.timezone = Some(timezone);
self
}
pub fn past_days(mut self, days: u8) -> Self {
self.past_days = Some(days);
self
}
pub fn forecast_days(mut self, days: u8) -> Self {
self.forecast_days = Some(days);
self
}
pub fn past_hours(mut self, hours: u16) -> Self {
self.past_hours = Some(hours);
self
}
pub fn forecast_hours(mut self, hours: u16) -> Self {
self.forecast_hours = Some(hours);
self
}
pub fn past_minutely_15(mut self, intervals: u16) -> Self {
self.past_minutely_15 = Some(intervals);
self
}
pub fn forecast_minutely_15(mut self, intervals: u16) -> Self {
self.forecast_minutely_15 = Some(intervals);
self
}
pub fn date_range(mut self, start: Date, end: Date) -> Self {
self.date_range = Some((start, end));
self
}
pub fn hour_range(mut self, start: PrimitiveDateTime, end: PrimitiveDateTime) -> Self {
self.hour_range = Some((start, end));
self
}
pub fn models<I>(mut self, models: I) -> Self
where
I: IntoIterator<Item = WeatherModel>,
{
self.models.extend(models);
self
}
pub fn cell_selection(mut self, selection: CellSelection) -> Self {
self.cell_selection = Some(selection);
self
}
pub fn elevation(mut self, meters: f64) -> Self {
self.elevation = Some(meters);
self
}
pub fn tilt(mut self, degrees: f32) -> Self {
self.tilt = Some(degrees);
self
}
pub fn azimuth(mut self, degrees: f32) -> Self {
self.azimuth = Some(degrees);
self
}
pub async fn send(self) -> Result<ForecastResponse> {
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![
("latitude", self.latitude.to_string()),
("longitude", self.longitude.to_string()),
];
push_api_values(&mut params, "hourly", &self.hourly);
push_api_values(&mut params, "minutely_15", &self.minutely_15);
push_api_values(&mut params, "daily", &self.daily);
push_api_values(&mut params, "current", &self.current);
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_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),
);
push_display_param(
&mut params,
"past_minutely_15",
self.past_minutely_15.filter(|intervals| *intervals > 0),
);
push_display_param(
&mut params,
"forecast_minutely_15",
self.forecast_minutely_15.filter(|intervals| *intervals > 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_values(&mut params, "models", &self.models);
push_api_param(&mut params, "cell_selection", self.cell_selection.as_ref());
push_float_param(&mut params, "elevation", self.elevation);
push_float_param(&mut params, "tilt", self.tilt.map(Into::into));
push_float_param(&mut params, "azimuth", self.azimuth.map(Into::into));
build_endpoint_url(
&self.client.forecast_base,
"forecast_base_url",
"v1/forecast",
self.client.api_key.as_deref(),
params,
)
}
fn validate(&self) -> Result<()> {
validate_coordinates(self.latitude, self.longitude)?;
if self.hourly.is_empty()
&& self.minutely_15.is_empty()
&& self.daily.is_empty()
&& self.current.is_empty()
{
return Err(Error::InvalidParam {
field: "variables",
reason: "set at least one hourly, minutely_15, daily, 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("forecast_hours", self.forecast_hours, MAX_FORECAST_HOURS)?;
validate_at_most(
"forecast_minutely_15",
self.forecast_minutely_15,
MAX_FORECAST_MINUTELY_15,
)?;
validate_optional_solar_angle("tilt", self.tilt, 0.0..=90.0)?;
validate_optional_solar_angle("azimuth", self.azimuth, -180.0..=180.0)?;
self.validate_hourly_variables()?;
self.validate_current_variables()?;
self.validate_minutely_15_variables()?;
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)),
("past_minutely_15", is_non_zero_u16(self.past_minutely_15)),
(
"forecast_minutely_15",
is_non_zero_u16(self.forecast_minutely_15),
),
],
)
}
fn validate_hourly_variables(&self) -> Result<()> {
for var in &self.hourly {
var.validate_weather_request()?;
}
Ok(())
}
fn validate_current_variables(&self) -> Result<()> {
for var in &self.current {
var.validate_weather_request()?;
}
Ok(())
}
fn validate_minutely_15_variables(&self) -> Result<()> {
for var in &self.minutely_15 {
var.validate_weather_request()?;
}
Ok(())
}
}
#[derive(Debug)]
#[must_use = "forecast batch builders do nothing until `.send().await` is called"]
pub struct ForecastBatchBuilder<'a> {
client: &'a Client,
locations: Vec<(f64, f64)>,
hourly: Vec<HourlyVar>,
minutely_15: Vec<Minutely15Var>,
daily: Vec<DailyVar>,
current: Vec<CurrentVar>,
temperature_unit: Option<TemperatureUnit>,
wind_speed_unit: Option<WindSpeedUnit>,
precipitation_unit: Option<PrecipitationUnit>,
timeformat: Option<TimeFormat>,
timezone: Option<Timezone>,
past_days: Option<u8>,
forecast_days: Option<u8>,
past_hours: Option<u16>,
forecast_hours: Option<u16>,
past_minutely_15: Option<u16>,
forecast_minutely_15: Option<u16>,
date_range: Option<(Date, Date)>,
hour_range: Option<(PrimitiveDateTime, PrimitiveDateTime)>,
models: Vec<WeatherModel>,
cell_selection: Option<CellSelection>,
elevation: Option<f64>,
tilt: Option<f32>,
azimuth: Option<f32>,
}
impl<'a> ForecastBatchBuilder<'a> {
pub(crate) fn new<I>(client: &'a Client, locations: I) -> Self
where
I: IntoIterator<Item = (f64, f64)>,
{
Self {
client,
locations: locations.into_iter().collect(),
hourly: Vec::new(),
minutely_15: Vec::new(),
daily: Vec::new(),
current: Vec::new(),
temperature_unit: None,
wind_speed_unit: None,
precipitation_unit: None,
timeformat: None,
timezone: None,
past_days: None,
forecast_days: None,
past_hours: None,
forecast_hours: None,
past_minutely_15: None,
forecast_minutely_15: None,
date_range: None,
hour_range: None,
models: Vec::new(),
cell_selection: None,
elevation: None,
tilt: None,
azimuth: None,
}
}
pub fn hourly<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = HourlyVar>,
{
self.hourly.extend(variables);
self
}
pub fn minutely_15<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = Minutely15Var>,
{
self.minutely_15.extend(variables);
self
}
pub fn daily<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = DailyVar>,
{
self.daily.extend(variables);
self
}
pub fn current<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = CurrentVar>,
{
self.current.extend(variables);
self
}
pub fn temperature_unit(mut self, unit: TemperatureUnit) -> Self {
self.temperature_unit = Some(unit);
self
}
pub fn wind_speed_unit(mut self, unit: WindSpeedUnit) -> Self {
self.wind_speed_unit = Some(unit);
self
}
pub fn precipitation_unit(mut self, unit: PrecipitationUnit) -> Self {
self.precipitation_unit = Some(unit);
self
}
pub fn timeformat(mut self, format: TimeFormat) -> Self {
self.timeformat = Some(format);
self
}
pub fn timezone(mut self, timezone: Timezone) -> Self {
self.timezone = Some(timezone);
self
}
pub fn past_days(mut self, days: u8) -> Self {
self.past_days = Some(days);
self
}
pub fn forecast_days(mut self, days: u8) -> Self {
self.forecast_days = Some(days);
self
}
pub fn past_hours(mut self, hours: u16) -> Self {
self.past_hours = Some(hours);
self
}
pub fn forecast_hours(mut self, hours: u16) -> Self {
self.forecast_hours = Some(hours);
self
}
pub fn past_minutely_15(mut self, intervals: u16) -> Self {
self.past_minutely_15 = Some(intervals);
self
}
pub fn forecast_minutely_15(mut self, intervals: u16) -> Self {
self.forecast_minutely_15 = Some(intervals);
self
}
pub fn date_range(mut self, start: Date, end: Date) -> Self {
self.date_range = Some((start, end));
self
}
pub fn hour_range(mut self, start: PrimitiveDateTime, end: PrimitiveDateTime) -> Self {
self.hour_range = Some((start, end));
self
}
pub fn models<I>(mut self, models: I) -> Self
where
I: IntoIterator<Item = WeatherModel>,
{
self.models.extend(models);
self
}
pub fn cell_selection(mut self, selection: CellSelection) -> Self {
self.cell_selection = Some(selection);
self
}
pub fn elevation(mut self, meters: f64) -> Self {
self.elevation = Some(meters);
self
}
pub fn tilt(mut self, degrees: f32) -> Self {
self.tilt = Some(degrees);
self
}
pub fn azimuth(mut self, degrees: f32) -> Self {
self.azimuth = Some(degrees);
self
}
pub async fn send(self) -> Result<Vec<ForecastResponse>> {
let url = self.build_url()?;
let body = self.client.execute(self.client.http.get(url)).await?;
decode_forecast_json_many(&body)
}
pub(crate) fn build_url(&self) -> Result<Url> {
self.validate()?;
let latitudes = self
.locations
.iter()
.map(|(latitude, _)| latitude.to_string())
.collect::<Vec<_>>()
.join(",");
let longitudes = self
.locations
.iter()
.map(|(_, longitude)| longitude.to_string())
.collect::<Vec<_>>()
.join(",");
let mut params = vec![("latitude", latitudes), ("longitude", longitudes)];
push_api_values(&mut params, "hourly", &self.hourly);
push_api_values(&mut params, "minutely_15", &self.minutely_15);
push_api_values(&mut params, "daily", &self.daily);
push_api_values(&mut params, "current", &self.current);
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_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),
);
push_display_param(
&mut params,
"past_minutely_15",
self.past_minutely_15.filter(|intervals| *intervals > 0),
);
push_display_param(
&mut params,
"forecast_minutely_15",
self.forecast_minutely_15.filter(|intervals| *intervals > 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_values(&mut params, "models", &self.models);
push_api_param(&mut params, "cell_selection", self.cell_selection.as_ref());
push_float_param(&mut params, "elevation", self.elevation);
push_float_param(&mut params, "tilt", self.tilt.map(Into::into));
push_float_param(&mut params, "azimuth", self.azimuth.map(Into::into));
build_endpoint_url(
&self.client.forecast_base,
"forecast_base_url",
"v1/forecast",
self.client.api_key.as_deref(),
params,
)
}
fn validate(&self) -> Result<()> {
if self.locations.is_empty() {
return Err(Error::InvalidParam {
field: "locations",
reason: "set at least one forecast location".into(),
});
}
if self.locations.len() > MAX_FORECAST_LOCATIONS {
return Err(Error::InvalidParam {
field: "locations",
reason: format!("must contain at most {MAX_FORECAST_LOCATIONS} locations"),
});
}
for (latitude, longitude) in &self.locations {
validate_coordinates(*latitude, *longitude)?;
}
if self.hourly.is_empty()
&& self.minutely_15.is_empty()
&& self.daily.is_empty()
&& self.current.is_empty()
{
return Err(Error::InvalidParam {
field: "variables",
reason: "set at least one hourly, minutely_15, daily, 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("forecast_hours", self.forecast_hours, MAX_FORECAST_HOURS)?;
validate_at_most(
"forecast_minutely_15",
self.forecast_minutely_15,
MAX_FORECAST_MINUTELY_15,
)?;
validate_optional_solar_angle("tilt", self.tilt, 0.0..=90.0)?;
validate_optional_solar_angle("azimuth", self.azimuth, -180.0..=180.0)?;
for var in &self.hourly {
var.validate_weather_request()?;
}
for var in &self.current {
var.validate_weather_request()?;
}
for var in &self.minutely_15 {
var.validate_weather_request()?;
}
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)),
("past_minutely_15", is_non_zero_u16(self.past_minutely_15)),
(
"forecast_minutely_15",
is_non_zero_u16(self.forecast_minutely_15),
),
],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
CellSelection, Minutely15Var, PrecipitationUnit, PressureLevel, SoilMoistureDepth,
TemperatureUnit, TimeFormat, Timezone, TowerLevel, WindSpeedUnit,
};
use time::macros::{date, datetime};
#[test]
fn build_forecast_url_with_typed_variables() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([
HourlyVar::Temperature2m,
HourlyVar::WindSpeedAtTower(TowerLevel::M10),
HourlyVar::TemperatureAtPressure(PressureLevel::Hpa850),
])
.temperature_unit(TemperatureUnit::Fahrenheit)
.forecast_days(1)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m%2Cwind_speed_10m%2Ctemperature_850hPa&temperature_unit=fahrenheit&forecast_days=1"
);
}
#[test]
fn build_forecast_batch_url_with_multiple_locations() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast_batch([(52.52, 13.41), (47.3769, 8.5417)])
.hourly([HourlyVar::Temperature2m])
.forecast_days(1)
.timezone(Timezone::Iana("Europe/Zurich".to_owned()))
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52%2C47.3769&longitude=13.41%2C8.5417&hourly=temperature_2m&timezone=Europe%2FZurich&forecast_days=1"
);
}
#[test]
fn rejects_empty_forecast_batch_locations() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let err = client
.forecast_batch([])
.hourly([HourlyVar::Temperature2m])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "locations",
..
}
));
}
#[test]
fn variable_setters_accumulate_values() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.hourly([HourlyVar::Precipitation])
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m%2Cprecipitation"
);
}
#[test]
fn build_forecast_url_with_untyped_weather_model() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.models([WeatherModel::other(String::from("icon_d2"))])
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m&models=icon_d2"
);
}
#[test]
fn build_forecast_url_with_optional_query_parameters() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.api_key("secret")
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.daily([DailyVar::WeatherCode])
.current([CurrentVar::Temperature2m])
.wind_speed_unit(WindSpeedUnit::Ms)
.precipitation_unit(PrecipitationUnit::Inch)
.timeformat(TimeFormat::UnixTime)
.timezone(Timezone::Iana("Europe/Zurich".to_owned()))
.past_days(2)
.forecast_days(3)
.past_hours(4)
.forecast_hours(5)
.past_minutely_15(6)
.forecast_minutely_15(7)
.cell_selection(CellSelection::Land)
.elevation(490.0)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&daily=weather_code¤t=temperature_2m&wind_speed_unit=ms&precipitation_unit=inch&timeformat=unixtime&timezone=Europe%2FZurich&past_days=2&forecast_days=3&past_hours=4&forecast_hours=5&past_minutely_15=6&forecast_minutely_15=7&cell_selection=land&elevation=490&apikey=secret"
);
}
#[test]
fn rejects_unsupported_temperature_tower_level() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::TemperatureAtTower(TowerLevel::M10)])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "hourly.temperature_at_tower",
..
}
));
}
#[test]
fn rejects_unsupported_current_temperature_tower_level() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.current([CurrentVar::TemperatureAtTower(TowerLevel::M10)])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "hourly.temperature_at_tower",
..
}
));
}
#[test]
fn build_forecast_url_with_minutely_15_variables() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.minutely_15([
Minutely15Var::Temperature2m,
Minutely15Var::LightningPotential,
])
.forecast_minutely_15(8)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&minutely_15=temperature_2m%2Clightning_potential&forecast_minutely_15=8"
);
}
#[test]
fn rejects_unsupported_minutely_15_tower_level() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.minutely_15([Minutely15Var::WindSpeedAtTower(TowerLevel::M120)])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "minutely_15.tower_level",
..
}
));
}
#[test]
fn rejects_empty_variable_set() {
let client = Client::new();
let err = client.forecast(52.52, 13.41).build_url().unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "variables",
..
}
));
}
#[test]
fn build_forecast_url_with_date_range() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.date_range(date!(2026 - 04 - 20), date!(2026 - 04 - 24))
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m&start_date=2026-04-20&end_date=2026-04-24"
);
}
#[test]
fn build_forecast_url_with_hour_range() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.hour_range(datetime!(2026-04-20 06:00), datetime!(2026-04-20 18:00))
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m&start_hour=2026-04-20T06%3A00&end_hour=2026-04-20T18%3A00"
);
}
#[test]
fn rejects_mutually_exclusive_range_and_relative_window() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.date_range(date!(2026 - 04 - 20), date!(2026 - 04 - 24))
.forecast_days(1)
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::MutuallyExclusive {
first: "date_range",
second: "forecast_days"
}
));
}
#[test]
fn rejects_reversed_hour_range() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.hour_range(datetime!(2026-04-20 18:00), datetime!(2026-04-20 06:00))
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "hour_range",
..
}
));
}
#[test]
fn preserves_pathful_base_url() {
let client = Client::builder()
.forecast_base_url("https://example.com/openmeteo")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.forecast_days(1)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/openmeteo/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m&forecast_days=1"
);
}
#[test]
fn preserves_base_url_query_parameters() {
let client = Client::builder()
.forecast_base_url("https://example.com/openmeteo?token=abc")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/openmeteo/v1/forecast?token=abc&latitude=52.52&longitude=13.41&hourly=temperature_2m"
);
}
#[test]
fn forecast_days_zero_is_emitted() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.forecast_days(0)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m&forecast_days=0"
);
}
#[test]
fn treats_zero_relative_hour_and_past_day_windows_as_noops() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.past_days(0)
.past_hours(0)
.forecast_hours(0)
.past_minutely_15(0)
.forecast_minutely_15(0)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m"
);
}
#[test]
fn zero_relative_windows_do_not_conflict_with_date_range() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.minutely_15([Minutely15Var::Temperature2m])
.date_range(date!(2026 - 04 - 20), date!(2026 - 04 - 20))
.past_hours(0)
.forecast_hours(0)
.past_minutely_15(0)
.forecast_minutely_15(0)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&minutely_15=temperature_2m&start_date=2026-04-20&end_date=2026-04-20"
);
}
#[test]
fn rejects_forecast_hours_above_api_bound() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.forecast_hours(385)
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "forecast_hours",
..
}
));
}
#[test]
fn rejects_forecast_days_above_api_bound() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.forecast_days(17)
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "forecast_days",
..
}
));
}
#[test]
fn rejects_past_days_above_api_bound() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.past_days(93)
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "past_days",
..
}
));
}
#[test]
fn build_forecast_url_with_solar_orientation() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::GlobalTiltedIrradiance])
.tilt(35.0)
.azimuth(f32::NAN)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=global_tilted_irradiance&tilt=35&azimuth=nan"
);
}
#[test]
fn build_forecast_url_with_elevation_nan_sentinel() {
let client = Client::builder()
.forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::Temperature2m])
.elevation(f64::NAN)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m&elevation=nan"
);
}
#[test]
fn rejects_out_of_range_solar_orientation() {
let client = Client::new();
let tilt_err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::GlobalTiltedIrradiance])
.tilt(91.0)
.build_url()
.unwrap_err();
let azimuth_err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::GlobalTiltedIrradiance])
.azimuth(181.0)
.build_url()
.unwrap_err();
assert!(matches!(
tilt_err,
Error::InvalidParam { field: "tilt", .. }
));
assert!(matches!(
azimuth_err,
Error::InvalidParam {
field: "azimuth",
..
}
));
}
#[test]
fn rejects_forecast_minutely_15_above_api_bound() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.minutely_15([Minutely15Var::Temperature2m])
.forecast_minutely_15(1537)
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "forecast_minutely_15",
..
}
));
}
#[test]
fn rejects_reversed_soil_moisture_depth_range() {
let client = Client::new();
let err = client
.forecast(52.52, 13.41)
.hourly([HourlyVar::SoilMoisture {
from: SoilMoistureDepth::Cm27,
to: SoilMoistureDepth::Cm3,
}])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "hourly.soil_moisture",
..
}
));
}
}