sota 0.7.0-rc4

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

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

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 {
        Self {
            base_path: BASE_PATH.into(),
            client: build_client(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!("{}/{endpoint}", self.base_path);
        debug!("GET {}", &url);
        self.client
            .get(url)
            .send()
            .await?
            .json()
            .await
            .map_err(APIError::from)
    }

    /// Get all active alerts.
    pub async fn all_alerts(&self) -> Result<Vec<Alert>, APIError> {
        handle_epoch(self.get("alerts/12/all/all").await)
    }

    /// Get all spots within the past _n_ hours.
    pub async fn all_spots(&self, hours: u8) -> Result<Vec<Spot>, APIError> {
        handle_epoch(self.get(&format!("spots/-{hours}/all/all")).await)
    }

    // TODO: alerts/spots functions that take filters.

    /// 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 a summit by code.
    pub async fn summit(&self, assoc: &str, summit: &str) -> Result<Summit, APIError> {
        self.get(&format!("summits/{assoc}/{summit}")).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))
    }
}

/// Prevent unchanged-epoch "errors" from propagating to users.
///
/// [`reqwest_middleware::Error`] is the only way to abort sending a request,
/// yet it's senseless to treat this mundane occurrence as an error.
fn handle_epoch<T: DeserializeOwned>(result: Result<Vec<T>, APIError>) -> Result<Vec<T>, APIError> {
    match result {
        Ok(res) => Ok(res),
        Err(e) => {
            if e.to_string().contains("Current data still fresh") {
                debug!("Current data still fresh");
                Ok(Vec::new())
            } else {
                Err(e)
            }
        }
    }
}

#[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),
}