mod epoch;
#[cfg(test)]
mod test;
use std::time::Duration;
use epoch::EpochMiddleware;
use itertools::Itertools;
use log::debug;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use tokio::time::interval;
use tokio_stream::{wrappers::IntervalStream, Stream, StreamExt};
use serde::de::DeserializeOwned;
use crate::{
alert::Alert,
assoc::{Association, Region, RegionResponse},
spot::Spot,
summit::{Summit, SummitCode},
Band, Mode,
};
pub const BASE_PATH: &str = "https://api-db2.sota.org.uk/api";
pub struct Client {
base_path: String,
client: ClientWithMiddleware,
}
#[allow(unstable_name_collisions)]
fn join_args<T: ToString>(items: &[T]) -> String {
items
.into_iter()
.map(ToString::to_string)
.intersperse(",".into())
.collect()
}
fn build_client(user_agent: &str) -> ClientWithMiddleware {
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
ClientBuilder::new(
reqwest::ClientBuilder::new()
.user_agent(user_agent)
.timeout(Duration::from_secs(10))
.build()
.unwrap(),
)
.with(EpochMiddleware::new())
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build()
}
impl Client {
pub fn new(user_agent: &str) -> Self {
Client::new_with_base(BASE_PATH, user_agent)
}
pub fn new_with_base(base_path: &str, user_agent: &str) -> Self {
Self {
base_path: base_path.into(),
client: build_client(user_agent),
}
}
async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, APIError> {
let url = format!("{}/{}", self.base_path, endpoint.to_lowercase());
debug!("GET {}", &url);
self.client
.get(url)
.send()
.await?
.json()
.await
.map_err(APIError::from)
}
async fn get_with_epoch<T: DeserializeOwned>(
&self,
endpoint: &str,
) -> Result<Vec<T>, APIError> {
match self.get(endpoint).await {
Ok(res) => Ok(res),
Err(e) => {
if e.to_string().contains("Current data still fresh") {
debug!("GET {endpoint} cancelled: current data still fresh");
Ok(Vec::new())
} else {
Err(e)
}
}
}
}
pub async fn all_alerts(&self) -> Result<Vec<Alert>, APIError> {
self.alerts(&[Band::All], &[Mode::All]).await
}
pub async fn all_spots(&self, hours: u8) -> Result<Vec<Spot>, APIError> {
self.spots(-1 * isize::from(hours), &[Band::All], &[Mode::All])
.await
}
pub async fn spots(
&self,
limit: isize,
bands: &[Band],
modes: &[Mode],
) -> Result<Vec<Spot>, APIError> {
self.get_with_epoch(&format!(
"spots/{limit}/{}/{}",
join_args(bands),
join_args(modes)
))
.await
}
pub async fn alerts(&self, bands: &[Band], modes: &[Mode]) -> Result<Vec<Alert>, APIError> {
self.get_with_epoch(&format!(
"alerts/12/{}/{}",
join_args(bands),
join_args(modes)
))
.await
}
pub async fn association(&self, assoc: &str) -> Result<Association, APIError> {
self.get(&format!("associations/{assoc}")).await
}
pub async fn region(&self, assoc: &str, region: &str) -> Result<Region, APIError> {
let resp: RegionResponse = self.get(&format!("regions/{assoc}/{region}")).await?;
Ok(resp.region)
}
pub async fn summit(&self, code: &SummitCode) -> Result<Summit, APIError> {
self.get(&format!(
"summits/{}/{}",
code.association,
code.short_code()
))
.await
}
pub async fn all_alerts_every_minute(
&self,
) -> impl Stream<Item = Result<Vec<Alert>, APIError>> + use<'_> {
IntervalStream::new(interval(Duration::from_secs(60))).then(|_| self.all_alerts())
}
pub async fn all_spots_every_minute(
&self,
) -> impl Stream<Item = Result<Vec<Spot>, APIError>> + use<'_> {
IntervalStream::new(interval(Duration::from_secs(60))).then(|_| self.all_spots(1))
}
}
#[allow(missing_docs, clippy::large_enum_variant)]
#[derive(thiserror::Error, Debug)]
pub enum APIError {
#[error(transparent)]
RequestError(#[from] reqwest::Error),
#[error(transparent)]
RequestMiddlewareError(#[from] reqwest_middleware::Error),
#[error(transparent)]
DeserializationError(#[from] std::io::Error),
#[error(transparent)]
MaidenheadError(#[from] maidenhead::MHError),
}