sota 0.9.0

API crate for Summits on the Air
Documentation
//! HTTP interface to the API.

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,
};

/// The default base path of the API.
pub const BASE_PATH: &str = "https://api-db2.sota.org.uk/api";

/// Holds configurations for API access.
pub struct Client {
    /// The base path of the API.
    base_path: String,
    client: ClientWithMiddleware,
}

#[allow(unstable_name_collisions)]
fn join_args<T: ToString>(items: &[T]) -> String {
    items
        .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 {
    /// Instantiate a new client with [the default base path](BASE_PATH).
    pub fn new(user_agent: &str) -> Self {
        Client::new_with_base(BASE_PATH, user_agent)
    }

    /// Instantiate a new client with the given base path.
    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)
    }

    /// A bodge that prevents the epoch-unchanged "error" condition from propagating to clients.
    // This works well enough because the only epoch-using endpoints serve Vecs.
    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)
                }
            }
        }
    }

    /// Get all active alerts.
    pub async fn all_alerts(&self) -> Result<Vec<Alert>, APIError> {
        self.alerts(&[Band::All], &[Mode::All]).await
    }

    /// Get all spots within the past _n_ hours.
    pub async fn all_spots(&self, hours: u8) -> Result<Vec<Spot>, APIError> {
        self.spots(-isize::from(hours), &[Band::All], &[Mode::All])
            .await
    }

    /// Get spots. Negative limit means "within past _n_ hours".
    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
    }

    /// Get alerts.
    pub async fn alerts(&self, bands: &[Band], modes: &[Mode]) -> Result<Vec<Alert>, APIError> {
        // I never did figure out what the "12" stands for.
        self.get_with_epoch(&format!(
            "alerts/12/{}/{}",
            join_args(bands),
            join_args(modes)
        ))
        .await
    }

    /// Get data about a SOTA association and its regions.
    pub async fn association(&self, assoc: &str) -> Result<Association, APIError> {
        self.get(&format!("associations/{assoc}")).await
    }

    /// Get data about a single region of a SOTA association.
    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)
    }

    /// Get data about a summit.
    pub async fn summit(&self, code: &SummitCode) -> Result<Summit, APIError> {
        self.get(&format!(
            "summits/{}/{}",
            code.association,
            code.short_code()
        ))
        .await
    }

    /// Get alerts every minute.
    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())
    }

    /// Get the last hour's spots every minute.
    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),
}