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#[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 #[must_use]
56 pub fn new() -> Self {
57 ApiClient::with_base_url(ApiClient::DEMOS_TF_BASE_URL).unwrap_or_else(|_| unreachable!())
58 }
59
60 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 pub fn with_base_url_and_timeout(
75 base_url: impl IntoUrl,
76 timeout: Duration,
77 ) -> Result<Self, Error> {
78 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 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 #[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 #[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(¶ms)
198 .send()
199 .await?
200 .error_for_status()?
201 .json()
202 .await?)
203 }
204
205 #[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 #[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 #[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 #[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 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}