1#![doc = include_str!("../README.md")]
2
3pub 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";
12
13pub mod types;
14
15use {
16 async_generic::async_generic,
17 chrono::{DateTime, Utc},
18 dashmap::DashMap,
19 serde::{Serialize, de::DeserializeOwned},
20 snafu::{ResultExt, Snafu},
21 std::{
22 fmt::{Debug, Display},
23 sync::{Arc, Mutex, MutexGuard, PoisonError},
24 time::Duration,
25 },
26 tracing::{debug, info, instrument, warn},
27 types::{
28 Ability, StatDefinition, datacron,
29 gear::Gear,
30 guild,
31 player::Player,
32 unit::{Character, Ship, Units},
33 },
34};
35
36#[derive(Debug, Snafu, PartialEq, Clone)]
38pub enum Error {
39 #[snafu(display("Missing API Key from \"{SWGOH_GG_ACCESS_KEY}\" env variable."))]
41 MissingApiKey { source: std::env::VarError },
42
43 #[snafu(display("failed to send HTTP request"))]
45 Request { err: String },
46
47 #[snafu(display("received malformed data"))]
49 Json { err: String },
50
51 #[snafu(display("internal error, rate limiter got stuck:\n\t{err}"))]
53 PoisonedLock { err: String },
54}
55
56macro_rules! impl_from_error {
57 {
58 $name:ident from [
59 $(
60 $from:ty as $variant:ident $(with <$lt:lifetime>)?
61 ),* $(,)?
62 ]
63 } => {
64 $(
65 impl$(<$lt>)? From<$from> for $name {
66 fn from(value: $from) -> Self {
67 Self::$variant {
68 err: value.to_string()
69 }
70 }
71 }
72 )*
73 };
74}
75
76impl_from_error! {
77 Error from [
78 reqwest::Error as Request,
79 PoisonError<MutexGuard<'a, DateTime<Utc>>> as PoisonedLock with <'a>,
80 serde_json::Error as Json,
81 ]
82}
83
84pub struct Client {
90 api_key: String,
91
92 sync_client: reqwest::blocking::Client,
93 async_client: reqwest::Client,
94
95 previous_request: Arc<Mutex<DateTime<Utc>>>,
96
97 cache: DashMap<String, Vec<u8>>,
98}
99
100impl Client {
101 #[instrument]
104 pub fn new() -> Result<Self, Error> {
105 let api_key = std::env::var(SWGOH_GG_ACCESS_KEY).context(MissingApiKeySnafu)?;
106
107 Ok(Self {
108 api_key,
109 sync_client: reqwest::blocking::Client::new(),
110 async_client: reqwest::Client::new(),
111 previous_request: Arc::new(Mutex::new(DateTime::UNIX_EPOCH)),
112 cache: DashMap::default(),
113 })
114 }
115
116 #[async_generic]
118 #[instrument(skip_all, fields(url))]
119 fn request<P, T>(&self, endpoint: P) -> Result<T, Error>
120 where
121 P: Display + Debug,
122 T: DeserializeOwned + Serialize,
123 {
124 let method = reqwest::Method::GET;
125 let url = format!("https://swgoh.gg/api/{endpoint}");
126
127 tracing::Span::current().record("url", &url);
128
129 let response = if _sync {
130 self.comply_with_rate_limit()?;
131
132 debug!(%method, url, "sending request");
133 self.sync_client
134 .request(method, &url)
135 .header(API_HEADER_KEY, &self.api_key)
136 .send()
137 .map_err(Error::from)?
138 .json()
139 .map_err(Into::into)
140 } else {
141 self.comply_with_rate_limit_async().await?;
142
143 debug!(%method, url, "sending request");
144 self.async_client
145 .request(method, &url)
146 .header(API_HEADER_KEY, &self.api_key)
147 .send()
148 .await
149 .map_err(Error::from)?
150 .json()
151 .await
152 .map_err(Into::into)
153 };
154
155 info!("response received");
156
157 self.set_last_request_time()?;
158
159 response
160 }
161
162 #[async_generic]
164 #[instrument(skip(self))]
165 fn comply_with_rate_limit(&self) -> Result<(), Error> {
166 let time_since_previous_request = Utc::now()
167 .signed_duration_since(*self.previous_request.lock().map_err(|err| {
168 Error::PoisonedLock {
169 err: err.to_string(),
170 }
171 })?)
172 .as_seconds_f32();
173
174 debug!(
175 time = time_since_previous_request,
176 "time elapsed since the last request"
177 );
178
179 if time_since_previous_request < RATE_LIMIT {
180 let wait = Duration::from_secs_f32(RATE_LIMIT - time_since_previous_request);
181 warn!(
182 waiting = ?wait,
183 "rate limiting the next request"
184 );
185
186 if _sync {
187 std::thread::sleep(wait);
188 } else {
189 tokio::time::sleep(wait).await;
190 }
191 debug!("wait time is over, ready for the next request");
192 }
193 Ok(())
194 }
195
196 #[instrument(skip(self))]
197 fn set_last_request_time(&self) -> Result<(), Error> {
198 let mut lock = self
199 .previous_request
200 .lock()
201 .map_err(|err| Error::PoisonedLock {
202 err: err.to_string(),
203 })?;
204
205 *lock = Utc::now();
206
207 debug!(
208 time = ?*lock,
209 "registered new time of last request"
210 );
211
212 Ok(())
213 }
214
215 #[instrument(skip(self))]
216 fn get_from_cache<T>(&self, key: &str) -> Option<T>
217 where
218 T: DeserializeOwned,
219 {
220 let buf = self.cache.get(key)?.clone();
221
222 info!("using cached response");
223
224 serde_json::from_slice(&buf).ok()
225 }
226
227 #[instrument(skip(self))]
230 pub fn clear_cache(&self) {
231 self.cache.clear();
232 }
233
234 #[instrument(skip(self, value), level = "info")]
235 fn cache_response<T>(&self, key: &str, value: &T) -> Result<(), Error>
236 where
237 T: Serialize,
238 {
239 debug!("caching response");
240
241 let buf = serde_json::to_vec(value).map_err(Error::from)?;
242
243 self.cache.insert(key.to_owned(), buf);
244
245 Ok(())
246 }
247}
248
249impl Client {
251 utils::def_endpoints! {
252 units => Units,
254 characters => Vec<Character>,
256 ships => Vec<Ship>,
258 gear => Vec<Gear>,
260 abilities => Vec<Ability>,
262 stat_definitions => Vec<StatDefinition>,
264 datacron_affix_template_sets => Vec<datacron::AffixTemplateSet>,
266 datacron_sets => Vec<datacron::Set>,
268 datacron_templates => Vec<datacron::Template>,
270 guild_profile(id: &str) => guild::Profile,
277 player(ally_code: u64) => Player,
282 }
283}
284
285mod utils {
286 macro_rules! def_endpoints {
287 (
288 $(
289 $( #[$attrs:meta] )*
290 $endpoint:ident $(($id:ident: $ty:ty))? => $res:ty
291 ),+
292 $(,)?
293 ) => {
294 $(
295 $( #[ $attrs ] )*
296 #[async_generic::async_generic]
297 #[instrument(skip(self))]
298 pub fn $endpoint(&self $(, $id: $ty)?) -> Result<$res, Error> {
299 let endpoint = stringify!($endpoint).replace("_", "-");
300 $( let endpoint = format!("{endpoint}/{}", $id); )?
301
302 if let Some(cached) = self.get_from_cache(&endpoint) {
303 return Ok(cached);
304 }
305
306 debug!(endpoint, "preparing next request");
307
308 let response = if _sync {
309 self.request(&endpoint)
310 } else {
311 self.request_async(&endpoint).await
312 };
313
314 if let Ok(res) = &response {
315 self.cache_response(&endpoint.to_string(), res)?;
316 }
317 response
318 }
319 )+
320 };
321 }
322
323 pub(crate) use def_endpoints;
324}
325
326impl Debug for Client {
327 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328 f.debug_struct("Client")
329 .field("api_key", &"==SECRET==")
330 .field(
331 "set_last_request_time",
332 &self
333 .previous_request
334 .lock()
335 .map(|t| t.to_rfc2822())
336 .unwrap_or("internal error".to_owned()),
337 )
338 .finish()
339 }
340}