Skip to main content

ddapi_rs/api/
mod.rs

1use crate::error::{Error, Result};
2#[cfg(feature = "cache")]
3use moka::future::Cache;
4use reqwest::header;
5use reqwest::Client;
6use serde::de::DeserializeOwned;
7#[allow(unused_imports)]
8use std::time::Duration;
9
10#[cfg(feature = "cache")]
11const DEFAULT_CACHE_TTL: Duration = Duration::from_mins(10);
12#[cfg(feature = "cache")]
13const DEFAULT_CACHE_CAPACITY: u64 = 10_000;
14
15#[derive(Clone, Default)]
16pub(crate) struct ApiCore {
17    client: Client,
18    #[cfg(feature = "cache")]
19    cache: Option<Cache<String, Vec<u8>>>,
20}
21
22impl ApiCore {
23    #[cfg(feature = "cache")]
24    fn default_cache() -> Cache<String, Vec<u8>> {
25        Cache::builder()
26            .max_capacity(DEFAULT_CACHE_CAPACITY)
27            .time_to_live(DEFAULT_CACHE_TTL)
28            .build()
29    }
30
31    fn new() -> Self {
32        let client = Client::builder()
33            .user_agent(concat!(
34                env!("CARGO_PKG_NAME"),
35                "/",
36                env!("CARGO_PKG_VERSION")
37            ))
38            .default_headers({
39                let mut h = header::HeaderMap::new();
40                h.insert(
41                    header::ACCEPT,
42                    header::HeaderValue::from_static("application/json"),
43                );
44                h
45            })
46            .build()
47            .unwrap_or_else(|_| Client::new());
48        Self {
49            client,
50            #[cfg(feature = "cache")]
51            cache: Some(Self::default_cache()),
52        }
53    }
54
55    fn new_with_client(client: Client) -> Self {
56        Self {
57            client,
58            #[cfg(feature = "cache")]
59            cache: Some(Self::default_cache()),
60        }
61    }
62
63    #[cfg(feature = "cache")]
64    fn set_cache(&mut self, capacity: u64, time_to_live: Duration) {
65        self.cache = Some(
66            Cache::builder()
67                .max_capacity(capacity)
68                .time_to_live(time_to_live)
69                .build(),
70        );
71    }
72
73    /// Sends an HTTP GET request to the specified URL and returns the raw response body.
74    async fn send_request(&self, url: &str) -> Result<Vec<u8>> {
75        let response = self
76            .client
77            .get(url)
78            // Avoid hanging forever on large responses while still being generous.
79            .timeout(Duration::from_secs(30))
80            .send()
81            .await?;
82
83        let status = response.status();
84        let body = response.bytes().await?.to_vec();
85
86        if body.is_empty() {
87            return Err(Error::EmptyBody);
88        }
89
90        if !status.is_success() {
91            let msg = String::from_utf8_lossy(&body).chars().take(2048).collect();
92            return Err(Error::HttpStatus { status, body: msg });
93        }
94
95        Ok(body)
96    }
97
98    pub async fn generator<T>(&self, url: &str) -> Result<T>
99    where
100        T: DeserializeOwned + Send + Sync + 'static,
101    {
102        #[cfg(feature = "cache")]
103        {
104            self.generator_cached(url).await
105        }
106        #[cfg(not(feature = "cache"))]
107        {
108            self.generator_no_cache(url).await
109        }
110    }
111
112    #[cfg(feature = "cache")]
113    async fn generator_cached<T>(&self, url: &str) -> Result<T>
114    where
115        T: DeserializeOwned + Send + Sync + 'static,
116    {
117        let type_name = std::any::type_name::<T>();
118        let cache_key = format!("{type_name}:{url}");
119
120        match &self.cache {
121            Some(cache) => {
122                if let Some(value) = cache.get(&cache_key).await {
123                    Self::parse_response::<T>(value.as_slice())
124                } else {
125                    let body = self.send_request(url).await?;
126                    cache.insert(cache_key, body.clone()).await;
127                    Self::parse_response::<T>(body.as_slice())
128                }
129            }
130            None => self.generator_no_cache(url).await,
131        }
132    }
133
134    pub async fn generator_no_cache<T>(&self, url: &str) -> Result<T>
135    where
136        T: DeserializeOwned,
137    {
138        let body = self.send_request(url).await?;
139        Self::parse_response::<T>(body.as_slice())
140    }
141
142    fn parse_response<T>(body: &[u8]) -> Result<T>
143    where
144        T: DeserializeOwned,
145    {
146        // ddnet "not found" convention: empty JSON object.
147        #[cfg(feature = "ddnet")]
148        {
149            let trimmed = trim_ascii(body);
150            if trimmed == b"{}" {
151                return Err(Error::NotFound);
152            }
153        }
154
155        // ddstats sometimes returns HTTP 200 with { "error": "..." }.
156        #[cfg(feature = "ddstats")]
157        {
158            #[derive(serde::Deserialize)]
159            #[serde(untagged)]
160            enum MaybeError<T> {
161                Err { error: String },
162                Ok(T),
163            }
164
165            // Single-pass parse: either error envelope or expected payload.
166            match serde_json::from_slice::<MaybeError<T>>(body)? {
167                MaybeError::Err { error } => {
168                    if error.eq_ignore_ascii_case("player not found") {
169                        Err(Error::NotFound)
170                    } else {
171                        Err(Error::RemoteMessage(error))
172                    }
173                }
174                MaybeError::Ok(v) => Ok(v),
175            }
176        }
177
178        #[cfg(not(feature = "ddstats"))]
179        {
180            Ok(serde_json::from_slice(body)?)
181        }
182    }
183}
184
185fn trim_ascii(mut s: &[u8]) -> &[u8] {
186    while let Some((&b, rest)) = s.split_first() {
187        if !b.is_ascii_whitespace() {
188            break;
189        }
190        s = rest;
191    }
192    while let Some((&b, rest)) = s.split_last() {
193        if !b.is_ascii_whitespace() {
194            break;
195        }
196        s = rest;
197    }
198    s
199}
200
201pub trait HasApiCore {
202    fn core(&self) -> &ApiCore;
203}
204
205#[derive(Clone, Default)]
206pub struct DDApi {
207    core: ApiCore,
208}
209
210impl HasApiCore for DDApi {
211    fn core(&self) -> &ApiCore {
212        &self.core
213    }
214}
215
216impl DDApi {
217    /// Creates a new `DDApi` instance with default settings
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use ddapi_rs::prelude::*;
223    ///
224    /// let api = DDApi::new();
225    /// ```
226    #[must_use]
227    pub fn new() -> Self {
228        DDApi {
229            core: ApiCore::new(),
230        }
231    }
232
233    /// Creates a new `DDApi` instance with a custom HTTP client
234    ///
235    /// This allows you to configure your own client with custom timeouts,
236    /// headers, or other settings.
237    ///
238    /// # Arguments
239    ///
240    /// * `client` - A pre-configured `reqwest::Client` instance
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// use ddapi_rs::prelude::*;
246    /// use reqwest::Client;
247    ///
248    /// let client = Client::builder()
249    ///     .timeout(std::time::Duration::from_secs(10))
250    ///     .build()
251    ///     .unwrap();
252    /// let api = DDApi::new_with_client(client);
253    /// ```
254    #[must_use]
255    pub fn new_with_client(client: Client) -> Self {
256        DDApi {
257            core: ApiCore::new_with_client(client),
258        }
259    }
260
261    /// Configures caching for API responses
262    ///
263    /// When the `cache` feature is enabled, this method allows you to set up
264    /// an in-memory cache to reduce API calls and improve performance.
265    ///
266    /// # Arguments
267    ///
268    /// * `capacity` - Maximum number of entries to store in the cache
269    /// * `time_to_live` - Time in seconds before cached entries expire
270    ///
271    /// # Examples
272    ///
273    ///
274    /// ```ignore
275    /// use ddapi_rs::prelude::*;
276    /// use std::time::Duration;
277    ///
278    /// let mut api = DDApi::new();
279    /// api.set_cache(1000, Duration::from_secs(60 * 5)); // Cache 1000 items for 5 minutes
280    /// ```
281    #[cfg(feature = "cache")]
282    pub fn set_cache(&mut self, capacity: u64, time_to_live: Duration) {
283        self.core.set_cache(capacity, time_to_live);
284    }
285
286    /// Executes an API request and deserializes the JSON response
287    ///
288    /// This method handles API requests and automatically deserializes the JSON response
289    /// into the specified type. The caching behavior is determined by the `cache` feature flag.
290    ///
291    /// # Type Parameters
292    ///
293    /// * `T` - The type to deserialize the response into. Must implement
294    ///   `DeserializeOwned + Send + Sync + 'static`
295    ///
296    /// # Arguments
297    ///
298    /// * `url` - The API endpoint URL to request
299    ///
300    /// # Returns
301    ///
302    /// `Result<T>` containing the deserialized data on success, or an error on failure
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if the request fails, the response is empty or has a
307    /// non-success status, or the body cannot be deserialized into `T`.
308    pub async fn generator<T>(&self, url: &str) -> Result<T>
309    where
310        T: DeserializeOwned + Send + Sync + 'static,
311    {
312        self.core.generator(url).await
313    }
314
315    /// Executes an API request without caching
316    ///
317    /// Always fetches fresh data from the API, bypassing any cache.
318    ///
319    /// # Type Parameters
320    ///
321    /// * `T` - The type to deserialize the response into
322    ///
323    /// # Arguments
324    ///
325    /// * `url` - The API endpoint URL to request
326    ///
327    /// # Returns
328    ///
329    /// Returns `Result<T>` with freshly fetched deserialized data
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if the request fails, the response is empty or has a
334    /// non-success status, or the body cannot be deserialized into `T`.
335    pub async fn generator_no_cache<T>(&self, url: &str) -> Result<T>
336    where
337        T: DeserializeOwned,
338    {
339        self.core.generator_no_cache(url).await
340    }
341}
342
343#[derive(Clone, Default)]
344pub struct DDnetClient {
345    core: ApiCore,
346}
347
348impl HasApiCore for DDnetClient {
349    fn core(&self) -> &ApiCore {
350        &self.core
351    }
352}
353
354impl DDnetClient {
355    #[must_use]
356    pub fn new() -> Self {
357        Self {
358            core: ApiCore::new(),
359        }
360    }
361
362    #[must_use]
363    pub fn new_with_client(client: Client) -> Self {
364        Self {
365            core: ApiCore::new_with_client(client),
366        }
367    }
368
369    #[cfg(feature = "cache")]
370    pub fn set_cache(&mut self, capacity: u64, time_to_live: Duration) {
371        self.core.set_cache(capacity, time_to_live);
372    }
373}
374
375#[derive(Clone, Default)]
376pub struct DDstatsClient {
377    core: ApiCore,
378}
379
380impl HasApiCore for DDstatsClient {
381    fn core(&self) -> &ApiCore {
382        &self.core
383    }
384}
385
386impl DDstatsClient {
387    #[must_use]
388    pub fn new() -> Self {
389        Self {
390            core: ApiCore::new(),
391        }
392    }
393
394    #[must_use]
395    pub fn new_with_client(client: Client) -> Self {
396        Self {
397            core: ApiCore::new_with_client(client),
398        }
399    }
400
401    #[cfg(feature = "cache")]
402    pub fn set_cache(&mut self, capacity: u64, time_to_live: Duration) {
403        self.core.set_cache(capacity, time_to_live);
404    }
405}
406
407#[cfg(feature = "ddnet")]
408pub mod ddnet;
409
410#[cfg(feature = "ddstats")]
411pub mod ddstats;