swgoh-gg 1.0.0

API Bindings for swgoh.gg
Documentation
#![doc = include_str!("../README.md")]

/// Artificial rate limit to comply with
/// the usage terms.
pub const RATE_LIMIT: f32 = 1.25; // only pub for documentation purposes

/// The env variable containing the access key
/// to create a [`Client`].
pub const SWGOH_GG_ACCESS_KEY: &str = "SWGOH_GG_ACCESS_KEY"; // only pub for documentation purposes

const API_HEADER_KEY: &str = "x-gg-bot-access";

pub mod types;

use {
    async_generic::async_generic,
    chrono::{DateTime, Utc},
    dashmap::DashMap,
    serde::{Serialize, de::DeserializeOwned},
    snafu::{ResultExt, Snafu},
    std::{
        fmt::{Debug, Display},
        sync::{Arc, Mutex, MutexGuard, PoisonError},
        time::Duration,
    },
    tracing::{debug, info, instrument, warn},
    types::{
        Ability, StatDefinition, datacron,
        gear::Gear,
        guild,
        player::Player,
        unit::{Character, Ship, Units},
    },
};

/// Error when sending a request to the API server
#[derive(Debug, Snafu, PartialEq, Clone)]
pub enum Error {
    /// Error when the [`SWGOH_GG_ACCESS_KEY`] env variable is not set.
    #[snafu(display("Missing API Key from \"{SWGOH_GG_ACCESS_KEY}\" env variable."))]
    MissingApiKey { source: std::env::VarError },

    /// Error when the request cannot be sent
    #[snafu(display("failed to send HTTP request"))]
    Request { err: String },

    /// Error when request Body is invalid JSON
    #[snafu(display("received malformed data"))]
    Json { err: String },

    /// Error when the rate limiter mutex deadlocks
    #[snafu(display("internal error, rate limiter got stuck:\n\t{err}"))]
    PoisonedLock { err: String },
}

macro_rules! impl_from_error {
    {
        $name:ident from [
            $(
                $from:ty as $variant:ident $(with <$lt:lifetime>)?
            ),* $(,)?
        ]
    } => {
        $(
            impl$(<$lt>)? From<$from> for $name {
                fn from(value: $from) -> Self {
                    Self::$variant {
                        err: value.to_string()
                    }
                }
            }
        )*
    };
}

impl_from_error! {
    Error from [
        reqwest::Error as Request,
        PoisonError<MutexGuard<'a, DateTime<Utc>>> as PoisonedLock with <'a>,
        serde_json::Error as Json,
    ]
}

/// A persistent client for querying the [`swgoh.gg`](https://swgoh.gg/) API.
///
/// Please note: subsequent requests are artificially
/// limited to an interval of `1.25s` to comply with
/// the access terms & limits.
pub struct Client {
    api_key: String,

    sync_client: reqwest::blocking::Client,
    async_client: reqwest::Client,

    previous_request: Arc<Mutex<DateTime<Utc>>>,

    cache: DashMap<String, Vec<u8>>,
}

impl Client {
    /// Create a new client using the API key from the
    /// env variable `SWGOH_GG_ACCESS_KEY`.
    #[instrument]
    pub fn new() -> Result<Self, Error> {
        let api_key = std::env::var(SWGOH_GG_ACCESS_KEY).context(MissingApiKeySnafu)?;

        Ok(Self {
            api_key,
            sync_client: reqwest::blocking::Client::new(),
            async_client: reqwest::Client::new(),
            previous_request: Arc::new(Mutex::new(DateTime::UNIX_EPOCH)),
            cache: DashMap::default(),
        })
    }

    /// Send a GET Request to the API on `endpoint`.
    #[async_generic]
    #[instrument(skip_all, fields(url))]
    fn request<P, T>(&self, endpoint: P) -> Result<T, Error>
    where
        P: Display + Debug,
        T: DeserializeOwned + Serialize,
    {
        let method = reqwest::Method::GET;
        let url = format!("https://swgoh.gg/api/{endpoint}");

        tracing::Span::current().record("url", &url);

        let response = if _sync {
            self.comply_with_rate_limit()?;

            debug!(%method, url, "sending request");
            self.sync_client
                .request(method, &url)
                .header(API_HEADER_KEY, &self.api_key)
                .send()
                .map_err(Error::from)?
                .json()
                .map_err(Into::into)
        } else {
            self.comply_with_rate_limit_async().await?;

            debug!(%method, url, "sending request");
            self.async_client
                .request(method, &url)
                .header(API_HEADER_KEY, &self.api_key)
                .send()
                .await
                .map_err(Error::from)?
                .json()
                .await
                .map_err(Into::into)
        };

        info!("response received");

        self.set_last_request_time()?;

        response
    }

