api-client/src/lib.rs
2026-05-31 23:23:50 +02:00

510 lines
13 KiB
Rust

use bytes::Bytes;
pub use client::ApiClient;
use futures_util::{Stream, StreamExt};
use md5::Context;
use reqwest::StatusCode;
use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer};
use std::borrow::Cow;
use std::fmt::{self, Debug, Display, Formatter};
use std::io::Write;
use std::str::FromStr;
pub use steamid_ng::SteamID;
use steamid_ng::{Instance, InstanceFlags, InstanceType};
use thiserror::Error;
use time::OffsetDateTime;
use tinyvec::TinyVec;
use tracing::{debug, error, instrument};
mod client;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
#[error("Invalid base url")]
InvalidBaseUrl,
#[error("Request failed: {0:#}")]
Request(reqwest::Error),
#[error("Invalid page requested")]
InvalidPage,
#[error("Invalid api key")]
InvalidApiKey,
#[error("Hash mismatch")]
HashMisMatch,
#[error("Unknown server error {0}")]
ServerError(u16),
#[error("Invalid response: {0}")]
InvalidResponse(String),
#[error("Demo {0} not found")]
DemoNotFound(u32),
#[error("User {0} not found")]
UserNotFound(u32),
#[error("Error while writing demo data")]
Write(#[source] std::io::Error),
#[error("Operation timed out")]
TimeOut,
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
if error.is_timeout() {
Error::TimeOut
} else {
match error.status() {
Some(StatusCode::UNAUTHORIZED) => Error::InvalidApiKey,
Some(StatusCode::PRECONDITION_FAILED) => Error::HashMisMatch,
Some(status) if status.is_server_error() => Error::ServerError(status.as_u16()),
_ => Error::Request(error),
}
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Data of an uploaded demo
pub struct Demo {
pub id: u32,
pub url: String,
pub name: String,
pub server: String,
pub duration: u16,
pub nick: String,
pub map: String,
#[serde(with = "time::serde::timestamp")]
pub time: OffsetDateTime,
pub red: String,
pub blue: String,
pub red_score: u8,
pub blue_score: u8,
pub player_count: u8,
pub uploader: UserRef,
#[serde(deserialize_with = "hex_to_digest")]
pub hash: [u8; 16],
pub backend: String,
pub path: String,
#[serde(default)]
/// Demos listed using `ApiClient::list` will not have any players set, use `get_players` to automatically
/// load the players when not set
pub players: Option<Vec<Player>>,
}
impl Demo {
/// Return either the stored players info or get the players from the api
#[instrument]
pub async fn get_players(&self, client: &ApiClient) -> Result<Cow<'_, [Player]>, Error> {
match &self.players {
Some(players) => Ok(Cow::Borrowed(players.as_slice())),
None => {
let demo = client.get(self.id).await?;
Ok(Cow::Owned(demo.players.unwrap_or_default()))
}
}
}
/// Download a demo, returning a stream of chunks
#[instrument]
pub async fn download(
&self,
client: &ApiClient,
) -> Result<impl Stream<Item = Result<Bytes, Error>>, Error> {
debug!(id = self.id, url = display(&self.url), "starting download");
Ok(client
.download_demo(&self.url, self.duration)
.await?
.bytes_stream()
.map(|chunk| chunk.map_err(Error::from)))
}
/// Download a demo and save it to a writer, verifying the md5 hash in the process
#[instrument(skip(target))]
pub async fn save<W: Write>(&self, client: &ApiClient, mut target: W) -> Result<(), Error> {
debug!(id = self.id, url = display(&self.url), "starting download");
let mut response = client.download_demo(&self.url, self.duration).await?;
let mut context = Context::new();
while let Some(chunk) = response.chunk().await? {
context.consume(&chunk);
target.write_all(&chunk).map_err(Error::Write)?;
}
let calculated = context.finalize().0;
if calculated != self.hash {
error!(
calculated = display(hex::encode(calculated)),
expected = display(hex::encode(self.hash)),
"hash mismatch"
);
return Err(Error::HashMisMatch);
}
Ok(())
}
}
/// Reference to a user, either contains the full user information or only the user id
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum UserRef {
User(User),
Id(u32),
}
impl UserRef {
/// Id of the user
#[must_use]
pub fn id(&self) -> u32 {
match self {
UserRef::Id(id) | UserRef::User(User { id, .. }) => *id,
}
}
/// Return the stored user info if available
#[must_use]
pub fn user(&self) -> Option<&User> {
match self {
UserRef::Id(_) => None,
UserRef::User(ref user) => Some(user),
}
}
/// Return either the stored user info or get the user information from the api
#[instrument]
pub async fn resolve(&self, client: &ApiClient) -> Result<Cow<'_, User>, Error> {
match self {
UserRef::User(ref user) => Ok(Cow::Borrowed(user)),
UserRef::Id(id) => Ok(Cow::Owned(client.get_user(*id).await?)),
}
}
}
/// User data
#[derive(Clone, Debug, Deserialize)]
pub struct User {
pub id: u32,
#[serde(rename = "steamid", deserialize_with = "deserialize_steamid")]
pub steam_id: SteamID,
pub name: String,
}
/// Data of a player in a demo
#[derive(Clone, Debug, Deserialize)]
pub struct Player {
#[serde(rename = "id")]
pub player_id: u32,
#[serde(flatten)]
#[serde(deserialize_with = "deserialize_nested_user")]
pub user: User,
pub team: Team,
/// If a player has played multiple classes, the class which the user spawned the most as is taken
pub class: Class,
pub kills: u8,
pub assists: u8,
pub deaths: u8,
}
#[derive(Clone, Debug, Deserialize)]
struct NestedPlayerUser {
user_id: u32,
#[serde(rename = "steamid", deserialize_with = "deserialize_steamid")]
steam_id: SteamID,
name: String,
}
fn deserialize_nested_user<'de, D>(deserializer: D) -> Result<User, D::Error>
where
D: Deserializer<'de>,
{
let nested = NestedPlayerUser::deserialize(deserializer)?;
Ok(User {
id: nested.user_id,
steam_id: nested.steam_id,
name: nested.name,
})
}
fn deserialize_steamid<'de, D>(deserializer: D) -> Result<SteamID, D::Error>
where
D: Deserializer<'de>,
{
let s = <Cow<'static, str>>::deserialize(deserializer)?;
SteamID::from_str(&s).map_err(D::Error::custom)
}
/// Player team, red or blue
#[derive(Clone, Copy, Debug, Deserialize, PartialOrd, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Team {
Red,
Blue,
}
/// Player class
#[derive(Clone, Copy, Debug, Deserialize, PartialOrd, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Class {
Scout,
Soldier,
Pyro,
Demoman,
HeavyWeapons,
Engineer,
Medic,
Sniper,
Spy,
}
/// Deserializes a lowercase hex string to a `[u8; 16]`.
fn hex_to_digest<'de, D>(deserializer: D) -> Result<[u8; 16], D::Error>
where
D: Deserializer<'de>,
{
use hex::FromHex;
use serde::de::Error;
let string = <&str>::deserialize(deserializer)?;
if string.is_empty() {
return Ok([0; 16]);
}
<[u8; 16]>::from_hex(string).map_err(|err| Error::custom(err.to_string()))
}
/// Chat message send in the demo
#[derive(Clone, Debug, Deserialize)]
pub struct ChatMessage {
pub user: String,
pub time: u32,
pub message: String,
}
/// Order for listing demos
#[derive(Debug, Clone, Copy, Serialize, Default)]
#[serde(into = "&str")]
pub enum ListOrder {
Ascending,
#[default]
Descending,
}
/// Game type as recognized by demos.tf, HL, Prolander, 6s or 4v4
#[derive(Debug, Clone, Copy, Serialize)]
pub enum GameType {
#[serde(rename = "hl")]
HL,
#[serde(rename = "prolander")]
Prolander,
#[serde(rename = "6v6")]
Sixes,
#[serde(rename = "4v4")]
Fours,
}
impl Display for ListOrder {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
Display::fmt(<&str>::from(*self), f)
}
}
impl From<ListOrder> for &str {
fn from(order: ListOrder) -> Self {
match order {
ListOrder::Ascending => "ASC",
ListOrder::Descending => "DESC",
}
}
}
/// Parameters for demo list command
#[derive(Debug, Default, Serialize)]
pub struct ListParams {
order: ListOrder,
backend: Option<String>,
map: Option<String>,
players: PlayerList,
#[serde(rename = "type")]
ty: Option<GameType>,
#[serde(serialize_with = "serialize_option_time")]
after: Option<OffsetDateTime>,
#[serde(serialize_with = "serialize_option_time")]
before: Option<OffsetDateTime>,
before_id: Option<u64>,
after_id: Option<u64>,
}
fn serialize_option_time<S>(dt: &Option<OffsetDateTime>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match dt {
Some(time) => time::serde::timestamp::serialize(time, serializer),
None => Option::<i64>::serialize(&None, serializer),
}
}
#[derive(Debug, Default)]
struct PlayerList(TinyVec<[Option<SteamID>; 2]>);
impl PlayerList {
fn new<T: IntoSteamId, I: IntoIterator<Item = T>>(players: I) -> Self {
PlayerList(
players
.into_iter()
.map(IntoSteamId::into_steam_id)
.map(Some)
.collect(),
)
}
}
impl Display for PlayerList {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut first = true;
for steam_id in self.0.iter().flatten() {
if first {
first = false;
write!(f, "{}", steam_id.steam64())?;
} else {
write!(f, ",{}", steam_id.steam64())?;
}
}
Ok(())
}
}
impl Serialize for PlayerList {
fn serialize<S>(&self, serializer: S) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error>
where
S: Serializer,
{
serializer.collect_str(&self)
}
}
#[test]
fn test_serialize_player_list() {
fn id(id: u64) -> SteamID {
SteamID::from_steam64(id).unwrap()
}
assert_eq!(
"76561198024494988",
PlayerList::new([id(76561198024494988)]).to_string()
);
assert_eq!(
"76561198024494988,76561197963701107",
PlayerList::new([id(76561198024494988), id(76561197963701107)]).to_string()
);
assert_eq!(
"76561198024494988,76561197963701107,76561197963701106",
PlayerList::new([
id(76561198024494988),
id(76561197963701107),
id(76561197963701106)
])
.to_string()
);
}
pub trait IntoSteamId {
fn into_steam_id(self) -> SteamID;
}
impl IntoSteamId for SteamID {
fn into_steam_id(self) -> SteamID {
self
}
}
impl IntoSteamId for u64 {
fn into_steam_id(self) -> SteamID {
SteamID::from_steam64(self).unwrap_or(SteamID::new(
0,
Instance::new(InstanceType::All, InstanceFlags::None),
steamid_ng::AccountType::Invalid,
steamid_ng::Universe::Invalid,
))
}
}
impl ListParams {
/// Specify the backend name to filter demos with
#[must_use]
pub fn with_backend(self, backend: impl Into<String>) -> Self {
ListParams {
backend: Some(backend.into()),
..self
}
}
/// Specify the map name to filter demos with
#[must_use]
pub fn with_map(self, map: impl Into<String>) -> Self {
ListParams {
map: Some(map.into()),
..self
}
}
/// Specify the player steam ids to filter demos with
#[must_use]
pub fn with_players<T: IntoSteamId, I: IntoIterator<Item = T>>(self, players: I) -> Self {
ListParams {
players: PlayerList::new(players),
..self
}
}
/// Specify the game type to filter demos with
#[must_use]
pub fn with_type(self, ty: GameType) -> Self {
ListParams {
ty: Some(ty),
..self
}
}
/// Specify the before date to filter demos with
#[must_use]
pub fn with_before(self, before: OffsetDateTime) -> Self {
ListParams {
before: Some(before),
..self
}
}
/// Specify the after date to filter demos with
#[must_use]
pub fn with_after(self, after: OffsetDateTime) -> Self {
ListParams {
after: Some(after),
..self
}
}
/// Specify the maximum demo id to filter demos with
#[must_use]
pub fn with_before_id(self, before: u64) -> Self {
ListParams {
before_id: Some(before),
..self
}
}
/// Specify the minimum demo id to filter demos with
#[must_use]
pub fn with_after_id(self, after: u64) -> Self {
ListParams {
after_id: Some(after),
..self
}
}
/// Specify the sort
#[must_use]
pub fn with_order(self, order: ListOrder) -> Self {
ListParams { order, ..self }
}
}