bom_buddy/
client.rs

1use crate::daily::{DailyForecast, DailyResponse};
2use crate::hourly::{HourlyForecast, HourlyResponse};
3use crate::location::{Location, LocationData, LocationResponse, SearchResponse, SearchResult};
4use crate::observation::{
5    Observation, ObservationResponse, PastObservationData, PastObservationsResponse,
6};
7use crate::warning::{Warning, WarningResponse};
8use crate::weather::{Weather, WeatherOptions};
9use anyhow::anyhow;
10use anyhow::Result;
11use chrono::Duration;
12use chrono::Utc;
13use serde::{Deserialize, Serialize};
14use serde_with::DurationSeconds;
15use std::collections::VecDeque;
16use std::thread::sleep;
17use tracing::{debug, error, trace};
18use ureq::{Agent, AgentBuilder, Error, Response};
19
20const URL_BASE: &str = "https://api.weather.bom.gov.au/v1/locations";
21const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
22    AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
23
24#[serde_with::serde_as]
25#[derive(Clone, Debug, Deserialize, Serialize)]
26pub struct ClientOptions {
27    pub retry_limit: u64,
28    #[serde_as(as = "DurationSeconds<i64>")]
29    pub retry_delay: Duration,
30    #[serde_as(as = "DurationSeconds<i64>")]
31    pub timeout: Duration,
32    #[serde_as(as = "DurationSeconds<i64>")]
33    pub timeout_connect: Duration,
34    pub user_agent: String,
35}
36
37impl Default for ClientOptions {
38    fn default() -> Self {
39        Self {
40            retry_limit: 5,
41            retry_delay: Duration::seconds(7),
42            timeout: Duration::seconds(7),
43            timeout_connect: Duration::seconds(30),
44            user_agent: USER_AGENT.to_string(),
45        }
46    }
47}
48
49#[derive(Debug)]
50pub struct Client {
51    client: Agent,
52    opts: ClientOptions,
53}
54
55impl Client {
56    pub fn new(opts: ClientOptions) -> Client {
57        let client = AgentBuilder::new()
58            .timeout_read(opts.timeout.to_std().unwrap())
59            .timeout_write(opts.timeout.to_std().unwrap())
60            .timeout_connect(opts.timeout_connect.to_std().unwrap())
61            .user_agent(&opts.user_agent)
62            .build();
63        Client { client, opts }
64    }
65
66    fn get(&self, url: &str) -> Result<Response> {
67        debug!("Fetching {url}");
68        let mut attemps = 0;
69        while attemps < self.opts.retry_limit {
70            let mut retry_delay = self.opts.retry_delay;
71            match self.client.get(url).call() {
72                Ok(response) => {
73                    return Ok(response);
74                }
75                Err(Error::Status(code, response)) => match code {
76                    503 | 429 | 408 => {
77                        if let Some(header) = response.header("retry-after") {
78                            retry_delay = Duration::seconds(header.parse()?);
79                        }
80                        error!("{} for {}", code, url);
81                        attemps += 1;
82                    }
83                    _ => {
84                        let error = response.into_string()?;
85                        error!("{code}: {error}");
86                        return Err(anyhow!("{error}"));
87                    }
88                },
89                Err(err) => {
90                    let message = err.into_transport().unwrap().to_string();
91                    error!("{message}");
92                    attemps += 1;
93                }
94            }
95            debug!("Retrying in {} seconds", retry_delay.num_seconds());
96            sleep(retry_delay.to_std()?);
97        }
98        Err(anyhow!("Retry limit exceeded"))
99    }
100
101    fn get_string(&self, url: &str) -> Result<String> {
102        let response = self.get(url)?;
103        let string = response.into_string()?;
104        trace!("{}", &string);
105        Ok(string)
106    }
107
108    fn get_json(&self, url: &str) -> Result<serde_json::Value> {
109        let string = self.get_string(url)?;
110        let value = match serde_json::from_str(&string) {
111            Ok(json) => json,
112            Err(e) => {
113                debug!("{:?}", &string);
114                return Err(anyhow!("Unable to decode JSON. {e}"));
115            }
116        };
117        Ok(value)
118    }
119
120    pub fn search(&self, term: &str) -> Result<Vec<SearchResult>> {
121        let url = format!("{URL_BASE}?search={term}");
122        let response: SearchResponse = serde_json::from_value(self.get_json(&url)?)?;
123        debug!(
124            "Search term {} returned {} results.",
125            term,
126            response.data.len()
127        );
128        for result in &response.data {
129            debug!("{:#?}", result);
130        }
131        Ok(response.data)
132    }
133
134    // Search results contain a 7 character geohash but other endpoints expect 6.
135    pub fn get_observation(&self, geohash: &str) -> Result<Option<Observation>> {
136        let url = format!("{URL_BASE}/{}/observations", &geohash[..6]);
137        let response: ObservationResponse = serde_json::from_value(self.get_json(&url)?)?;
138        Ok(response.into())
139    }
140
141    pub fn get_daily(&self, geohash: &str) -> Result<DailyForecast> {
142        let url = format!("{URL_BASE}/{}/forecasts/daily", &geohash[..6]);
143        let response: DailyResponse = serde_json::from_value(self.get_json(&url)?)?;
144        Ok(response.into())
145    }
146
147    pub fn get_hourly(&self, geohash: &str) -> Result<HourlyForecast> {
148        let url = format!("{URL_BASE}/{}/forecasts/hourly", &geohash[..6]);
149        let response: HourlyResponse = serde_json::from_value(self.get_json(&url)?)?;
150        Ok(response.into())
151    }
152
153    pub fn get_warnings(&self, geohash: &str) -> Result<Vec<Warning>> {
154        let url = format!("{URL_BASE}/{}/warnings", &geohash[..6]);
155        let response: WarningResponse = serde_json::from_value(self.get_json(&url)?)?;
156        Ok(response.data)
157    }
158
159    pub fn get_weather(&self, geohash: &str) -> Result<Weather> {
160        let now = Utc::now();
161        let opts = WeatherOptions::default();
162
163        let daily_forecast = self.get_daily(geohash)?;
164        let mut next_daily_due = if let Some(next) = daily_forecast.next_issue_time {
165            next + opts.update_delay
166        } else {
167            daily_forecast.issue_time + opts.daily_update_frequency + opts.update_delay
168        };
169        if now > next_daily_due {
170            next_daily_due = now + opts.daily_overdue_delay;
171        }
172
173        let hourly_forecast = self.get_hourly(geohash)?;
174        let mut next_hourly_due =
175            hourly_forecast.issue_time + opts.hourly_update_frequency + opts.update_delay;
176        if now > next_hourly_due {
177            next_hourly_due = now + opts.hourly_overdue_delay;
178        }
179
180        let mut observations = VecDeque::new();
181        let mut next_observation_due = now + opts.observation_missing_delay;
182        if let Some(observation) = self.get_observation(geohash)? {
183            next_observation_due =
184                observation.issue_time + opts.observation_update_frequency + opts.update_delay;
185            if now > next_observation_due {
186                next_observation_due = now + opts.observation_overdue_delay;
187            }
188            observations.push_front(observation);
189        }
190
191        let warnings = self.get_warnings(geohash)?;
192        let next_warning_due = now + opts.warning_update_frequency;
193
194        Ok(Weather {
195            geohash: geohash.to_string(),
196            observations,
197            daily_forecast,
198            hourly_forecast,
199            warnings,
200            next_observation_due,
201            next_daily_due,
202            next_hourly_due,
203            next_warning_due,
204            opts,
205        })
206    }
207
208    pub fn get_location(&self, geohash: &str) -> Result<LocationData> {
209        let url = format!("{URL_BASE}/{}", &geohash[..6]);
210        let response: LocationResponse = serde_json::from_value(self.get_json(&url)?)?;
211        Ok(response.data)
212    }
213
214    pub fn get_past_observations(&self, location: &Location) -> Result<Vec<PastObservationData>> {
215        let Some(station) = &location.station else {
216            return Err(anyhow!("{} doesn't have a weather station", location.id));
217        };
218        let Some(wmo_id) = station.wmo_id else {
219            return Err(anyhow!("{} doesn't have a WMO ID", station.name));
220        };
221        let code = location.state.get_product_code("60910");
222        let url = format!("https://reg.bom.gov.au/fwo/{code}/{code}.{wmo_id}.json");
223        let response: PastObservationsResponse = serde_json::from_value(self.get_json(&url)?)?;
224        Ok(response.observations.data)
225    }
226
227    pub fn get_station_list(&self) -> Result<String> {
228        let url = "https://reg.bom.gov.au/climate/data/lists_by_element/stations.txt";
229        self.get_string(url)
230    }
231}
232
233impl Default for Client {
234    fn default() -> Self {
235        let opts = ClientOptions::default();
236        Self::new(opts)
237    }
238}