    /// Makes sure time between is at least ~ `1.25s`
    #[async_generic]
    #[instrument(skip(self))]
    fn comply_with_rate_limit(&self) -> Result<(), Error> {
        let time_since_previous_request = Utc::now()
            .signed_duration_since(*self.previous_request.lock().map_err(|err| {
                Error::PoisonedLock {
                    err: err.to_string(),
                }
            })?)
            .as_seconds_f32();

        debug!(
            time = time_since_previous_request,
            "time elapsed since the last request"
        );

        if time_since_previous_request < RATE_LIMIT {
            let wait = Duration::from_secs_f32(RATE_LIMIT - time_since_previous_request);
            warn!(
                waiting = ?wait,
                "rate limiting the next request"
            );

            if _sync {
                std::thread::sleep(wait);
            } else {
                tokio::time::sleep(wait).await;
            }
            debug!("wait time is over, ready for the next request");
        }
        Ok(())
    }

    #[instrument(skip(self))]
    fn set_last_request_time(&self) -> Result<(), Error> {
        let mut lock = self
            .previous_request
            .lock()
            .map_err(|err| Error::PoisonedLock {
                err: err.to_string(),
            })?;

        *lock = Utc::now();

        debug!(
            time = ?*lock,
            "registered new time of last request"
        );

        Ok(())
    }

    #[instrument(skip(self))]
    fn get_from_cache<T>(&self, key: &str) -> Option<T>
    where
        T: DeserializeOwned,
    {
        let buf = self.cache.get(key)?.clone();

        info!("using cached response");

        serde_json::from_slice(&buf).ok()
    }

    /// Clear the in memory cache to have the next requests
    /// fetch data from the API.
    #[instrument(skip(self))]
    pub fn clear_cache(&self) {
        self.cache.clear();
    }

    #[instrument(skip(self, value), level = "info")]
    fn cache_response<T>(&self, key: &str, value: &T) -> Result<(), Error>
    where
        T: Serialize,
    {
        debug!("caching response");

        let buf = serde_json::to_vec(value).map_err(Error::from)?;

        self.cache.insert(key.to_owned(), buf);

        Ok(())
    }
}

/// API Endpoints
impl Client {
    utils::def_endpoints! {
        /// Returns all [`Units`], including Characters and Ships.
        units => Units,
        /// Returns all [`Character`]s.
        characters => Vec<Character>,
        /// Returns all [`Ship`]s.
        ships => Vec<Ship>,
        /// Returns all [`Gear`] pieces.
        gear => Vec<Gear>,
        /// Returns all [`Ability`]s.
        abilities => Vec<Ability>,
        /// Returns all [`StatDefinition`] definitions.
        stat_definitions => Vec<StatDefinition>,
        /// Returns all [`DataCronAffixTemplateSet`](datacron::AffixTemplateSet)s.
        datacron_affix_template_sets => Vec<datacron::AffixTemplateSet>,
        /// Returns all [`DataCronSet`](datacron::Set)s affix templates.
        datacron_sets => Vec<datacron::Set>,
        /// Returns all [`DataCronTemplate`](datacron::Template)s.
        datacron_templates => Vec<datacron::Template>,
        /// Returns a [`Guild`](guild::Profile) profile from it's `id`.
        ///
        /// You can get a guild's ID either from the URL of its
        /// swgoh.gg page: `https://swgoh.gg/g/<id>/` or fetch
        /// it using a player's profile with [`player(id)`](Self::player)
        /// and extracting it from [`player.data.guild_id`](types::player::Data::guild_id).
        guild_profile(id: &str) => guild::Profile,
        /// Returns a [`Player`] profile from it's `ally_code`.
        ///
        /// The `ally_code` is the 9-digit ally code you find
        /// on your in-game profile.
        player(ally_code: u64) => Player,
    }
}

mod utils {
    macro_rules! def_endpoints {
        (
            $(
                $( #[$attrs:meta] )*
                $endpoint:ident $(($id:ident: $ty:ty))? => $res:ty
            ),+
            $(,)?
        ) => {
            $(
                $( #[ $attrs ] )*
                #[async_generic::async_generic]
                #[instrument(skip(self))]
                pub fn $endpoint(&self $(, $id: $ty)?) -> Result<$res, Error> {
                    let endpoint = stringify!($endpoint).replace("_", "-");
                    $( let endpoint = format!("{endpoint}/{}", $id); )?

                    if let Some(cached) = self.get_from_cache(&endpoint) {
                        return Ok(cached);
                    }

                    debug!(endpoint, "preparing next request");

                    let response = if _sync {
                        self.request(&endpoint)
                    } else {
                        self.request_async(&endpoint).await
                    };

                    if let Ok(res) = &response {
                        self.cache_response(&endpoint.to_string(), res)?;
                    }
                    response
                }
            )+
        };
    }

    pub(crate) use def_endpoints;
}

impl Debug for Client {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Client")
            .field("api_key", &"==SECRET==")
            .field(
                "set_last_request_time",
                &self
                    .previous_request
                    .lock()
                    .map(|t| t.to_rfc2822())
                    .unwrap_or("internal error".to_owned()),
            )
            .finish()
    }
}