Skip to main content

openmeteo_rs/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use reqwest::Url;
5use time::Date;
6
7use crate::endpoints::air_quality::AirQualityBuilder;
8use crate::endpoints::archive::ArchiveBuilder;
9use crate::endpoints::climate::ClimateBuilder;
10use crate::endpoints::ensemble::EnsembleBuilder;
11use crate::endpoints::flood::FloodBuilder;
12use crate::endpoints::forecast::{ForecastBatchBuilder, ForecastBuilder};
13use crate::endpoints::geocoding::GeocodingBuilder;
14use crate::endpoints::historical_forecast::HistoricalForecastBuilder;
15use crate::endpoints::http::read_success_body;
16use crate::endpoints::marine::MarineBuilder;
17use crate::endpoints::previous_runs::PreviousRunsBuilder;
18use crate::endpoints::satellite_radiation::SatelliteRadiationBuilder;
19use crate::endpoints::seasonal::SeasonalBuilder;
20use crate::error::map_reqwest_error;
21use crate::{Error, Result};
22
23const DEFAULT_FORECAST_BASE: &str = "https://api.open-meteo.com";
24const DEFAULT_ARCHIVE_BASE: &str = "https://archive-api.open-meteo.com";
25const DEFAULT_HISTORICAL_FORECAST_BASE: &str = "https://historical-forecast-api.open-meteo.com";
26const DEFAULT_PREVIOUS_RUNS_BASE: &str = "https://previous-runs-api.open-meteo.com";
27const DEFAULT_ENSEMBLE_BASE: &str = "https://ensemble-api.open-meteo.com";
28const DEFAULT_SEASONAL_BASE: &str = "https://seasonal-api.open-meteo.com";
29const DEFAULT_CLIMATE_BASE: &str = "https://climate-api.open-meteo.com";
30const DEFAULT_SATELLITE_RADIATION_BASE: &str = "https://satellite-api.open-meteo.com";
31const DEFAULT_FLOOD_BASE: &str = "https://flood-api.open-meteo.com";
32const DEFAULT_MARINE_BASE: &str = "https://marine-api.open-meteo.com";
33const DEFAULT_AIR_QUALITY_BASE: &str = "https://air-quality-api.open-meteo.com";
34const DEFAULT_ELEVATION_BASE: &str = "https://api.open-meteo.com";
35const DEFAULT_GEOCODING_BASE: &str = "https://geocoding-api.open-meteo.com";
36const CUSTOMER_FORECAST_BASE: &str = "https://customer-api.open-meteo.com";
37const CUSTOMER_ARCHIVE_BASE: &str = "https://customer-archive-api.open-meteo.com";
38const CUSTOMER_HISTORICAL_FORECAST_BASE: &str =
39    "https://customer-historical-forecast-api.open-meteo.com";
40const CUSTOMER_ELEVATION_BASE: &str = "https://customer-api.open-meteo.com";
41const CUSTOMER_GEOCODING_BASE: &str = "https://customer-geocoding-api.open-meteo.com";
42const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
43
44/// Async Open-Meteo API client.
45#[derive(Debug, Clone)]
46pub struct Client {
47    pub(crate) http: reqwest::Client,
48    pub(crate) forecast_base: Url,
49    pub(crate) archive_base: Url,
50    pub(crate) historical_forecast_base: Url,
51    pub(crate) previous_runs_base: Url,
52    pub(crate) ensemble_base: Url,
53    pub(crate) seasonal_base: Url,
54    pub(crate) climate_base: Url,
55    pub(crate) satellite_radiation_base: Url,
56    pub(crate) flood_base: Url,
57    pub(crate) marine_base: Url,
58    pub(crate) air_quality_base: Url,
59    pub(crate) elevation_base: Url,
60    pub(crate) geocoding_base: Url,
61    pub(crate) api_key: Option<Arc<str>>,
62    pub(crate) timeout: Duration,
63}
64
65impl Client {
66    /// Constructs a client using the public free-tier Open-Meteo forecast API.
67    ///
68    /// Panics only if the crate's built-in default client configuration is
69    /// invalid or the internal HTTP client cannot be constructed.
70    pub fn new() -> Self {
71        Self::builder()
72            .build()
73            .expect("default Open-Meteo client configuration is valid")
74    }
75
76    /// Starts a custom client builder.
77    pub fn builder() -> ClientBuilder {
78        ClientBuilder::default()
79    }
80
81    /// Starts a general forecast request for one coordinate.
82    pub fn forecast(&self, latitude: f64, longitude: f64) -> ForecastBuilder<'_> {
83        ForecastBuilder::new(self, latitude, longitude)
84    }
85
86    /// Starts a general forecast request for multiple coordinates.
87    pub fn forecast_batch<I>(&self, locations: I) -> ForecastBatchBuilder<'_>
88    where
89        I: IntoIterator<Item = (f64, f64)>,
90    {
91        ForecastBatchBuilder::new(self, locations)
92    }
93
94    /// Alias for [`Self::forecast_batch`].
95    pub fn forecast_many<I>(&self, locations: I) -> ForecastBatchBuilder<'_>
96    where
97        I: IntoIterator<Item = (f64, f64)>,
98    {
99        self.forecast_batch(locations)
100    }
101
102    /// Starts an archive request for one coordinate and an inclusive date range.
103    pub fn archive(
104        &self,
105        latitude: f64,
106        longitude: f64,
107        start_date: Date,
108        end_date: Date,
109    ) -> ArchiveBuilder<'_> {
110        ArchiveBuilder::new(self, latitude, longitude, start_date, end_date)
111    }
112
113    /// Starts a historical forecast request for one coordinate and an inclusive date range.
114    pub fn historical_forecast(
115        &self,
116        latitude: f64,
117        longitude: f64,
118        start_date: Date,
119        end_date: Date,
120    ) -> HistoricalForecastBuilder<'_> {
121        HistoricalForecastBuilder::new(self, latitude, longitude, start_date, end_date)
122    }
123
124    /// Starts a previous model runs request for one coordinate.
125    pub fn previous_runs(&self, latitude: f64, longitude: f64) -> PreviousRunsBuilder<'_> {
126        PreviousRunsBuilder::new(self, latitude, longitude)
127    }
128
129    /// Starts an ensemble forecast request for one coordinate.
130    pub fn ensemble(&self, latitude: f64, longitude: f64) -> EnsembleBuilder<'_> {
131        EnsembleBuilder::new(self, latitude, longitude)
132    }
133
134    /// Starts a seasonal forecast request for one coordinate.
135    pub fn seasonal(&self, latitude: f64, longitude: f64) -> SeasonalBuilder<'_> {
136        SeasonalBuilder::new(self, latitude, longitude)
137    }
138
139    /// Starts a climate projection request for one coordinate and an inclusive date range.
140    pub fn climate(
141        &self,
142        latitude: f64,
143        longitude: f64,
144        start_date: Date,
145        end_date: Date,
146    ) -> ClimateBuilder<'_> {
147        ClimateBuilder::new(self, latitude, longitude, start_date, end_date)
148    }
149
150    /// Starts a satellite-radiation request for one coordinate.
151    pub fn satellite_radiation(
152        &self,
153        latitude: f64,
154        longitude: f64,
155    ) -> SatelliteRadiationBuilder<'_> {
156        SatelliteRadiationBuilder::new(self, latitude, longitude)
157    }
158
159    /// Starts a flood forecast request for one coordinate.
160    pub fn flood(&self, latitude: f64, longitude: f64) -> FloodBuilder<'_> {
161        FloodBuilder::new(self, latitude, longitude)
162    }
163
164    /// Starts a marine forecast request for one coordinate.
165    pub fn marine(&self, latitude: f64, longitude: f64) -> MarineBuilder<'_> {
166        MarineBuilder::new(self, latitude, longitude)
167    }
168
169    /// Starts an air-quality request for one coordinate.
170    pub fn air_quality(&self, latitude: f64, longitude: f64) -> AirQualityBuilder<'_> {
171        AirQualityBuilder::new(self, latitude, longitude)
172    }
173
174    /// Starts a geocoding request by place name.
175    ///
176    /// ```
177    /// use openmeteo_rs::Client;
178    ///
179    /// # async fn example() -> openmeteo_rs::Result<()> {
180    /// let client = Client::new();
181    /// let locations = client
182    ///     .geocode("Zurich")
183    ///     .count(1)
184    ///     .language("en")
185    ///     .send()
186    ///     .await?;
187    ///
188    /// if let Some(location) = locations.first() {
189    ///     println!("{}, {}", location.latitude, location.longitude);
190    /// }
191    /// # Ok(())
192    /// # }
193    /// ```
194    pub fn geocode(&self, name: impl Into<String>) -> GeocodingBuilder<'_> {
195        GeocodingBuilder::new(self, name.into())
196    }
197
198    pub(crate) async fn execute(&self, request: reqwest::RequestBuilder) -> Result<Vec<u8>> {
199        let response = request
200            .send()
201            .await
202            .map_err(|err| map_reqwest_error(err, self.timeout))?;
203        read_success_body(response, self.timeout).await
204    }
205}
206
207impl Default for Client {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213/// Builder for [`Client`].
214#[derive(Debug, Default)]
215pub struct ClientBuilder {
216    forecast_base: Option<Url>,
217    archive_base: Option<Url>,
218    historical_forecast_base: Option<Url>,
219    previous_runs_base: Option<Url>,
220    ensemble_base: Option<Url>,
221    seasonal_base: Option<Url>,
222    climate_base: Option<Url>,
223    satellite_radiation_base: Option<Url>,
224    flood_base: Option<Url>,
225    marine_base: Option<Url>,
226    air_quality_base: Option<Url>,
227    elevation_base: Option<Url>,
228    geocoding_base: Option<Url>,
229    api_key: Option<String>,
230    user_agent: Option<String>,
231    timeout: Option<Duration>,
232}
233
234impl ClientBuilder {
235    /// Sets an Open-Meteo commercial API key.
236    ///
237    /// If no endpoint base URLs are explicitly configured, this switches
238    /// requests to Open-Meteo's commercial hosts and appends `apikey=`.
239    #[must_use]
240    pub fn api_key(mut self, key: impl Into<String>) -> Self {
241        self.api_key = Some(key.into());
242        self
243    }
244
245    /// Sets the user-agent header used by the internal HTTP client.
246    #[must_use]
247    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
248        self.user_agent = Some(user_agent.into());
249        self
250    }
251
252    /// Sets the HTTP request timeout.
253    #[must_use]
254    pub fn timeout(mut self, timeout: Duration) -> Self {
255        self.timeout = Some(timeout);
256        self
257    }
258
259    /// Overrides the forecast API base URL.
260    ///
261    /// The URL path is preserved when forecast requests are built. Query
262    /// parameters on the base URL are also preserved and additional request
263    /// parameters are appended after them.
264    pub fn forecast_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
265        self.forecast_base = Some(parse_url("forecast_base_url", url.as_ref())?);
266        Ok(self)
267    }
268
269    /// Overrides the archive API base URL.
270    ///
271    /// The URL path is preserved when archive requests are built. Query
272    /// parameters on the base URL are also preserved and additional request
273    /// parameters are appended after them.
274    pub fn archive_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
275        self.archive_base = Some(parse_url("archive_base_url", url.as_ref())?);
276        Ok(self)
277    }
278
279    /// Overrides the historical forecast API base URL.
280    ///
281    /// The URL path is preserved when historical forecast requests are built.
282    /// Query parameters on the base URL are also preserved and additional
283    /// request parameters are appended after them.
284    pub fn historical_forecast_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
285        self.historical_forecast_base =
286            Some(parse_url("historical_forecast_base_url", url.as_ref())?);
287        Ok(self)
288    }
289
290    /// Overrides the previous-runs API base URL.
291    ///
292    /// The URL path is preserved when previous-runs requests are built. Query
293    /// parameters on the base URL are also preserved and additional request
294    /// parameters are appended after them.
295    pub fn previous_runs_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
296        self.previous_runs_base = Some(parse_url("previous_runs_base_url", url.as_ref())?);
297        Ok(self)
298    }
299
300    /// Overrides the ensemble API base URL.
301    ///
302    /// The URL path is preserved when ensemble requests are built. Query
303    /// parameters on the base URL are also preserved and additional request
304    /// parameters are appended after them.
305    pub fn ensemble_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
306        self.ensemble_base = Some(parse_url("ensemble_base_url", url.as_ref())?);
307        Ok(self)
308    }
309
310    /// Overrides the seasonal API base URL.
311    ///
312    /// The URL path is preserved when seasonal requests are built. Query
313    /// parameters on the base URL are also preserved and additional request
314    /// parameters are appended after them.
315    pub fn seasonal_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
316        self.seasonal_base = Some(parse_url("seasonal_base_url", url.as_ref())?);
317        Ok(self)
318    }
319
320    /// Overrides the climate API base URL.
321    ///
322    /// The URL path is preserved when climate requests are built. Query
323    /// parameters on the base URL are also preserved and additional request
324    /// parameters are appended after them.
325    pub fn climate_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
326        self.climate_base = Some(parse_url("climate_base_url", url.as_ref())?);
327        Ok(self)
328    }
329
330    /// Overrides the satellite-radiation API base URL.
331    ///
332    /// The URL path is preserved when satellite-radiation requests are built.
333    /// Query parameters on the base URL are also preserved and additional
334    /// request parameters are appended after them.
335    pub fn satellite_radiation_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
336        self.satellite_radiation_base =
337            Some(parse_url("satellite_radiation_base_url", url.as_ref())?);
338        Ok(self)
339    }
340
341    /// Overrides the flood API base URL.
342    ///
343    /// The URL path is preserved when flood requests are built. Query
344    /// parameters on the base URL are also preserved and additional request
345    /// parameters are appended after them.
346    pub fn flood_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
347        self.flood_base = Some(parse_url("flood_base_url", url.as_ref())?);
348        Ok(self)
349    }
350
351    /// Overrides the marine API base URL.
352    ///
353    /// The URL path is preserved when marine requests are built. Query
354    /// parameters on the base URL are also preserved and additional request
355    /// parameters are appended after them.
356    pub fn marine_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
357        self.marine_base = Some(parse_url("marine_base_url", url.as_ref())?);
358        Ok(self)
359    }
360
361    /// Overrides the air-quality API base URL.
362    ///
363    /// The URL path is preserved when air-quality requests are built. Query
364    /// parameters on the base URL are also preserved and additional request
365    /// parameters are appended after them.
366    pub fn air_quality_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
367        self.air_quality_base = Some(parse_url("air_quality_base_url", url.as_ref())?);
368        Ok(self)
369    }
370
371    /// Overrides the elevation API base URL.
372    ///
373    /// The URL path is preserved when elevation requests are built. Query
374    /// parameters on the base URL are also preserved and additional request
375    /// parameters are appended after them.
376    pub fn elevation_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
377        self.elevation_base = Some(parse_url("elevation_base_url", url.as_ref())?);
378        Ok(self)
379    }
380
381    /// Overrides the geocoding API base URL.
382    ///
383    /// The URL path is preserved when geocoding requests are built. Query
384    /// parameters on the base URL are also preserved and additional request
385    /// parameters are appended after them.
386    pub fn geocoding_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
387        self.geocoding_base = Some(parse_url("geocoding_base_url", url.as_ref())?);
388        Ok(self)
389    }
390
391    /// Builds the client.
392    pub fn build(self) -> Result<Client> {
393        let forecast_base = self.forecast_base.unwrap_or(parse_default_url(
394            "forecast_base_url",
395            self.api_key.is_some(),
396            DEFAULT_FORECAST_BASE,
397            CUSTOMER_FORECAST_BASE,
398        )?);
399        let archive_base = self.archive_base.unwrap_or(parse_default_url(
400            "archive_base_url",
401            self.api_key.is_some(),
402            DEFAULT_ARCHIVE_BASE,
403            CUSTOMER_ARCHIVE_BASE,
404        )?);
405        let historical_forecast_base = self.historical_forecast_base.unwrap_or(parse_default_url(
406            "historical_forecast_base_url",
407            self.api_key.is_some(),
408            DEFAULT_HISTORICAL_FORECAST_BASE,
409            CUSTOMER_HISTORICAL_FORECAST_BASE,
410        )?);
411        let previous_runs_base = self.previous_runs_base.unwrap_or(parse_url(
412            "previous_runs_base_url",
413            DEFAULT_PREVIOUS_RUNS_BASE,
414        )?);
415        let ensemble_base = self
416            .ensemble_base
417            .unwrap_or(parse_url("ensemble_base_url", DEFAULT_ENSEMBLE_BASE)?);
418        let seasonal_base = self
419            .seasonal_base
420            .unwrap_or(parse_url("seasonal_base_url", DEFAULT_SEASONAL_BASE)?);
421        let climate_base = self
422            .climate_base
423            .unwrap_or(parse_url("climate_base_url", DEFAULT_CLIMATE_BASE)?);
424        let satellite_radiation_base = self.satellite_radiation_base.unwrap_or(parse_url(
425            "satellite_radiation_base_url",
426            DEFAULT_SATELLITE_RADIATION_BASE,
427        )?);
428        let flood_base = self
429            .flood_base
430            .unwrap_or(parse_url("flood_base_url", DEFAULT_FLOOD_BASE)?);
431        let marine_base = self
432            .marine_base
433            .unwrap_or(parse_url("marine_base_url", DEFAULT_MARINE_BASE)?);
434        let air_quality_base = self
435            .air_quality_base
436            .unwrap_or(parse_url("air_quality_base_url", DEFAULT_AIR_QUALITY_BASE)?);
437        let elevation_base = self.elevation_base.unwrap_or(parse_default_url(
438            "elevation_base_url",
439            self.api_key.is_some(),
440            DEFAULT_ELEVATION_BASE,
441            CUSTOMER_ELEVATION_BASE,
442        )?);
443        let geocoding_base = self.geocoding_base.unwrap_or(parse_default_url(
444            "geocoding_base_url",
445            self.api_key.is_some(),
446            DEFAULT_GEOCODING_BASE,
447            CUSTOMER_GEOCODING_BASE,
448        )?);
449
450        let user_agent = self
451            .user_agent
452            .unwrap_or_else(|| format!("openmeteo-rs/{}", env!("CARGO_PKG_VERSION")));
453        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
454
455        let http = reqwest::Client::builder()
456            .user_agent(user_agent)
457            .timeout(timeout)
458            .build()?;
459
460        Ok(Client {
461            http,
462            forecast_base,
463            archive_base,
464            historical_forecast_base,
465            previous_runs_base,
466            ensemble_base,
467            seasonal_base,
468            climate_base,
469            satellite_radiation_base,
470            flood_base,
471            marine_base,
472            air_quality_base,
473            elevation_base,
474            geocoding_base,
475            api_key: self.api_key.map(Arc::from),
476            timeout,
477        })
478    }
479}
480
481fn parse_default_url(
482    field: &'static str,
483    use_customer_base: bool,
484    public_base: &str,
485    customer_base: &str,
486) -> Result<Url> {
487    parse_url(
488        field,
489        if use_customer_base {
490            customer_base
491        } else {
492            public_base
493        },
494    )
495}
496
497fn parse_url(field: &'static str, value: &str) -> Result<Url> {
498    let mut url = Url::parse(value).map_err(|err| Error::InvalidParam {
499        field,
500        reason: err.to_string(),
501    })?;
502
503    if !url.path().ends_with('/') {
504        let mut path = url.path().to_owned();
505        path.push('/');
506        url.set_path(&path);
507    }
508
509    Ok(url)
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn api_key_switches_default_endpoint_bases_to_customer_hosts() {
518        let client = Client::builder().api_key("secret").build().unwrap();
519
520        assert_eq!(
521            client.forecast_base.as_str(),
522            "https://customer-api.open-meteo.com/"
523        );
524        assert_eq!(
525            client.archive_base.as_str(),
526            "https://customer-archive-api.open-meteo.com/"
527        );
528        assert_eq!(
529            client.historical_forecast_base.as_str(),
530            "https://customer-historical-forecast-api.open-meteo.com/"
531        );
532        assert_eq!(
533            client.previous_runs_base.as_str(),
534            "https://previous-runs-api.open-meteo.com/"
535        );
536        assert_eq!(
537            client.ensemble_base.as_str(),
538            "https://ensemble-api.open-meteo.com/"
539        );
540        assert_eq!(
541            client.seasonal_base.as_str(),
542            "https://seasonal-api.open-meteo.com/"
543        );
544        assert_eq!(
545            client.climate_base.as_str(),
546            "https://climate-api.open-meteo.com/"
547        );
548        assert_eq!(
549            client.satellite_radiation_base.as_str(),
550            "https://satellite-api.open-meteo.com/"
551        );
552        assert_eq!(
553            client.flood_base.as_str(),
554            "https://flood-api.open-meteo.com/"
555        );
556        assert_eq!(
557            client.marine_base.as_str(),
558            "https://marine-api.open-meteo.com/"
559        );
560        assert_eq!(
561            client.air_quality_base.as_str(),
562            "https://air-quality-api.open-meteo.com/"
563        );
564        assert_eq!(
565            client.elevation_base.as_str(),
566            "https://customer-api.open-meteo.com/"
567        );
568        assert_eq!(
569            client.geocoding_base.as_str(),
570            "https://customer-geocoding-api.open-meteo.com/"
571        );
572    }
573
574    #[test]
575    fn explicit_endpoint_bases_override_customer_defaults() {
576        let client = Client::builder()
577            .api_key("secret")
578            .forecast_base_url("https://forecast.example")
579            .unwrap()
580            .archive_base_url("https://archive.example")
581            .unwrap()
582            .historical_forecast_base_url("https://historical.example")
583            .unwrap()
584            .previous_runs_base_url("https://previous.example")
585            .unwrap()
586            .ensemble_base_url("https://ensemble.example")
587            .unwrap()
588            .seasonal_base_url("https://seasonal.example")
589            .unwrap()
590            .climate_base_url("https://climate.example")
591            .unwrap()
592            .satellite_radiation_base_url("https://satellite.example")
593            .unwrap()
594            .flood_base_url("https://flood.example")
595            .unwrap()
596            .marine_base_url("https://marine.example")
597            .unwrap()
598            .air_quality_base_url("https://air.example")
599            .unwrap()
600            .elevation_base_url("https://elevation.example")
601            .unwrap()
602            .geocoding_base_url("https://geocoding.example")
603            .unwrap()
604            .build()
605            .unwrap();
606
607        assert_eq!(client.forecast_base.as_str(), "https://forecast.example/");
608        assert_eq!(client.archive_base.as_str(), "https://archive.example/");
609        assert_eq!(
610            client.historical_forecast_base.as_str(),
611            "https://historical.example/"
612        );
613        assert_eq!(
614            client.previous_runs_base.as_str(),
615            "https://previous.example/"
616        );
617        assert_eq!(client.ensemble_base.as_str(), "https://ensemble.example/");
618        assert_eq!(client.seasonal_base.as_str(), "https://seasonal.example/");
619        assert_eq!(client.climate_base.as_str(), "https://climate.example/");
620        assert_eq!(
621            client.satellite_radiation_base.as_str(),
622            "https://satellite.example/"
623        );
624        assert_eq!(client.flood_base.as_str(), "https://flood.example/");
625        assert_eq!(client.marine_base.as_str(), "https://marine.example/");
626        assert_eq!(client.air_quality_base.as_str(), "https://air.example/");
627        assert_eq!(client.elevation_base.as_str(), "https://elevation.example/");
628        assert_eq!(client.geocoding_base.as_str(), "https://geocoding.example/");
629    }
630}