Skip to main content

swgoh_gg/
lib.rs

1#![doc = include_str!("../README.md")]
2
3/// Artificial rate limit to comply with
4/// the usage terms.
5pub const RATE_LIMIT: f32 = 1.25; // only pub for documentation purposes
6
7/// The env variable containing the access key
8/// to create a [`Client`].
9pub const SWGOH_GG_ACCESS_KEY: &str = "SWGOH_GG_ACCESS_KEY"; // only pub for documentation purposes
10
11const 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/// Error when sending a request to the API server
37#[derive(Debug, Snafu, PartialEq, Clone)]
38pub enum Error {
39    /// Error when the [`SWGOH_GG_ACCESS_KEY`] env variable is not set.
40    #[snafu(display("Missing API Key from \"{SWGOH_GG_ACCESS_KEY}\" env variable."))]
41    MissingApiKey { source: std::env::VarError },
42
43    /// Error when the request cannot be sent
44    #[snafu(display("failed to send HTTP request"))]
45    Request { err: String },
46
47    /// Error when request Body is invalid JSON
48    #[snafu(display("received malformed data"))]
49    Json { err: String },
50
51    /// Error when the rate limiter mutex deadlocks
52    #[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
84/// A persistent client for querying the [`swgoh.gg`](https://swgoh.gg/) API.
85///
86/// Please note: subsequent requests are artificially
87/// limited to an interval of `1.25s` to comply with
88/// the access terms & limits.
89pub 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    /// Create a new client using the API key from the
102    /// env variable `SWGOH_GG_ACCESS_KEY`.
103    #[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    /// Send a GET Request to the API on `endpoint`.
117    #[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    /// Makes sure time between is at least ~ `1.25s`
163    #[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    /// Clear the in memory cache to have the next requests
228    /// fetch data from the API.
229    #[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
249/// API Endpoints
250impl Client {
251    utils::def_endpoints! {
252        /// Returns all [`Units`], including Characters and Ships.
253        units => Units,
254        /// Returns all [`Character`]s.
255        characters => Vec<Character>,
256        /// Returns all [`Ship`]s.
257        ships => Vec<Ship>,
258        /// Returns all [`Gear`] pieces.
259        gear => Vec<Gear>,
260        /// Returns all [`Ability`]s.
261        abilities => Vec<Ability>,
262        /// Returns all [`StatDefinition`] definitions.
263        stat_definitions => Vec<StatDefinition>,
264        /// Returns all [`DataCronAffixTemplateSet`](datacron::AffixTemplateSet)s.
265        datacron_affix_template_sets => Vec<datacron::AffixTemplateSet>,
266        /// Returns all [`DataCronSet`](datacron::Set)s affix templates.
267        datacron_sets => Vec<datacron::Set>,
268        /// Returns all [`DataCronTemplate`](datacron::Template)s.
269        datacron_templates => Vec<datacron::Template>,
270        /// Returns a [`Guild`](guild::Profile) profile from it's `id`.
271        ///
272        /// You can get a guild's ID either from the URL of its
273        /// swgoh.gg page: `https://swgoh.gg/g/<id>/` or fetch
274        /// it using a player's profile with [`player(id)`](Self::player)
275        /// and extracting it from [`player.data.guild_id`](types::player::Data::guild_id).
276        guild_profile(id: &str) => guild::Profile,
277        /// Returns a [`Player`] profile from it's `ally_code`.
278        ///
279        /// The `ally_code` is the 9-digit ally code you find
280        /// on your in-game profile.
281        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}