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_secs(60 * 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>(&self, 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    pub fn new() -> Self {
227        DDApi {
228            core: ApiCore::new(),
229        }
230    }
231
232    /// Creates a new DDApi instance with a custom HTTP client
233    ///
234    /// This allows you to configure your own client with custom timeouts,
235    /// headers, or other settings.
236    ///
237    /// # Arguments
238    ///
239    /// * `client` - A pre-configured `reqwest::Client` instance
240    ///
241    /// # Examples
242    ///
243    /// ```
244    /// use ddapi_rs::prelude::*;
245    /// use reqwest::Client;
246    ///
247    /// let client = Client::builder()
248    ///     .timeout(std::time::Duration::from_secs(10))
249    ///     .build()
250    ///     .unwrap();
251    /// let api = DDApi::new_with_client(client);
252    /// ```
253    pub fn new_with_client(client: Client) -> Self {
254        DDApi {
255            core: ApiCore::new_with_client(client),
256        }
257    }
258
259    /// Configures caching for API responses
260    ///
261    /// When the `cache` feature is enabled, this method allows you to set up
262    /// an in-memory cache to reduce API calls and improve performance.
263    ///
264    /// # Arguments
265    ///
266    /// * `capacity` - Maximum number of entries to store in the cache
267    /// * `time_to_live` - Time in seconds before cached entries expire
268    ///
269    /// # Examples
270    ///
271    ///
272    /// ```ignore
273    /// use ddapi_rs::prelude::*;
274    /// use std::time::Duration;
275    ///
276    /// let mut api = DDApi::new();
277    /// api.set_cache(1000, Duration::from_secs(60 * 5)); // Cache 1000 items for 5 minutes
278    /// ```
279    #[cfg(feature = "cache")]
280    pub fn set_cache(&mut self, capacity: u64, time_to_live: Duration) {
281        self.core.set_cache(capacity, time_to_live);
282    }
283
284    /// Executes an API request and deserializes the JSON response
285    ///
286    /// This method handles API requests and automatically deserializes the JSON response
287    /// into the specified type. The caching behavior is determined by the `cache` feature flag.
288    ///
289    /// # Type Parameters
290    ///
291    /// * `T` - The type to deserialize the response into. Must implement
292    ///   `DeserializeOwned + Send + Sync + 'static`
293    ///
294    /// # Arguments
295    ///
296    /// * `url` - The API endpoint URL to request
297    /// # Returns
298    ///
299    /// # Returns
300    ///
301    /// `Result<T>` containing the deserialized data on success, or an error on failure
302    pub async fn _generator<T>(&self, url: &str) -> Result<T>
303    where
304        T: DeserializeOwned + Send + Sync + 'static,
305    {
306        self.core._generator(url).await
307    }
308
309    /// Executes an API request without caching
310    ///
311    /// Always fetches fresh data from the API, bypassing any cache.
312    ///
313    /// # Type Parameters
314    ///
315    /// * `T` - The type to deserialize the response into
316    ///
317    /// # Arguments
318    ///
319    /// * `url` - The API endpoint URL to request
320    ///
321    /// # Returns
322    ///
323    /// Returns `Result<T>` with freshly fetched deserialized data
324    pub async fn _generator_no_cache<T>(&self, url: &str) -> Result<T>
325    where
326        T: DeserializeOwned,
327    {
328        self.core._generator_no_cache(url).await
329    }
330}
331
332#[derive(Clone, Default)]
333pub struct DDnetClient {
334    core: ApiCore,
335}
336
337impl HasApiCore for DDnetClient {
338    fn core(&self) -> &ApiCore {
339        &self.core
340    }
341}
342
343impl DDnetClient {
344    pub fn new() -> Self {
345        Self {
346            core: ApiCore::new(),
347        }
348    }
349
350    pub fn new_with_client(client: Client) -> Self {
351        Self {
352            core: ApiCore::new_with_client(client),
353        }
354    }
355
356    #[cfg(feature = "cache")]
357    pub fn set_cache(&mut self, capacity: u64, time_to_live: Duration) {
358        self.core.set_cache(capacity, time_to_live);
359    }
360}
361
362#[derive(Clone, Default)]
363pub struct DDstatsClient {
364    core: ApiCore,
365}
366
367impl HasApiCore for DDstatsClient {
368    fn core(&self) -> &ApiCore {
369        &self.core
370    }
371}
372
373impl DDstatsClient {
374    pub fn new() -> Self {
375        Self {
376            core: ApiCore::new(),
377        }
378    }
379
380    pub fn new_with_client(client: Client) -> Self {
381        Self {
382            core: ApiCore::new_with_client(client),
383        }
384    }
385
386    #[cfg(feature = "cache")]
387    pub fn set_cache(&mut self, capacity: u64, time_to_live: Duration) {
388        self.core.set_cache(capacity, time_to_live);
389    }
390}
391
392#[cfg(feature = "ddnet")]
393pub mod ddnet;
394
395#[cfg(feature = "ddstats")]
396pub mod ddstats;