Basic /image and /image/backgrounded tests
This commit is contained in:
parent
7a21618a54
commit
7c766ab434
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
/target
|
||||
/.direnv
|
||||
/.envrc
|
||||
/result
|
||||
|
|
1193
Cargo.lock
generated
1193
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
14
Cargo.toml
14
Cargo.toml
|
@ -6,3 +6,17 @@ edition = "2021"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-rt = "2.8.0"
|
||||
awc = { version = "3.1.1", default-features = false }
|
||||
color-eyre = "0.6.2"
|
||||
mime = "0.3.17"
|
||||
multipart-client-stream = "0.1.0"
|
||||
serde = { version = "1.0.173", features = ["derive"] }
|
||||
time = { version = "0.3.23", features = ["formatting", "parsing", "serde-well-known"] }
|
||||
tokio = { version = "1.29.1", features = ["fs"] }
|
||||
tracing = "0.1.37"
|
||||
url = { version = "2.4.0", features = ["serde"] }
|
||||
uuid = { version = "1.4.1", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
|
BIN
images/valid/Pikachu-animated.gif
Normal file
BIN
images/valid/Pikachu-animated.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
images/valid/Shenzi-animated.apng
Normal file
BIN
images/valid/Shenzi-animated.apng
Normal file
Binary file not shown.
After Width: | Height: | Size: 1 MiB |
BIN
images/valid/Shenzi-animated.avif
Normal file
BIN
images/valid/Shenzi-animated.avif
Normal file
Binary file not shown.
BIN
images/valid/Shenzi-animated.webp
Normal file
BIN
images/valid/Shenzi-animated.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 100 KiB |
BIN
images/valid/Shenzi.avif
Normal file
BIN
images/valid/Shenzi.avif
Normal file
Binary file not shown.
BIN
images/valid/Shenzi.jpeg
Normal file
BIN
images/valid/Shenzi.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 129 KiB |
BIN
images/valid/Shenzi.jxl
Normal file
BIN
images/valid/Shenzi.jxl
Normal file
Binary file not shown.
BIN
images/valid/Shenzi.png
Normal file
BIN
images/valid/Shenzi.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 520 KiB |
BIN
images/valid/Shenzi.webp
Normal file
BIN
images/valid/Shenzi.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
207
src/main.rs
207
src/main.rs
|
@ -1,3 +1,208 @@
|
|||
fn main() {
|
||||
mod types;
|
||||
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use awc::{http::StatusCode, Client, ClientResponse};
|
||||
use multipart_client_stream::{Body, Part};
|
||||
use types::{Alias, DeleteToken, Image, ImageResponse, Upload, UploadResponse};
|
||||
use url::Url;
|
||||
|
||||
#[actix_rt::main]
|
||||
async fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
let client = pict_rs_client();
|
||||
|
||||
let endpoint: Url = "http://localhost:8080".parse()?;
|
||||
|
||||
let mut readdir = tokio::fs::read_dir("./images/valid").await?;
|
||||
let mut valid_paths = Vec::new();
|
||||
while let Some(entry) = readdir.next_entry().await? {
|
||||
valid_paths.push(entry.path());
|
||||
}
|
||||
let images = upload_backgrounded(&client, &endpoint, &valid_paths)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(check_response)
|
||||
.collect::<color_eyre::Result<Vec<Vec<Image>>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
// TODO: head images
|
||||
// TODO: fetch images
|
||||
// TODO: fetch details
|
||||
// TODO: process images
|
||||
for Image {
|
||||
file, delete_token, ..
|
||||
} in images
|
||||
{
|
||||
delete_image(&client, &endpoint, &file, &delete_token).await?;
|
||||
}
|
||||
let images = check_response(upload_inline(&client, &endpoint, &valid_paths).await?)?;
|
||||
// TODO: head images
|
||||
// TODO: fetch images
|
||||
// TODO: fetch details
|
||||
// TODO: process images
|
||||
for Image {
|
||||
file, delete_token, ..
|
||||
} in images
|
||||
{
|
||||
delete_image(&client, &endpoint, &file, &delete_token).await?;
|
||||
}
|
||||
|
||||
// TODO: Healthz
|
||||
|
||||
// TODO: Invalid Images
|
||||
|
||||
// TODO: Internal APIs
|
||||
|
||||
println!("Hello, world!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pict_rs_client() -> Client {
|
||||
Client::builder()
|
||||
.disable_redirects()
|
||||
.add_default_header(("user-agent", "pict-rs-integration-tests v0.1.0"))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(client, images))]
|
||||
async fn post_images<P: AsRef<Path>>(
|
||||
client: &Client,
|
||||
endpoint: &Url,
|
||||
images: &[P],
|
||||
) -> color_eyre::Result<ClientResponse> {
|
||||
let mut builder = Body::builder();
|
||||
|
||||
for image in images {
|
||||
let filename = image
|
||||
.as_ref()
|
||||
.file_name()
|
||||
.ok_or(color_eyre::eyre::eyre!("No file name"))?
|
||||
.to_str()
|
||||
.ok_or(color_eyre::eyre::eyre!("Invalid filename string"))?;
|
||||
|
||||
let file = tokio::fs::File::open(image).await?;
|
||||
|
||||
builder = builder.append(Part::new("images[]", file).filename(filename));
|
||||
}
|
||||
|
||||
let body = builder.build();
|
||||
|
||||
let response = client
|
||||
.post(endpoint.as_str())
|
||||
.insert_header(("content-type", body.content_type()))
|
||||
.send_stream(body)
|
||||
.await
|
||||
.map_err(|e| color_eyre::eyre::eyre!("{e}"))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(client, images))]
|
||||
async fn upload_inline<P: AsRef<Path>>(
|
||||
client: &Client,
|
||||
endpoint: &Url,
|
||||
images: &[P],
|
||||
) -> color_eyre::Result<ImageResponse> {
|
||||
let mut endpoint = endpoint.clone();
|
||||
endpoint.set_path("/image");
|
||||
|
||||
let mut response = post_images(client, &endpoint, images).await?;
|
||||
|
||||
let image_response: ImageResponse = response.json().await?;
|
||||
|
||||
Ok(image_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(client, images))]
|
||||
async fn upload_backgrounded<P: AsRef<Path>>(
|
||||
client: &Client,
|
||||
endpoint: &Url,
|
||||
images: &[P],
|
||||
) -> color_eyre::Result<Vec<ImageResponse>> {
|
||||
let mut endpoint = endpoint.clone();
|
||||
endpoint.set_path("/image/backgrounded");
|
||||
|
||||
let mut response = post_images(client, &endpoint, images).await?;
|
||||
|
||||
let upload_response: UploadResponse = response.json().await?;
|
||||
|
||||
let uploads = match upload_response {
|
||||
UploadResponse::Ok { uploads, .. } => uploads,
|
||||
UploadResponse::Error { msg } => return Err(color_eyre::eyre::eyre!("{msg}")),
|
||||
};
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for upload in uploads {
|
||||
let client = client.clone();
|
||||
let mut endpoint = endpoint.clone();
|
||||
|
||||
endpoint.set_path("/image/backgrounded/claim");
|
||||
|
||||
tasks.push(actix_rt::spawn(async move {
|
||||
claim_image(&client, &endpoint, &upload).await
|
||||
}));
|
||||
}
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for task in tasks {
|
||||
responses.push(task.await??);
|
||||
}
|
||||
|
||||
Ok(responses)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(client))]
|
||||
async fn claim_image(
|
||||
client: &Client,
|
||||
endpoint: &Url,
|
||||
upload: &Upload,
|
||||
) -> color_eyre::Result<ImageResponse> {
|
||||
loop {
|
||||
let mut response = client
|
||||
.get(endpoint.as_str())
|
||||
.timeout(Duration::from_secs(15))
|
||||
.query(upload)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| color_eyre::eyre::eyre!("{e}"))?;
|
||||
|
||||
if response.status() == StatusCode::NO_CONTENT {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(response.json().await?);
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_image(
|
||||
client: &Client,
|
||||
endpoint: &Url,
|
||||
alias: &Alias,
|
||||
delete_token: &DeleteToken,
|
||||
) -> color_eyre::Result<()> {
|
||||
let mut endpoint = endpoint.clone();
|
||||
endpoint.set_path(&format!("/image/delete/{delete_token}/{alias}"));
|
||||
let response = client
|
||||
.delete(endpoint.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| color_eyre::eyre::eyre!("{e}"))?;
|
||||
|
||||
if response.status() != StatusCode::NO_CONTENT {
|
||||
return Err(color_eyre::eyre::eyre!("Failed to delete image"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_response(response: ImageResponse) -> color_eyre::Result<Vec<Image>> {
|
||||
match response {
|
||||
ImageResponse::Ok { files, .. } => Ok(files),
|
||||
ImageResponse::Error { msg } => Err(color_eyre::eyre::eyre!("{msg}")),
|
||||
}
|
||||
}
|
||||
|
|
212
src/types.rs
Normal file
212
src/types.rs
Normal file
|
@ -0,0 +1,212 @@
|
|||
use serde::de::Error;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum UploadResponse {
|
||||
Ok {
|
||||
#[allow(unused)]
|
||||
msg: OkString,
|
||||
uploads: Vec<Upload>,
|
||||
},
|
||||
Error {
|
||||
msg: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum ImageResponse {
|
||||
Ok {
|
||||
#[allow(unused)]
|
||||
msg: OkString,
|
||||
files: Vec<Image>,
|
||||
},
|
||||
Error {
|
||||
msg: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub(crate) struct Upload {
|
||||
pub(crate) upload_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(crate) enum OkString {
|
||||
#[serde(rename = "ok")]
|
||||
Ok,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub(crate) struct Image {
|
||||
pub(crate) delete_token: DeleteToken,
|
||||
pub(crate) file: Alias,
|
||||
pub(crate) details: Details,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum DeleteToken {
|
||||
Uuid(Uuid),
|
||||
Old(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum Alias {
|
||||
Alias(AliasInner),
|
||||
Old(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AliasInner {
|
||||
uuid: Uuid,
|
||||
extension: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AliasError;
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub(crate) struct Details {
|
||||
pub(crate) width: u16,
|
||||
pub(crate) height: u16,
|
||||
pub(crate) frames: Option<u32>,
|
||||
pub(crate) content_type: Mime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub(crate) created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Mime(mime::Mime);
|
||||
|
||||
impl std::ops::Deref for Mime {
|
||||
type Target = mime::Mime;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<mime::Mime> for Mime {
|
||||
fn as_ref(&self) -> &mime::Mime {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for AliasInner {
|
||||
type Err = AliasError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if let Some((left, right)) = s.split_once('.') {
|
||||
let uuid = left.parse::<Uuid>().map_err(|_| AliasError)?;
|
||||
|
||||
Ok(AliasInner {
|
||||
uuid,
|
||||
extension: String::from(right),
|
||||
})
|
||||
} else {
|
||||
Err(AliasError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Alias {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Alias(inner) => inner.fmt(f),
|
||||
Self::Old(s) => s.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeleteToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Uuid(u) => u.fmt(f),
|
||||
Self::Old(s) => s.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AliasInner {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}.{}", self.uuid, self.extension)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AliasError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Invalid alias format")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AliasError {}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for AliasInner {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
|
||||
s.parse::<AliasInner>().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for AliasInner {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let s = self.to_string();
|
||||
|
||||
String::serialize(&s, serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for Mime {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
|
||||
s.parse::<mime::Mime>().map_err(D::Error::custom).map(Mime)
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for Mime {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let s = self.to_string();
|
||||
|
||||
String::serialize(&s, serializer)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn deserialize_ok() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TestStruct {
|
||||
#[serde(rename = "msg")]
|
||||
_msg: super::OkString,
|
||||
}
|
||||
|
||||
let _: TestStruct = serde_json::from_str(r#"{"msg":"ok"}"#).expect("Deserialized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_image_response() {
|
||||
let response: super::ImageResponse =
|
||||
serde_json::from_str(r#"{"msg": "ok", "files": []}"#).expect("Deserialized");
|
||||
|
||||
assert!(matches!(response, super::ImageResponse::Ok { .. }))
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue