Skip to main content

demostf_client/
client.rs

1use crate::{ChatMessage, Demo, Error, ListParams, User};
2use reqwest::{multipart, Client, IntoUrl, Response, StatusCode, Url};
3use std::borrow::Borrow;
4use std::fmt::{self, Debug, Formatter};
5use std::str::FromStr;
6use std::time::Duration;
7use steamid_ng::SteamID;
8use tracing::{instrument, trace};
9
10/// Api client for demos.tf
11///
12/// # Example
13///
14/// ```rust
15/// use demostf_client::{ListOrder, ListParams, ApiClient};
16///
17/// # #[tokio::main]
18/// # async fn main() -> Result<(), demostf_client::Error> {
19/// let client = ApiClient::new();
20///
21/// let demos = client.list(ListParams::default().with_order(ListOrder::Ascending), 1).await?;
22///
23/// for demo in demos {
24///     println!("{}: {}", demo.id, demo.name);
25/// }
26/// # Ok(())
27/// # }
28/// ```
29#[derive(Clone)]
30pub struct ApiClient {
31    base_timeout: Duration,
32    client: Client,
33    base_url: Url,
34    access_key: Option<String>,
35}
36
37impl Default for ApiClient {
38    fn default() -> Self {
39        ApiClient::new()
40    }
41}
42
43impl Debug for ApiClient {
44    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
45        f.debug_struct("ApiClient")
46            .field("base_url", &format_args!("{}", self.base_url))
47            .finish_non_exhaustive()
48    }
49}
50
51impl ApiClient {
52    pub const DEMOS_TF_BASE_URL: &'static str = "https://api.demos.tf/";
53
54    /// Create an api client for the default demos.tf endpoint
55    #[must_use]
56    pub fn new() -> Self {
57        ApiClient::with_base_url(ApiClient::DEMOS_TF_BASE_URL).unwrap_or_else(|_| unreachable!())
58    }
59
60    /// Create an api client using a different api endpoint
61    ///
62    /// # Errors
63    ///
64    /// Returns an error when the provided `base_url` is not a valid url
65    pub fn with_base_url(base_url: impl IntoUrl) -> Result<Self, Error> {
66        ApiClient::with_base_url_and_timeout(base_url, Duration::from_secs(15))
67    }
68
69    /// Create an api client using a different api endpoint and timeout
70    ///
71    /// # Errors
72    ///
73    /// Returns an error when the provided `base_url` is not a valid url
74    pub fn with_base_url_and_timeout(
75        base_url: impl IntoUrl,
76        timeout: Duration,
77    ) -> Result<Self, Error> {
78        // ensure there is always a leading / to prevent unexpected behavior with url creation later
79        let mut base_url = base_url.into_url().map_err(|_| Error::InvalidBaseUrl)?;
80        if !base_url.path().ends_with("/") {
81            base_url.set_path(&format!("{}/", base_url.path()));
82        }
83
84        Ok(ApiClient {
85            base_timeout: timeout,
86            client: Client::builder().timeout(timeout).build()?,
87            base_url,
88            access_key: None,
89        })
90    }
91
92    /// Set access key used to access private demos
93    pub fn set_access_key(&mut self, access_key: String) {
94        self.access_key = Some(access_key);
95    }
96
97    fn url<P: AsRef<str>>(&self, path: P) -> Result<Url, Error> {
98        self.base_url
99            .join(path.as_ref())
100            .map_err(|_| Error::InvalidBaseUrl)
101    }
102
103    fn url_with_params<P, I, K, V>(&self, path: P, iter: I) -> Result<Url, Error>
104    where
105        P: AsRef<str>,
106        I: IntoIterator,
107        I::Item: Borrow<(K, V)>,
108        K: AsRef<str>,
109        V: AsRef<str>,
110    {
111        let mut url = self
112            .base_url
113            .join(path.as_ref())
114            .map_err(|_| Error::InvalidBaseUrl)?;
115        url.query_pairs_mut().extend_pairs(iter);
116        Ok(url)
117    }
118
119    /// List demos with the provided options
120    ///
121    /// note that the pages start counting at 1
122    ///
123    /// # Example
124    ///
125    /// ```rust
126    /// use demostf_client::{ListOrder, ListParams};
127    /// # use demostf_client::ApiClient;
128    ///
129    /// # #[tokio::main]
130    /// # async fn main() -> Result<(), demostf_client::Error> {
131    /// # let client = ApiClient::default();
132    /// #
133    /// let demos = client.list(ListParams::default().with_order(ListOrder::Ascending), 1).await?;
134    ///
135    /// for demo in demos {
136    ///     println!("{}: {}", demo.id, demo.name);
137    /// }
138    /// # Ok(())
139    /// # }
140    /// ```
141    #[instrument]
142    pub async fn list(&self, params: ListParams, page: u32) -> Result<Vec<Demo>, Error> {
143        self.list_url(self.url("demos")?, params, page).await
144    }
145
146    /// List demos uploaded by a user with the provided options
147    ///
148    /// note that the pages start counting at 1
149    ///
150    /// # Example
151    ///
152    /// ```rust
153    /// use demostf_client::{ListOrder, ListParams};
154    /// # use demostf_client::ApiClient;
155    ///
156    /// # #[tokio::main]
157    /// # async fn main() -> Result<(), demostf_client::Error> {
158    /// # use steamid_ng::SteamID;
159    /// let client = ApiClient::default();
160    /// #
161    /// let demos = client.list_uploads(SteamID::from(76561198024494988), ListParams::default().with_order(ListOrder::Ascending), 1).await?;
162    ///
163    /// for demo in demos {
164    ///     println!("{}: {}", demo.id, demo.name);
165    /// }
166    /// # Ok(())
167    /// # }
168    /// ```
169    #[instrument]
170    pub async fn list_uploads(
171        &self,
172        uploader: SteamID,
173        params: ListParams,
174        page: u32,
175    ) -> Result<Vec<Demo>, Error> {
176        self.list_url(
177            self.url(format!("uploads/{}", u64::from(uploader)))?,
178            params,
179            page,
180        )
181        .await
182    }
183
184    async fn list_url(&self, url: Url, params: ListParams, page: u32) -> Result<Vec<Demo>, Error> {
185        if page == 0 {
186            return Err(Error::InvalidPage);
187        }
188
189        let mut req = self.client.get(url);
190
191        if let Some(access_key) = &self.access_key {
192            req = req.header("ACCESS-KEY", access_key.as_str());
193        }
194
195        Ok(req
196            .query(&[("page", page)])
197            .query(&params)
198            .send()
199            .await?
200            .error_for_status()?
201            .json()
202            .await?)
203    }
204
205    /// Get the data for a single demo
206    ///
207    /// # Example
208    ///
209    /// ```rust
210    /// # use demostf_client::ApiClient;
211    /// #
212    /// # #[tokio::main]
213    /// # async fn main() -> Result<(), demostf_client::Error> {
214    /// # let client = ApiClient::default();
215    /// #
216    /// let demo = client.get(9).await?;
217    ///
218    /// println!("{}: {}", demo.id, demo.name);
219    /// println!("players:");
220    ///
221    /// for player in demo.players.unwrap_or_default() {
222    ///     println!("{}", player.user.name);
223    /// }
224    /// # Ok(())
225    /// # }
226    /// ```
227    #[instrument]
228    pub async fn get(&self, demo_id: u32) -> Result<Demo, Error> {
229        let mut req = self.client.get(self.url(format!("/demos/{}", demo_id))?);
230
231        if let Some(access_key) = &self.access_key {
232            req = req.header("ACCESS-KEY", access_key.as_str());
233        }
234
235        let response = req.send().await?;
236
237        if response.status() == StatusCode::NOT_FOUND {
238            return Err(Error::DemoNotFound(demo_id));
239        }
240
241        Ok(response.error_for_status()?.json().await?)
242    }
243
244    /// Get user info by id
245    ///
246    /// # Example
247    ///
248    /// ```rust
249    /// # use demostf_client::ApiClient;
250    /// #
251    /// # #[tokio::main]
252    /// # async fn main() -> Result<(), demostf_client::Error> {
253    /// # let client = ApiClient::default();
254    /// #
255    /// let user = client.get_user(1).await?;
256    ///
257    /// println!("{} ({})", user.name, user.steam_id.steam3());
258    /// # Ok(())
259    /// # }
260    /// ```
261    #[instrument]
262    pub async fn get_user(&self, user_id: u32) -> Result<User, Error> {
263        let response = self
264            .client
265            .get(self.url(format!("/users/{}", user_id))?)
266            .send()
267            .await?;
268
269        if response.status() == StatusCode::NOT_FOUND {
270            return Err(Error::UserNotFound(user_id));
271        }
272
273        Ok(response.error_for_status()?.json().await?)
274    }
275
276    /// Search for players by name
277    ///
278    /// # Example
279    ///
280    /// ```rust
281    /// # use demostf_client::ApiClient;
282    /// #
283    /// # #[tokio::main]
284    /// # async fn main() -> Result<(), demostf_client::Error> {
285    /// let client = ApiClient::default();
286    /// #
287    /// let users = client.search_users("icewind").await?;
288    ///
289    /// for user in users {
290    ///   println!("{} ({})", user.name, user.steam_id.steam3());
291    /// }
292    /// # Ok(())
293    /// # }
294    /// ```
295    #[instrument]
296    pub async fn search_users(&self, name: &str) -> Result<Vec<User>, Error> {
297        let response = self
298            .client
299            .get(self.url_with_params("/users/search", [("query", name)])?)
300            .send()
301            .await?;
302
303        Ok(response.error_for_status()?.json().await?)
304    }
305
306    /// List demos with the provided options
307    ///
308    /// # Example
309    ///
310    /// ```rust
311    /// # use demostf_client::ApiClient;
312    /// #
313    /// # #[tokio::main]
314    /// # async fn main() -> Result<(), demostf_client::Error> {
315    /// # let client = ApiClient::default();
316    /// #
317    /// let chat = client.get_chat(447678).await?;
318    ///
319    /// for message in chat {
320    ///     println!("{}: {}", message.user, message.message);
321    /// }
322    /// # Ok(())
323    /// # }
324    /// ```
325    #[instrument]
326    pub async fn get_chat(&self, demo_id: u32) -> Result<Vec<ChatMessage>, Error> {
327        let response = self
328            .client
329            .get(self.url(format!("/demos/{}/chat", demo_id))?)
330            .send()
331            .await?;
332
333        if response.status() == StatusCode::NOT_FOUND {
334            return Err(Error::DemoNotFound(demo_id));
335        }
336
337        Ok(response.error_for_status()?.json().await?)
338    }
339
340    #[instrument]
341    pub async fn set_url(
342        &self,
343        demo_id: u32,
344        backend: &str,
345        path: &str,
346        url: &str,
347        hash: [u8; 16],
348        key: &str,
349    ) -> Result<(), Error> {
350        let response = self
351            .client
352            .post(self.url(format!("/demos/{}/url", demo_id))?)
353            .form(&[
354                ("hash", hex::encode(hash).as_str()),
355                ("backend", backend),
356                ("url", url),
357                ("path", path),
358                ("key", key),
359            ])
360            .send()
361            .await?;
362
363        if response.status() == StatusCode::NOT_FOUND {
364            return Err(Error::DemoNotFound(demo_id));
365        }
366
367        response.error_for_status()?;
368
369        Ok(())
370    }
371
372    #[instrument(skip(body))]
373    pub async fn upload_demo(
374        &self,
375        file_name: String,
376        body: Vec<u8>,
377        red: String,
378        blue: String,
379        key: String,
380    ) -> Result<u32, Error> {
381        self.upload_maybe_private_demo(file_name, body, red, blue, key, false)
382            .await
383    }
384
385    #[instrument(skip(body))]
386    pub async fn upload_private_demo(
387        &self,
388        file_name: String,
389        body: Vec<u8>,
390        red: String,
391        blue: String,
392        key: String,
393    ) -> Result<u32, Error> {
394        self.upload_maybe_private_demo(file_name, body, red, blue, key, true)
395            .await
396    }
397
398    async fn upload_maybe_private_demo(
399        &self,
400        file_name: String,
401        body: Vec<u8>,
402        red: String,
403        blue: String,
404        key: String,
405        private: bool,
406    ) -> Result<u32, Error> {
407        let form = multipart::Form::new()
408            .text("red", red)
409            .text("blue", blue)
410            .text("name", file_name)
411            .text("key", key)
412            .text("private", if private { "1" } else { "0" });
413
414        let file = multipart::Part::bytes(body)
415            .file_name("demo.dem")
416            .mime_str("text/plain")?;
417
418        let form = form.part("demo", file);
419
420        let resp = self
421            .client
422            .post(self.url("/upload")?)
423            .multipart(form)
424            .send()
425            .await?
426            .error_for_status()?
427            .text()
428            .await?;
429
430        if resp == "Invalid key" {
431            return Err(Error::InvalidApiKey);
432        }
433
434        let tail = resp.split('/').next_back().unwrap_or_default();
435        u32::from_str(tail).map_err(|_| Error::InvalidResponse(resp))
436    }
437
438    pub(crate) async fn download_demo(&self, url: &str, duration: u16) -> Result<Response, Error> {
439        // set timeout to 1s per 60s (~1mb) with a minimum of 15s, scaled by an configured timeout (default 15s)
440        let timeout_scale = (f32::from(duration) / 60.0).max(15.0) / 15.0;
441        let timeout = Duration::from_secs_f32(self.base_timeout.as_secs_f32() * timeout_scale);
442        trace!(url = url, timeout = debug(timeout), "requesting demo file");
443        Ok(self
444            .client
445            .get(url)
446            .timeout(timeout)
447            .send()
448            .await?
449            .error_for_status()?)
450    }
451}
452
453#[test]
454fn test_url() {
455    assert_eq!(
456        "https://example.com/demos",
457        ApiClient::with_base_url("https://example.com")
458            .unwrap()
459            .url("demos")
460            .unwrap()
461            .to_string()
462    );
463    assert_eq!(
464        "https://example.com/demos",
465        ApiClient::with_base_url("https://example.com/")
466            .unwrap()
467            .url("demos")
468            .unwrap()
469            .to_string()
470    );
471    assert_eq!(
472        "https://example.com/sub/demos",
473        ApiClient::with_base_url("https://example.com/sub/")
474            .unwrap()
475            .url("demos")
476            .unwrap()
477            .to_string()
478    );
479    assert_eq!(
480        "https://example.com/sub/demos",
481        ApiClient::with_base_url("https://example.com/sub")
482            .unwrap()
483            .url("demos")
484            .unwrap()
485            .to_string()
486    );
487}