mirror of
https://codeberg.org/demostf/api-client.git
synced 2026-06-03 16:44:09 +02:00
initial version
This commit is contained in:
parent
e29f3e4b4c
commit
9303972319
2 changed files with 302 additions and 4 deletions
|
|
@ -7,3 +7,12 @@ edition = "2018"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
reqwest = { version = "0.10", features = ["json"] }
|
||||||
|
thiserror = "1.0"
|
||||||
|
hex = "0.4"
|
||||||
|
steamid-ng = "0.3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "0.2", features = ["macros"] }
|
||||||
295
src/lib.rs
295
src/lib.rs
|
|
@ -1,7 +1,296 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
use reqwest::{Client, IntoUrl, Url};
|
||||||
|
use thiserror::Error;
|
||||||
|
use steamid_ng::SteamID;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Invalid base url: {0}")]
|
||||||
|
InvalidBaseUrl(#[source] reqwest::Error),
|
||||||
|
#[error("Request failed: {0}")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
#[error("MD5 digest mismatch for downloaded demo, expected {expected:?}, received {got:?}")]
|
||||||
|
DigestMismatch { expected: [u8; 16], got: [u8; 16] },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
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 = "chrono::serde::ts_seconds")]
|
||||||
|
pub time: DateTime<Utc>,
|
||||||
|
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)]
|
||||||
|
pub players: Vec<Player>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
pub fn id(&self) -> u32 {
|
||||||
|
match self {
|
||||||
|
UserRef::Id(id) => *id,
|
||||||
|
UserRef::User(User { id, .. }) => *id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the stored user info if available
|
||||||
|
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
|
||||||
|
pub async fn resolve<'a>(&'a self, client: &ApiClient) -> Result<Cow<'a, User>, Error> {
|
||||||
|
match self {
|
||||||
|
UserRef::User(ref user) => Ok(Cow::Borrowed(user)),
|
||||||
|
UserRef::Id(id) => Ok(Cow::Owned(client.get_user(*id).await?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
id: u32,
|
||||||
|
#[serde(rename = "steamid")]
|
||||||
|
steam_id: SteamID,
|
||||||
|
name: String,
|
||||||
|
avatar: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct Player {
|
||||||
|
#[serde(rename = "id")]
|
||||||
|
player_id: u32,
|
||||||
|
#[serde(flatten)]
|
||||||
|
#[serde(deserialize_with = "deserialize_nested_user")]
|
||||||
|
user: User,
|
||||||
|
team: Team,
|
||||||
|
class: Class,
|
||||||
|
kills: u8,
|
||||||
|
assists: u8,
|
||||||
|
deaths: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
struct NestedPlayerUser {
|
||||||
|
user_id: u32,
|
||||||
|
#[serde(rename = "steamid")]
|
||||||
|
steam_id: SteamID,
|
||||||
|
name: String,
|
||||||
|
avatar: 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,
|
||||||
|
avatar: nested.avatar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Team {
|
||||||
|
Red,
|
||||||
|
Blue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[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.len() == 0 {
|
||||||
|
return Ok([0; 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
<[u8; 16]>::from_hex(string).map_err(|err| Error::custom(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
#[serde(into = "&str")]
|
||||||
|
pub enum ListOrder {
|
||||||
|
Ascending,
|
||||||
|
Descending,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ListOrder {
|
||||||
|
fn default() -> Self {
|
||||||
|
ListOrder::Descending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ListOrder {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
<&str>::from(*self).fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ListOrder> for &str {
|
||||||
|
fn from(order: ListOrder) -> Self {
|
||||||
|
match order {
|
||||||
|
ListOrder::Ascending => "ASC",
|
||||||
|
ListOrder::Descending => "DESC",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize)]
|
||||||
|
pub struct ListParams {
|
||||||
|
order: ListOrder,
|
||||||
|
backend: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListParams {
|
||||||
|
pub fn with_backend(self, backend: impl ToString) -> Self {
|
||||||
|
ListParams {
|
||||||
|
backend: Some(backend.to_string()),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_order(self, order: ListOrder) -> Self {
|
||||||
|
ListParams { order, ..self }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApiClient {
|
||||||
|
client: Client,
|
||||||
|
base_url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ApiClient {
|
||||||
|
fn default() -> Self {
|
||||||
|
ApiClient::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClient {
|
||||||
|
const DEMOS_TF_BASE_URL: &'static str = "https://api.demos.tf";
|
||||||
|
|
||||||
|
pub fn new() -> Self {
|
||||||
|
ApiClient::with_base_url(ApiClient::DEMOS_TF_BASE_URL).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_base_url(base_url: impl IntoUrl) -> Result<Self, Error> {
|
||||||
|
Ok(ApiClient {
|
||||||
|
client: Client::new(),
|
||||||
|
base_url: base_url.into_url().map_err(Error::InvalidBaseUrl)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(&self, params: ListParams, page: u32) -> Result<Vec<Demo>, Error> {
|
||||||
|
let mut url = self.base_url.clone();
|
||||||
|
url.set_path("/demos");
|
||||||
|
Ok(self.client.get(url)
|
||||||
|
.query(&[("page", page)])
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, demo_id: u32) -> Result<Demo, Error> {
|
||||||
|
let mut url = self.base_url.clone();
|
||||||
|
url.set_path(&format!("/demos/{}", demo_id));
|
||||||
|
Ok(self.client.get(url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user(&self, user_id: u32) -> Result<User, Error> {
|
||||||
|
let mut url = self.base_url.clone();
|
||||||
|
url.set_path(&format!("/users/{}", user_id));
|
||||||
|
Ok(self.client.get(url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
use crate::{ApiClient, ListParams, ListOrder};
|
||||||
fn it_works() {
|
use steamid_ng::SteamID;
|
||||||
assert_eq!(2 + 2, 4);
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_demos() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
|
||||||
|
let demos = client.list(ListParams::default().with_order(ListOrder::Ascending), 1).await.unwrap();
|
||||||
|
assert_eq!(demos[0].id, 9);
|
||||||
|
assert_eq!(demos[0].uploader.id(), 1);
|
||||||
|
assert!(demos[0].uploader.user().is_none());
|
||||||
|
assert_eq!(demos[0].uploader.resolve(&client).await.unwrap().steam_id, SteamID::from(76561198024494988));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_demo() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
|
||||||
|
let demo = client.get(9).await.unwrap();
|
||||||
|
assert_eq!(demo.id, 9);
|
||||||
|
assert_eq!(demo.uploader.id(), 1);
|
||||||
|
assert!(demo.uploader.user().is_some());
|
||||||
|
assert_eq!(demo.uploader.user().unwrap().steam_id, SteamID::from(76561198024494988));
|
||||||
|
assert_eq!(demo.uploader.resolve(&client).await.unwrap().steam_id, SteamID::from(76561198024494988));
|
||||||
|
|
||||||
|
assert_eq!(demo.players[0].player_id, 623);
|
||||||
|
assert_eq!(demo.players[0].user.id, 346);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue