add download convenience method

This commit is contained in:
Robin Appelman 2022-05-09 22:10:48 +02:00
commit 1f752f36c7
5 changed files with 128 additions and 10 deletions

View file

@ -33,7 +33,7 @@ jobs:
api: api:
image: demostf/api image: demostf/api
env: env:
DEMO_ROOT: /tmp DEMO_ROOT: /demos
DEMO_HOST: localhost DEMO_HOST: localhost
DB_TYPE: pgsql DB_TYPE: pgsql
DB_HOST: api-test-db DB_HOST: api-test-db
@ -43,12 +43,16 @@ jobs:
DB_PASSWORD: test DB_PASSWORD: test
APP_ROOT: https://api.localhost APP_ROOT: https://api.localhost
EDIT_SECRET: edit EDIT_SECRET: edit
volumes:
- /tmp:/demos
api-test: api-test:
image: demostf/api-nginx-test image: demostf/api-nginx-test
env: env:
POSTGRES_PASSWORD: test POSTGRES_PASSWORD: test
ports: ports:
- 80:80 - 80:80
volumes:
- /tmp:/demos
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View file

@ -1,6 +1,6 @@
[package] [package]
name = "demostf-client" name = "demostf-client"
version = "0.3.2" version = "0.3.3"
authors = ["Robin Appelman <robin@icewind.nl>"] authors = ["Robin Appelman <robin@icewind.nl>"]
edition = "2018" edition = "2018"
description = "Api client for demos.tf" description = "Api client for demos.tf"
@ -22,6 +22,7 @@ bytes = "1.1.0"
futures-util = "0.3.21" futures-util = "0.3.21"
tracing = "0.1.33" tracing = "0.1.33"
tinyvec = "1.5.1" tinyvec = "1.5.1"
md5 = "0.7.0"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["macros"] } tokio = { version = "1", features = ["macros"] }

View file

