use crate::{ChatMessage, Demo, Error, ListParams, User};
use reqwest::{multipart, Client, IntoUrl, Response, StatusCode, Url};
use std::fmt::{self, Debug, Formatter};
use std::str::FromStr;
use std::time::Duration;
use steamid_ng::SteamID;
use tracing::{instrument, trace};
#[derive(Clone)]
pub struct ApiClient {
base_timeout: Duration,
client: Client,
base_url: Url,
}
impl Default for ApiClient {
fn default() -> Self {
ApiClient::new()
}
}
impl Debug for ApiClient {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiClient")
.field("base_url", &format_args!("{}", self.base_url))
.finish_non_exhaustive()
}
}
impl ApiClient {
pub const DEMOS_TF_BASE_URL: &'static str = "https://api.demos.tf/";
#[must_use]
pub fn new() -> Self {
ApiClient::with_base_url(ApiClient::DEMOS_TF_BASE_URL).unwrap_or_else(|_| unreachable!())
}
pub fn with_base_url(base_url: impl IntoUrl) -> Result<Self, Error> {
ApiClient::with_base_url_and_timeout(base_url, Duration::from_secs(15))
}
pub fn with_base_url_and_timeout(
base_url: impl IntoUrl,
timeout: Duration,
) -> Result<Self, Error> {
let mut base_url = base_url.into_url().map_err(|_| Error::InvalidBaseUrl)?;
if !base_url.path().ends_with("/") {
base_url.set_path(&format!("{}/", base_url.path()));
}
Ok(ApiClient {
base_timeout: timeout,
client: Client::builder().timeout(timeout).build()?,
base_url,
})
}
fn url<P: AsRef<str>>(&self, path: P) -> Result<Url, Error> {
self.base_url
.join(path.as_ref())
.map_err(|_| Error::InvalidBaseUrl)
}
#[instrument]
pub async fn list(&self, params: ListParams, page: u32) -> Result<Vec<Demo>, Error> {
self.list_url(self.url("demos")?, params, page).await
}
#[instrument]
pub async fn list_uploads(
&self,
uploader: SteamID,
params: ListParams,
page: u32,
) -> Result<Vec<Demo>, Error> {
self.list_url(
self.url(format!("uploads/{}", u64::from(uploader)))?,
params,
page,
)
.await
}
async fn list_url(&self, url: Url, params: ListParams, page: u32) -> Result<Vec<Demo>, Error> {
if page == 0 {
return Err(Error::InvalidPage);
}
Ok(self
.client
.get(url)
.query(&[("page", page)])
.query(¶ms)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
#[instrument]
pub async fn get(&self, demo_id: u32) -> Result<Demo, Error> {
let response = self
.client
.get(self.url(format!("/demos/{}", demo_id))?)
.send()
.await?;
if response.status() == StatusCode::NOT_FOUND {
return Err(Error::DemoNotFound(demo_id));
}
Ok(response.error_for_status()?.json().await?)
}
#[instrument]
pub async fn get_user(&self, user_id: u32) -> Result<User, Error> {
let response = self
.client
.get(self.url(format!("/users/{}", user_id))?)
.send()
.await?;
if response.status() == StatusCode::NOT_FOUND {
return Err(Error::UserNotFound(user_id));
}
Ok(response.error_for_status()?.json().await?)
}
#[instrument]
pub async fn get_chat(&self, demo_id: u32) -> Result<Vec<ChatMessage>, Error> {
let response = self
.client
.get(self.url(format!("/demos/{}/chat", demo_id))?)
.send()
.await?;
if response.status() == StatusCode::NOT_FOUND {
return Err(Error::DemoNotFound(demo_id));
}
Ok(response.error_for_status()?.json().await?)
}
#[instrument]
pub async fn set_url(
&self,
demo_id: u32,
backend: &str,
path: &str,
url: &str,
hash: [u8; 16],
key: &str,
) -> Result<(), Error> {
let response = self
.client
.post(self.url(format!("/demos/{}/url", demo_id))?)
.form(&[
("hash", hex::encode(hash).as_str()),
("backend", backend),
("url", url),
("path", path),
("key", key),
])
.send()
.await?;
if response.status() == StatusCode::NOT_FOUND {
return Err(Error::DemoNotFound(demo_id));
}
response.error_for_status()?;
Ok(())
}
#[instrument(skip(body))]
pub async fn upload_demo(
&self,
file_name: String,
body: Vec<u8>,
red: String,
blue: String,
key: String,
) -> Result<u32, Error> {
let form = multipart::Form::new()
.text("red", red)
.text("blue", blue)
.text("name", file_name)
.text("key", key);
let file = multipart::Part::bytes(body)
.file_name("demo.dem")
.mime_str("text/plain")?;
let form = form.part("demo", file);
let resp = self
.client
.post(self.url("/upload")?)
.multipart(form)
.send()
.await?
.error_for_status()?
.text()
.await?;
if resp == "Invalid key" {
return Err(Error::InvalidApiKey);
}
let tail = resp.split('/').last().unwrap_or_default();
u32::from_str(tail).map_err(|_| Error::InvalidResponse(resp))
}
pub(crate) async fn download_demo(&self, url: &str, duration: u16) -> Result<Response, Error> {
let timeout_scale = (f32::from(duration) / 60.0).max(15.0) / 15.0;
let timeout = Duration::from_secs_f32(self.base_timeout.as_secs_f32() * timeout_scale);
trace!(url = url, timeout = debug(timeout), "requesting demo file");
Ok(self
.client
.get(url)
.timeout(timeout)
.send()
.await?
.error_for_status()?)
}
}
#[test]
fn test_url() {
assert_eq!(
"https://example.com/demos",
ApiClient::with_base_url("https://example.com")
.unwrap()
.url("demos")
.unwrap()
.to_string()
);
assert_eq!(
"https://example.com/demos",
ApiClient::with_base_url("https://example.com/")
.unwrap()
.url("demos")
.unwrap()
.to_string()
);
assert_eq!(
"https://example.com/sub/demos",
ApiClient::with_base_url("https://example.com/sub/")
.unwrap()
.url("demos")
.unwrap()
.to_string()
);
assert_eq!(
"https://example.com/sub/demos",
ApiClient::with_base_url("https://example.com/sub")
.unwrap()
.url("demos")
.unwrap()
.to_string()
);
}