#![doc = include_str!("../README.md")]
pub const RATE_LIMIT: f32 = 1.25;
pub const SWGOH_GG_ACCESS_KEY: &str = "SWGOH_GG_ACCESS_KEY";
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},
},
};
#[derive(Debug, Snafu, PartialEq, Clone)]
pub enum Error {
#[snafu(display("Missing API Key from \"{SWGOH_GG_ACCESS_KEY}\" env variable."))]
MissingApiKey { source: std::env::VarError },
#[snafu(display("failed to send HTTP request"))]
Request { err: String },
#[snafu(display("received malformed data"))]
Json { err: String },
#[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,
]
}
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 {
#[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(),
})
}
#[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
}
#[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()
}
#[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(())
}
}
impl Client {
utils::def_endpoints! {
units => Units,
characters => Vec<Character>,
ships => Vec<Ship>,
gear => Vec<Gear>,
abilities => Vec<Ability>,
stat_definitions => Vec<StatDefinition>,
datacron_affix_template_sets => Vec<datacron::AffixTemplateSet>,
datacron_sets => Vec<datacron::Set>,
datacron_templates => Vec<datacron::Template>,
guild_profile(id: &str) => guild::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()
}
}