@ -1,15 +1,17 @@
use bytes::Bytes; use bytes::Bytes;
use futures_util::{Stream, StreamExt}; use futures_util::{Stream, StreamExt};
use md5::Context;
use reqwest::{multipart, Client, IntoUrl, StatusCode, Url}; use reqwest::{multipart, Client, IntoUrl, StatusCode, Url};
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::borrow::Cow; use std::borrow::Cow;
use std::fmt::{self, Debug, Display, Formatter}; use std::fmt::{self, Debug, Display, Formatter};
use std::io::Write;
use std::str::FromStr; use std::str::FromStr;
pub use steamid_ng::SteamID; pub use steamid_ng::SteamID;
use thiserror::Error; use thiserror::Error;
use time::OffsetDateTime; use time::OffsetDateTime;
use tinyvec::TinyVec; use tinyvec::TinyVec;
use tracing::{debug, instrument}; use tracing::{debug, error, instrument};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
@ -29,6 +31,8 @@ pub enum Error {
InvalidResponse(String), InvalidResponse(String),
#[error("Demo {0} not found")] #[error("Demo {0} not found")]
DemoNotFound(u32), DemoNotFound(u32),
#[error("Error while writing demo data")]
Write(#[source] std::io::Error),
} }
impl From<reqwest::Error> for Error { impl From<reqwest::Error> for Error {
@ -85,14 +89,46 @@ impl Demo {
&self, &self,
client: &ApiClient, client: &ApiClient,
) -> Result<impl Stream<Item = Result<Bytes, Error>>, Error> { ) -> Result<impl Stream<Item = Result<Bytes, Error>>, Error> {
debug!(id = self.id, "starting download"); debug!(id = self.id, url = display(&self.url), "starting download");
Ok(client Ok(client
.client .client
.get(&self.url) .get(&self.url)
.send() .send()
.await? .await?
.error_for_status()?
.bytes_stream() .bytes_stream()
.map(|chunk| Ok(chunk?))) .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
.client
.get(&self.url)
.send()
.await?
.error_for_status()?;
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.compute().0;
if calculated != self.hash {
error!(
calculated = display(hex::encode(calculated)),
expected = display(hex::encode(self.hash)),
"hash mismatch"
);
return Err(Error::HashMisMatch);
}
Ok(())
} }
} }
@ -586,7 +622,14 @@ impl ApiClient {
pub async fn get_user(&self, user_id: u32) -> Result<User, Error> { pub async fn get_user(&self, user_id: u32) -> Result<User, Error> {
let mut url = self.base_url.clone(); let mut url = self.base_url.clone();
url.set_path(&format!("/users/{}", user_id)); url.set_path(&format!("/users/{}", user_id));
Ok(self.client.get(url).send().await?.json().await?) Ok(self
.client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?)
} }
/// List demos with the provided options /// List demos with the provided options
@ -612,7 +655,14 @@ impl ApiClient {
pub async fn get_chat(&self, demo_id: u32) -> Result<Vec<ChatMessage>, Error> { pub async fn get_chat(&self, demo_id: u32) -> Result<Vec<ChatMessage>, Error> {
let mut url = self.base_url.clone(); let mut url = self.base_url.clone();
url.set_path(&format!("/demos/{}/chat", demo_id)); url.set_path(&format!("/demos/{}/chat", demo_id));
Ok(self.client.get(url).send().await?.json().await?) Ok(self
.client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?)
} }
#[instrument] #[instrument]

View file

@ -5,12 +5,12 @@ docker rm -f api-test-fpm
docker rm -f api-test docker rm -f api-test
mkdir -p /tmp/api-test-data mkdir -p /tmp/api-test-data
chmod 0777 /tmp/api-test-data chmod -R 0777 /tmp/api-test-data
docker run -d --name api-test-db -e POSTGRES_PASSWORD=test -p 15432:5432 demostf/db docker run -d --name api-test-db -e POSTGRES_PASSWORD=test -p 15432:5432 demostf/db
docker run -d --name api-test-fpm --link api-test-db:db -v /tmp/api-test-data:/demos \ docker run -d --name api-test-fpm --link api-test-db:db -v /tmp/api-test-data:/demos \
-e DEMO_ROOT=/demos -e DEMO_HOST=localhost -e DB_TYPE=pgsql \ -e DEMO_ROOT=/demos -e DEMO_HOST=localhost -e DB_TYPE=pgsql \
-e DB_HOST=db -e DB_PORT=5432 -e DB_DATABASE=postgres -e DB_USERNAME=postgres \ -e DB_HOST=db -e DB_PORT=5432 -e DB_DATABASE=postgres -e DB_USERNAME=postgres \
-e DB_PASSWORD=test -e APP_ROOT=http://api.localhost -e EDIT_SECRET=edit \ -e DB_PASSWORD=test -e APP_ROOT=http://localhost:8888 -e EDIT_SECRET=edit \
demostf/api demostf/api
docker run -d --name api-test --link api-test-fpm:api -p 8888:80 demostf/api-nginx-test docker run -d --name api-test --link api-test-fpm:api -v /tmp/api-test-data:/demos -p 8888:80 demostf/api-nginx-test

View file

@ -1,5 +1,6 @@
use demostf_client::{ApiClient, Error, ListOrder, ListParams}; use demostf_client::{ApiClient, Error, ListOrder, ListParams};
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use std::fs::read;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use steamid_ng::SteamID; use steamid_ng::SteamID;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -205,6 +206,47 @@ async fn test_set_url_invalid_key() {
assert!(matches!(res.unwrap_err(), Error::InvalidApiKey)); assert!(matches!(res.unwrap_err(), Error::InvalidApiKey));
} }
#[tokio::test]
async fn test_set_url_invalid_hash() {
let client = test_client().await;
let res = client
.set_url(
1,
"tests",
"tests",
"http://example.com/tests",
[0; 16],
"edit",
)
.await;
assert!(matches!(res.unwrap_err(), Error::HashMisMatch));
}
#[tokio::test]
async fn test_set_url() {
let client = test_client().await;
let demo = client.get(1).await.unwrap();
client
.set_url(
1,
"example",
"tests",
"http://example.com/tests",
demo.hash,
"edit",
)
.await
.unwrap();
let moved = client.get(1).await.unwrap();
assert_eq!(moved.backend, "example");
assert_eq!(moved.path, "tests");
assert_eq!(moved.url, "http://example.com/tests");
}
#[tokio::test] #[tokio::test]
async fn test_get_demo_not_found() { async fn test_get_demo_not_found() {
let client = test_client().await; let client = test_client().await;
@ -267,3 +309,24 @@ async fn test_list_players() {
.unwrap(); .unwrap();
assert_eq!(demos.len(), 0); assert_eq!(demos.len(), 0);
} }
#[tokio::test]
async fn test_download_demo() {
let client = test_client().await;
let mut demo = client.get(1).await.unwrap();
let demos_url =
std::env::var("API_ROOT").unwrap_or_else(|_| "http://localhost:8888/".to_string());
// fixup the url to one that is actually usable
demo.url = format!(
"{}static/01/b2/01b2265d875026b91d59a2785abfd50d_test.dem",
demos_url
);
let mut data: Vec<u8> = Vec::new();
demo.save(&client, &mut data).await.unwrap();
assert_eq!(data.len(), read("./tests/data/gully.dem").unwrap().len());
}