diff --git a/Cargo.toml b/Cargo.toml index 723bc20..37f3d3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinify-rs" -version = "1.3.0" +version = "1.4.0" edition = "2021" description = "A Rust client for the Tinify API" authors = ["The tinify-rs Developers"] @@ -18,6 +18,7 @@ tokio = { version = "1", features = ["full"], optional = true} serde = { version = "1.0.149", default-features = false, features = ["derive"] } serde_json = { version = "1.0.89", default-features = false, features = ["alloc"] } serde_derive = "1.0.149" +url = "2.5.0" [dev-dependencies] dotenv = "0.15.0" diff --git a/README.md b/README.md index e69298c..2f6ccfa 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ # Tinify API client for Rust 🦀 -

- Tinify -

-

CI Status @@ -44,14 +40,14 @@ Install the API client with Cargo. Add this to `Cargo.toml`: ```toml [dependencies] -tinify-rs = "1.3.0" +tinify-rs = "1.4.0" ``` Using async client ```toml [dependencies] -tinify-rs = { version = "1.3.0", features = ["async"] } +tinify-rs = { version = "1.4.0", features = ["async"] } ``` ## Usage @@ -62,38 +58,61 @@ tinify-rs = { version = "1.3.0", features = ["async"] } - Compress from a file ```rust -use tinify::sync::Tinify; use tinify::error::TinifyError; +use tinify::sync::Tinify; +use std::path::Path; fn main() -> Result<(), TinifyError> { - let key = "tinify api key"; + let key = "api key"; + let output = Path::new("./optimized.jpg"); let tinify = Tinify::new().set_key(key); - let client = tinify.get_client()?; - let _ = client + let optimized = tinify + .get_client()? .from_file("./unoptimized.jpg")? - .to_file("./optimized.jpg")?; - + .to_file(output); + + if let Err(error) = optimized { + match error { + TinifyError::ClientError { ref upstream } => { + println!("Error: {} message: {}", upstream.error, upstream.message); + } + _ => println!("{:?}", error), + } + } + Ok(()) } + ``` - Compress from a file async ```rust -use tinify::async_bin::Tinify as AsyncTinify; use tinify::error::TinifyError; +use tinify::async_bin::Tinify; +use std::path::Path; #[tokio::main] async fn main() -> Result<(), TinifyError> { - let key = "tinify api key"; - let tinify = AsyncTinify::new().set_key(key); - let client = tinify.get_async_client()?; - client - .from_file("./unoptimized.jpg") - .await? - .to_file("./optimized.jpg")?; + let key = "api key"; + let output = Path::new("./optimized.jpg"); + let tinify = Tinify::new().set_key(key); + let compressed = tinify + .get_async_client()? + .from_file("./unoptimized.jpg").await? + .to_file(output).await; + + if let Err(error) = compressed { + match error { + TinifyError::ClientError { ref upstream } => { + println!("Error: {} message: {}", upstream.error, upstream.message); + } + _ => println!("{:?}", error), + } + } Ok(()) } + ``` ## Running tests diff --git a/examples/async/convert_transform.rs b/examples/async/convert_transform.rs new file mode 100644 index 0000000..44d2afa --- /dev/null +++ b/examples/async/convert_transform.rs @@ -0,0 +1,36 @@ +use tinify::error::TinifyError; +use tinify::transform::Transform; +use tinify::async_bin::Tinify; +use tinify::convert::Convert; +use tinify::convert::Type; +use std::path::Path; + +#[tokio::main] +async fn main() -> Result<(), TinifyError> { + let key = "api key"; + let convert = Convert { + r#type: vec![Type::Jpeg], + }; + let transform = Transform { + background: "#800020".to_string(), + }; + let output = Path::new("./optimized.jpg"); + let tinify = Tinify::new().set_key(key); + let optimized = tinify + .get_async_client()? + .from_url("https://tinypng.com/images/panda-happy.png").await? + .convert(convert)? + .transform(transform)? + .to_file(output).await; + + if let Err(error) = optimized { + match error { + TinifyError::ClientError { ref upstream } => { + println!("Error: {} message: {}", upstream.error, upstream.message); + } + _ => println!("{:?}", error), + } + } + + Ok(()) +} diff --git a/examples/async/optimize_image.rs b/examples/async/optimize_image.rs new file mode 100644 index 0000000..be62461 --- /dev/null +++ b/examples/async/optimize_image.rs @@ -0,0 +1,25 @@ +use tinify::error::TinifyError; +use tinify::async_bin::Tinify; +use std::path::Path; + +#[tokio::main] +async fn main() -> Result<(), TinifyError> { + let key = "api key"; + let output = Path::new("./optimized.jpg"); + let tinify = Tinify::new().set_key(key); + let optimized = tinify + .get_async_client()? + .from_file("./unoptimized.jpg").await? + .to_file(output).await; + + if let Err(error) = optimized { + match error { + TinifyError::ClientError { ref upstream } => { + println!("Error: {} message: {}", upstream.error, upstream.message); + } + _ => println!("{:?}", error), + } + } + + Ok(()) +} diff --git a/examples/async/resize_image.rs b/examples/async/resize_image.rs new file mode 100644 index 0000000..b4db794 --- /dev/null +++ b/examples/async/resize_image.rs @@ -0,0 +1,33 @@ +use tinify::error::TinifyError; +use tinify::async_bin::Tinify; +use tinify::resize::Resize; +use tinify::resize::Method; +use std::path::Path; + +#[tokio::main] +async fn main() -> Result<(), TinifyError> { + let key = "api key"; + let output = Path::new("./optimized.jpg"); + let resize = Resize { + method: Method::Fit, + width: Some(150), + height: Some(100), + }; + let tinify = Tinify::new().set_key(key); + let optimized = tinify + .get_async_client()? + .from_file("./unoptimized.jpg").await? + .resize(resize)? + .to_file(output).await; + + if let Err(error) = optimized { + match error { + TinifyError::ClientError { ref upstream } => { + println!("Error: {} message: {}", upstream.error, upstream.message); + } + _ => println!("{:?}", error), + } + } + + Ok(()) +} diff --git a/examples/sync/convert_transform.rs b/examples/sync/convert_transform.rs new file mode 100644 index 0000000..16360cc --- /dev/null +++ b/examples/sync/convert_transform.rs @@ -0,0 +1,35 @@ +use tinify::error::TinifyError; +use tinify::transform::Transform; +use tinify::convert::Convert; +use tinify::convert::Type; +use tinify::sync::Tinify; +use std::path::Path; + +fn main() -> Result<(), TinifyError> { + let key = "api key"; + let convert = Convert { + r#type: vec![Type::Jpeg], + }; + let transform = Transform { + background: "#800020".to_string(), + }; + let output = Path::new("./optimized.jpg"); + let tinify = Tinify::new().set_key(key); + let optimized = tinify + .get_client()? + .from_url("https://tinypng.com/images/panda-happy.png")? + .convert(convert)? + .transform(transform)? + .to_file(output); + + if let Err(error) = optimized { + match error { + TinifyError::ClientError { ref upstream } => { + println!("Error: {} message: {}", upstream.error, upstream.message); + } + _ => println!("{:?}", error), + } + } + + Ok(()) +} diff --git a/examples/sync/optimize_image.rs b/examples/sync/optimize_image.rs new file mode 100644 index 0000000..290a1f9 --- /dev/null +++ b/examples/sync/optimize_image.rs @@ -0,0 +1,24 @@ +use tinify::error::TinifyError; +use tinify::sync::Tinify; +use std::path::Path; + +fn main() -> Result<(), TinifyError> { + let key = "api key"; + let output = Path::new("./optimized.jpg"); + let tinify = Tinify::new().set_key(key); + let optimized = tinify + .get_client()? + .from_file("./unoptimized.jpg")? + .to_file(output); + + if let Err(error) = optimized { + match error { + TinifyError::ClientError { ref upstream } => { + println!("Error: {} message: {}", upstream.error, upstream.message); + } + _ => println!("{:?}", error), + } + } + + Ok(()) +} diff --git a/examples/sync/resize_image.rs b/examples/sync/resize_image.rs new file mode 100644 index 0000000..d5b3100 --- /dev/null +++ b/examples/sync/resize_image.rs @@ -0,0 +1,32 @@ +use tinify::error::TinifyError; +use tinify::resize::Method; +use tinify::resize::Resize; +use tinify::sync::Tinify; +use std::path::Path; + +fn main() -> Result<(), TinifyError> { + let key = "api key"; + let resize = Resize { + method: Method::Fit, + width: Some(150), + height: Some(100), + }; + let output = Path::new("./optimized.jpg"); + let tinify = Tinify::new().set_key(key); + let optimized = tinify + .get_client()? + .from_file("./unoptimized.jpg")? + .resize(resize)? + .to_file(output); + + if let Err(error) = optimized { + match error { + TinifyError::ClientError { ref upstream } => { + println!("Error: {} message: {}", upstream.error, upstream.message); + } + _ => println!("{:?}", error), + } + } + + Ok(()) +} diff --git a/src/async_bin/client.rs b/src/async_bin/client.rs index 978eada..34ad163 100644 --- a/src/async_bin/client.rs +++ b/src/async_bin/client.rs @@ -13,92 +13,28 @@ impl Client { K: AsRef, { Self { - source: Source::new(None, Some(key.as_ref())), + source: Source::new(Some(key.as_ref())), } } /// Choose a file to compress. - /// - /// # Examples - /// - /// ``` - /// use tinify::async_bin::Tinify as AsyncTinify; - /// use tinify::error::TinifyError; - /// - /// #[tokio::main] - /// async fn main() -> Result<(), TinifyError> { - /// let key = "tinify api key"; - /// let tinify = AsyncTinify::new().set_key(key); - /// let client = tinify.get_async_client()?; - /// client - /// .from_file("./unoptimized.jpg") - /// .await? - /// .to_file("./optimized.jpg")?; - /// - /// Ok(()) - /// } - /// ``` - #[allow(clippy::wrong_self_convention)] pub async fn from_file

(self, path: P) -> Result where P: AsRef, { self.source.from_file(path).await } + /// Choose a buffer to compress. - /// - /// # Examples - /// - /// ``` - /// use tinify::async_bin::Tinify as AsyncTinify; - /// use tinify::error::TinifyError; - /// use std::fs; - /// - /// #[tokio::main] - /// async fn main() -> Result<(), TinifyError> { - /// let key = "tinify api key"; - /// let tinify = AsyncTinify::new().set_key(key); - /// let bytes = fs::read("./unoptimized.jpg")?; - /// let client = tinify.get_async_client()?; - /// client - /// .from_buffer(&bytes) - /// .await? - /// .to_file("./optimized.jpg")?; - /// - /// Ok(()) - /// } - /// ``` - #[allow(clippy::wrong_self_convention)] pub async fn from_buffer(self, buffer: &[u8]) -> Result { self.source.from_buffer(buffer).await } /// Choose an url image to compress. - /// - /// # Examples - /// - /// ``` - /// use tinify::async_bin::Tinify as AsyncTinify; - /// use tinify::error::TinifyError; - /// - /// #[tokio::main] - /// async fn main() -> Result<(), TinifyError> { - /// let key = "tinify api key"; - /// let tinify = AsyncTinify::new().set_key(key); - /// let client = tinify.get_async_client()?; - /// client - /// .from_url("https://tinypng.com/images/panda-happy.png") - /// .await? - /// .to_file("./optimized.jpg")?; - /// - /// Ok(()) - /// } - /// ``` - #[allow(clippy::wrong_self_convention)] pub async fn from_url

(self, url: P) -> Result where - P: AsRef, + P: AsRef + Into, { self.source.from_url(url).await } @@ -108,7 +44,7 @@ impl Client { #[cfg(feature = "async")] mod tests { use super::*; - use crate::convert::Color; + use crate::convert::Convert; use crate::convert::Type; use crate::resize::Method; use crate::resize::Resize; @@ -136,7 +72,7 @@ mod tests { .await .unwrap_err(); - assert_matches!(request, TinifyError::ClientError); + assert_matches!(request, TinifyError::ClientError { .. }); } #[tokio::test] @@ -144,7 +80,11 @@ mod tests { let key = get_key(); let output = Path::new("./optimized.jpg"); let tmp_image = Path::new("./tmp_image.jpg"); - let _ = Client::new(key).from_file(tmp_image).await?.to_file(output); + let _ = Client::new(key) + .from_file(tmp_image) + .await? + .to_file(output) + .await; let actual = fs::metadata(tmp_image)?.len(); let expected = fs::metadata(output)?.len(); @@ -164,7 +104,11 @@ mod tests { let output = Path::new("./optimized.jpg"); let tmp_image = Path::new("./tmp_image.jpg"); let buffer = fs::read(tmp_image).unwrap(); - let _ = Client::new(key).from_buffer(&buffer).await?.to_file(output); + let _ = Client::new(key) + .from_buffer(&buffer) + .await? + .to_file(output) + .await; let actual = fs::metadata(tmp_image)?.len(); let expected = fs::metadata(output)?.len(); @@ -186,9 +130,9 @@ mod tests { let _ = Client::new(key) .from_url(remote_image) .await? - .to_file(output); + .to_file(output) + .await; let expected = fs::metadata(output)?.len(); - let actual = ReqwestClient::new().get(remote_image).send().await?; if let Some(content_length) = actual.content_length() { @@ -209,7 +153,11 @@ mod tests { let key = get_key(); let output = Path::new("./optimized.jpg"); let tmp_image = Path::new("./tmp_image.jpg"); - let _ = Client::new(key).from_file(tmp_image).await?.to_file(output); + let _ = Client::new(key) + .from_file(tmp_image) + .await? + .to_file(output) + .await; assert!(output.exists()); @@ -226,7 +174,7 @@ mod tests { let output = Path::new("./optimized.jpg"); let tmp_image = Path::new("./tmp_image.jpg"); let client = Client::new(key); - let buffer = client.from_file(tmp_image).await?.to_buffer(); + let buffer = client.from_file(tmp_image).await?.to_buffer().await?; assert_eq!(buffer.capacity(), 102051); @@ -244,9 +192,13 @@ mod tests { let _ = Client::new(key) .from_file("./tmp_image.jpg") .await? - .resize(Resize::new(Method::SCALE, Some(400), None)) - .await? - .to_file(output); + .resize(Resize { + method: Method::Scale, + width: Some(400), + height: None, + })? + .to_file(output) + .await; let (width, height) = match size(output) { Ok(dim) => (dim.width, dim.height), @@ -269,9 +221,13 @@ mod tests { let _ = Client::new(key) .from_file("./tmp_image.jpg") .await? - .resize(Resize::new(Method::SCALE, None, Some(400))) - .await? - .to_file(output); + .resize(Resize { + method: Method::Scale, + width: None, + height: Some(400), + })? + .to_file(output) + .await; let (width, height) = match size(output) { Ok(dim) => (dim.width, dim.height), @@ -294,9 +250,13 @@ mod tests { let _ = Client::new(key) .from_file("./tmp_image.jpg") .await? - .resize(Resize::new(Method::FIT, Some(400), Some(200))) - .await? - .to_file(output); + .resize(Resize { + method: Method::Fit, + width: Some(400), + height: Some(200), + })? + .to_file(output) + .await; let (width, height) = match size(output) { Ok(dim) => (dim.width, dim.height), @@ -319,9 +279,13 @@ mod tests { let _ = Client::new(key) .from_file("./tmp_image.jpg") .await? - .resize(Resize::new(Method::COVER, Some(400), Some(200))) - .await? - .to_file(output); + .resize(Resize { + method: Method::Cover, + width: Some(400), + height: Some(200), + })? + .to_file(output) + .await; let (width, height) = match size(output) { Ok(dim) => (dim.width, dim.height), @@ -344,9 +308,13 @@ mod tests { let _ = Client::new(key) .from_file("./tmp_image.jpg") .await? - .resize(Resize::new(Method::THUMB, Some(400), Some(200))) - .await? - .to_file(output); + .resize(Resize { + method: Method::Thumb, + width: Some(400), + height: Some(200), + })? + .to_file(output) + .await; let (width, height) = match size(output) { Ok(dim) => (dim.width, dim.height), @@ -365,10 +333,14 @@ mod tests { #[tokio::test] async fn test_error_transparent_png_to_jpeg() -> Result<(), TinifyError> { let key = get_key(); + let convert = Convert { + r#type: vec![Type::Jpeg], + }; let request = Client::new(key) .from_url("https://tinypng.com/images/panda-happy.png") .await? - .convert((Some(Type::JPEG), None, None), None) + .convert(convert)? + .to_file(Path::new("./tmp_transparent.jpg")) .await .unwrap_err(); @@ -377,38 +349,19 @@ mod tests { Ok(()) } - #[tokio::test] - async fn test_transparent_png_to_jpeg() -> Result<(), TinifyError> { - let key = get_key(); - let output = Path::new("./panda-sticker.jpg"); - let _ = Client::new(key) - .from_url("https://tinypng.com/images/panda-happy.png") - .await? - .convert((Some(Type::JPEG), None, None), Some(Color("#000000"))) - .await? - .to_file(output); - - let extension = output.extension().and_then(OsStr::to_str).unwrap(); - - assert_eq!(extension, "jpg"); - - if output.exists() { - fs::remove_file(output)?; - } - - Ok(()) - } - #[tokio::test] async fn test_convert_from_jpg_to_png() -> Result<(), TinifyError> { let key = get_key(); - let output = Path::new("./tmp_converted.png"); + let output = Path::new("./panda-sticker.png"); + let convert = Convert { + r#type: vec![Type::Png], + }; let _ = Client::new(key) - .from_file(Path::new("./tmp_image.jpg")) - .await? - .convert((Some(Type::PNG), None, None), None) + .from_file("./tmp_image.jpg") .await? - .to_file(output); + .convert(convert)? + .to_file(output) + .await; let extension = output.extension().and_then(OsStr::to_str).unwrap(); @@ -425,12 +378,15 @@ mod tests { async fn test_convert_from_jpg_to_webp() -> Result<(), TinifyError> { let key = get_key(); let output = Path::new("./panda-sticker.webp"); + let convert = Convert { + r#type: vec![Type::Webp], + }; let _ = Client::new(key) - .from_url("https://tinypng.com/images/panda-happy.png") - .await? - .convert((Some(Type::WEBP), None, None), None) + .from_file("./tmp_image.jpg") .await? - .to_file(output); + .convert(convert)? + .to_file(output) + .await; let extension = output.extension().and_then(OsStr::to_str).unwrap(); @@ -447,12 +403,15 @@ mod tests { async fn test_convert_smallest_type() -> Result<(), TinifyError> { let key = get_key(); let output = Path::new("./panda-sticker.webp"); + let convert = Convert { + r#type: vec![Type::Jpeg, Type::Png, Type::Webp], + }; let _ = Client::new(key) .from_url("https://tinypng.com/images/panda-happy.png") .await? - .convert((Some(Type::PNG), Some(Type::WEBP), Some(Type::JPEG)), None) - .await? - .to_file(output); + .convert(convert)? + .to_file(output) + .await; let extension = output.extension().and_then(OsStr::to_str).unwrap(); @@ -469,12 +428,15 @@ mod tests { async fn test_convert_smallest_wildcard_type() -> Result<(), TinifyError> { let key = get_key(); let output = Path::new("./panda-sticker.webp"); + let convert = Convert { + r#type: vec![Type::WildCard], + }; let _ = Client::new(key) .from_url("https://tinypng.com/images/panda-happy.png") .await? - .convert((Some(Type::WILDCARD), None, None), None) - .await? - .to_file(output); + .convert(convert)? + .to_file(output) + .await; let extension = output.extension().and_then(OsStr::to_str).unwrap(); diff --git a/src/async_bin/source.rs b/src/async_bin/source.rs index 4bd7607..47f2eef 100644 --- a/src/async_bin/source.rs +++ b/src/async_bin/source.rs @@ -1,16 +1,17 @@ -use crate::convert::Color; use crate::convert::Convert; -use crate::convert::JsonData; -use crate::convert::Transform; use crate::error::TinifyError; -use crate::resize; +use crate::error::Upstream; +use crate::resize::Resize; +use crate::transform::Transform; +use crate::Operations; +use crate::SourceUrl; use crate::API_ENDPOINT; use reqwest::header::HeaderValue; use reqwest::header::CONTENT_TYPE; use reqwest::Client as ReqwestClient; -use reqwest::Method; -use reqwest::Response; use reqwest::StatusCode; +use serde_json::json; +use serde_json::Value; use std::fs::File; use std::io::BufReader; use std::io::BufWriter; @@ -19,312 +20,243 @@ use std::io::Write; use std::path::Path; use std::str; use std::time::Duration; +use url::Url; #[derive(Debug)] pub struct Source { - url: Option, key: Option, buffer: Option>, - request_client: ReqwestClient, + output: Option, + reqwest_client: ReqwestClient, + operations: Operations, } impl Source { - pub(crate) fn new(url: Option<&str>, key: Option<&str>) -> Self { - let url = url.map(|val| val.into()); + pub(crate) fn new(key: Option<&str>) -> Self { let key = key.map(|val| val.into()); - let request_client = ReqwestClient::new(); + let reqwest_client = ReqwestClient::new(); + let operations = Operations { + convert: None, + resize: None, + transform: None, + }; Self { - url, key, buffer: None, - request_client, + output: None, + reqwest_client, + operations, } } - pub(crate) async fn request( - &self, - url: U, - method: Method, + async fn get_source_from_response( + mut self, buffer: Option<&[u8]>, - ) -> Result - where - U: AsRef, - { - let full_url = format!("{}{}", API_ENDPOINT, url.as_ref()); - let response = match method { - Method::POST => { - self - .request_client - .post(full_url) - .body(buffer.unwrap().to_owned()) - .basic_auth("api", self.key.as_ref()) - .timeout(Duration::from_secs(300)) - .send() - .await? + json: Option, + ) -> Result { + let parse = Url::parse(API_ENDPOINT)?; + let url = parse.join("/shrink")?; + let compressed_image = if let Some(json) = json { + self + .reqwest_client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(json.to_string()) + .basic_auth("api", self.key.as_ref()) + .timeout(Duration::from_secs(300)) + .send() + .await? + } else { + self + .reqwest_client + .post(url) + .body(buffer.unwrap().to_vec()) + .basic_auth("api", self.key.as_ref()) + .timeout(Duration::from_secs(300)) + .send() + .await? + }; + + match compressed_image.status() { + StatusCode::CREATED => { + if let Some(location) = compressed_image.headers().get("location") { + let location = location.to_str()?.to_string(); + let bytes = self + .reqwest_client + .get(&location) + .timeout(Duration::from_secs(300)) + .send() + .await? + .bytes() + .await? + .to_vec(); + + self.buffer = Some(bytes); + self.output = Some(location); + + Ok(self) + } else { + let upstream = Upstream { + error: "Empty".to_string(), + message: "The location of the compressed image is empty." + .to_string(), + }; + Err(TinifyError::ServerError { upstream }) + } + } + StatusCode::UNAUTHORIZED | StatusCode::UNSUPPORTED_MEDIA_TYPE => { + let upstream: Upstream = + serde_json::from_str(&compressed_image.text().await?)?; + Err(TinifyError::ClientError { upstream }) } - Method::GET => { - self - .request_client - .get(url.as_ref()) - .timeout(Duration::from_secs(300)) - .send() - .await? + StatusCode::SERVICE_UNAVAILABLE => { + let upstream: Upstream = + serde_json::from_str(&compressed_image.text().await?)?; + Err(TinifyError::ServerError { upstream }) } _ => unreachable!(), - }; - - match response.status() { - StatusCode::UNAUTHORIZED => Err(TinifyError::ClientError), - StatusCode::UNSUPPORTED_MEDIA_TYPE => Err(TinifyError::ClientError), - StatusCode::SERVICE_UNAVAILABLE => Err(TinifyError::ServerError), - _ => Ok(response), } } + #[allow(clippy::wrong_self_convention)] + pub(crate) async fn from_buffer( + self, + buffer: &[u8], + ) -> Result { + self.get_source_from_response(Some(buffer), None).await + } + #[allow(clippy::wrong_self_convention)] pub(crate) async fn from_file

(self, path: P) -> Result where P: AsRef, { - let file = - File::open(path).map_err(|source| TinifyError::ReadError { source })?; + let file = File::open(path)?; let mut reader = BufReader::new(file); let mut buffer = Vec::with_capacity(reader.capacity()); reader.read_to_end(&mut buffer)?; - self.from_buffer(&buffer).await + self.get_source_from_response(Some(&buffer), None).await } #[allow(clippy::wrong_self_convention)] - pub(crate) async fn from_buffer( - self, - buffer: &[u8], - ) -> Result { - let response = self.request("/shrink", Method::POST, Some(buffer)).await?; - - self.get_source_from_response(response).await - } - - #[allow(clippy::wrong_self_convention)] - pub(crate) async fn from_url(self, url: U) -> Result + pub(crate) async fn from_url

(self, path: P) -> Result where - U: AsRef, + P: AsRef + Into, { - let get_request = self.request(url, Method::GET, None).await?; - let buffer = get_request.bytes().await?; - let post_request = - self.request("/shrink", Method::POST, Some(&buffer)).await?; + let json = json!({ + "source": SourceUrl { url: path.into() }, + }); - self.get_source_from_response(post_request).await + self.get_source_from_response(None, Some(json)).await } /// Resize the current compressed image. - /// - /// # Examples - /// - /// ``` - /// use tinify::async_bin::Tinify as AsyncTinify; - /// use tinify::error::TinifyError; - /// use tinify::resize::Method; - /// use tinify::resize::Resize; - /// - /// #[tokio::main] - /// async fn main() -> Result<(), TinifyError> { - /// let key = "l96rSTt3HV242TQWyG5DhRwfLRJzkrBg"; - /// let tinify = AsyncTinify::new().set_key(key); - /// let client = tinify.get_async_client()?; - /// client - /// .from_file("./unoptimized.jpg") - /// .await? - /// .resize(Resize::new( - /// Method::FIT, - /// Some(400), - /// Some(200), - /// )) - /// .await? - /// .to_file("./optimized.jpg")?; - /// - /// Ok(()) - /// } - /// ``` - pub async fn resize( - self, - resize: resize::Resize, - ) -> Result { - let json_data = resize::JsonData::new(resize); - let mut json_string = serde_json::to_string(&json_data).unwrap(); - let width = json_data.resize.width; - let height = json_data.resize.height; - json_string = match ( - (width.is_some(), height.is_none()), - (height.is_some(), width.is_none()), - ) { - ((true, true), (_, _)) => json_string.replace(",\"height\":null", ""), - ((_, _), (true, true)) => json_string.replace(",\"width\":null", ""), - _ => json_string, - }; - let response = self - .request_client - .post(self.url.as_ref().unwrap()) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .body(json_string) - .basic_auth("api", self.key.as_ref()) - .timeout(Duration::from_secs(300)) - .send() - .await?; + pub fn resize(mut self, resize: Resize) -> Result { + self.operations.resize = Some(resize); + Ok(self) + } - match response.status() { - StatusCode::BAD_REQUEST => Err(TinifyError::ClientError), - _ => self.get_source_from_response(response).await, - } + /// Convert the current compressed image. + pub fn convert(mut self, convert: Convert) -> Result { + self.operations.convert = Some(convert); + Ok(self) } - - /// The following options are available as a type: - /// One image type, specified as a string `"image/webp"` - /// - /// Multiple image types, specified as a tuple (`"image/webp"`, `"image/png"`). - /// The smallest of the provided image types will be returned. - /// - /// The transform object specifies the stylistic transformations - /// that will be applied to the image. - /// - /// Include a background property to fill a transparent image's background. - /// - /// Specify a background color to convert an image with a transparent background - /// to an image type which does not support transparency (like JPEG). - /// - /// # Examples - /// - /// ``` - /// use tinify::async_bin::Tinify as AsyncTinify; - /// use tinify::error::TinifyError; - /// use tinify::convert::Color; - /// use tinify::convert::Type; - /// - /// #[tokio::main] - /// async fn main() -> Result<(), TinifyError> { - /// let key = "l96rSTt3HV242TQWyG5DhRwfLRJzkrBg"; - /// let tinify = AsyncTinify::new().set_key(key); - /// let client = tinify.get_async_client()?; - /// client - /// .from_url("https://tinypng.com/images/panda-happy.png") - /// .await? - /// .convert( - /// (Some(Type::JPEG), None, None), - /// Some(Color("#FF5733")), - /// ) - /// .await? - /// .to_file("./optimized.jpg")?; - /// - /// Ok(()) - /// } - /// ``` - pub async fn convert( - self, - convert_type: (Option, Option, Option), - transform: Option, - ) -> Result - where - T: Into + Copy, - { - let types = &[&convert_type.0, &convert_type.1, &convert_type.2]; - let count: Vec = types - .iter() - .filter_map(|&val| val.and_then(|x| Some(x.into()))) - .collect(); - let len = count.len(); - let parse_type = match len { - _ if len >= 2 => serde_json::to_string(&count).unwrap(), - _ => count.first().unwrap().to_string(), - }; - let template = if let Some(color) = transform { - JsonData::new(Convert::new(parse_type), Some(Transform::new(color.0))) - } else { - JsonData::new(Convert::new(parse_type), None) - }; - // Using replace to avoid invalid JSON string. - let json_string = serde_json::to_string(&template) - .unwrap() - .replace("\"convert_type\"", "\"type\"") - .replace(",\"transform\":null", "") - .replace("\"[", "[") - .replace("]\"", "]") - .replace("\\\"", "\""); - let response = self - .request_client - .post(self.url.as_ref().unwrap()) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .body(json_string) - .basic_auth("api", self.key.as_ref()) - .timeout(Duration::from_secs(300)) - .send() - .await?; + /// Transform the current compressed image. + pub fn transform( + mut self, + transform: Transform, + ) -> Result { + self.operations.transform = Some(transform); + Ok(self) + } - if response.status() == StatusCode::BAD_REQUEST { - return Err(TinifyError::ClientError); - } + async fn run_operations(&mut self) -> Result<(), TinifyError> { + let operations = serde_json::to_string(&self.operations)?; + + if let Some(output) = self.output.take() { + let response = self + .reqwest_client + .post(output) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(operations) + .basic_auth("api", self.key.as_ref()) + .timeout(Duration::from_secs(300)) + .send() + .await?; + + match response.status() { + StatusCode::OK => { + let bytes = response.bytes().await?.to_vec(); - self.get_source_from_response(response).await + self.buffer = Some(bytes); + + Ok(()) + } + StatusCode::BAD_REQUEST + | StatusCode::UNAUTHORIZED + | StatusCode::UNSUPPORTED_MEDIA_TYPE => { + let upstream: Upstream = + serde_json::from_str(&response.text().await?)?; + Err(TinifyError::ClientError { upstream }) + } + StatusCode::SERVICE_UNAVAILABLE => { + let upstream: Upstream = + serde_json::from_str(&response.text().await?)?; + Err(TinifyError::ServerError { upstream }) + } + _ => unreachable!(), + } + } else { + let upstream = Upstream { + error: "Empty".to_string(), + message: "Output of the compressed image is empty.".to_string(), + }; + Err(TinifyError::ClientError { upstream }) + } } - /// Save the compressed image to a file. - pub fn to_file

(&self, path: P) -> Result<(), TinifyError> + /// Save the current compressed image to a file. + pub async fn to_file

(&mut self, path: P) -> Result<(), TinifyError> where P: AsRef, { - let file = File::create(path) - .map_err(|source| TinifyError::WriteError { source })?; - let mut reader = BufWriter::new(file); - reader.write_all(self.buffer.as_ref().unwrap())?; - reader.flush()?; + if self.operations.convert.is_some() + || self.operations.resize.is_some() + || self.operations.transform.is_some() + { + self.run_operations().await?; + } - Ok(()) - } + if let Some(ref buffer) = self.buffer { + let file = File::create(path)?; + let mut reader = BufWriter::new(file); + reader.write_all(buffer)?; + reader.flush()?; + } - /// Convert the compressed image to a buffer. - pub fn to_buffer(&self) -> Vec { - self.buffer.as_ref().unwrap().to_vec() + Ok(()) } - pub(crate) async fn get_source_from_response( - mut self, - response: Response, - ) -> Result { - if let Some(location) = response.headers().get("location") { - let mut url = String::new(); - - if !location.is_empty() { - let slice = str::from_utf8(location.as_bytes()).unwrap(); - url.push_str(slice); - } + /// Save the current compressed image to a buffer. + pub async fn to_buffer(&mut self) -> Result, TinifyError> { + if self.operations.convert.is_some() + || self.operations.resize.is_some() + || self.operations.transform.is_some() + { + self.run_operations().await?; + } - let get_request = self.request(&url, Method::GET, None).await?; - let bytes = get_request.bytes().await?.to_vec(); - self.buffer = Some(bytes); - self.url = Some(url); + if let Some(buffer) = self.buffer.as_ref() { + Ok(buffer.to_vec()) } else { - let bytes = response.bytes().await?.to_vec(); - self.buffer = Some(bytes); - self.url = None; + let upstream = Upstream { + error: "Empty".to_string(), + message: "Buffer of the compressed image is empty.".to_string(), + }; + Err(TinifyError::ClientError { upstream }) } - - Ok(self) - } -} - -#[cfg(test)] -#[cfg(feature = "async")] -mod tests { - use super::*; - use assert_matches::assert_matches; - - #[tokio::test] - async fn test_request_error() { - let source = Source::new(None, None); - let request = source.request("", Method::GET, None).await.unwrap_err(); - - assert_matches!(request, TinifyError::ReqwestError { .. }); } } diff --git a/src/convert.rs b/src/convert.rs index 4357dd8..6b9ef31 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -1,71 +1,29 @@ use serde::Deserialize; use serde::Serialize; -/// Tinify currently supports converting between WebP, JPEG, and PNG. -/// -/// When provided more than one image type in the convert request, -/// the smallest version will be returned. -#[derive(Serialize, Deserialize)] -pub struct Type(&'static str); - -#[allow(dead_code)] -impl Type { - /// The `"image/png"` type. - pub const PNG: &'static str = "image/png"; - /// The `"image/jpeg"` type. - pub const JPEG: &'static str = "image/jpeg"; - /// The `"image/webp"` type. - pub const WEBP: &'static str = "image/webp"; - /// The wildcard `"*/*"` returns the smallest of Tinify's supported image types, - /// currently WebP, JPEG and PNG. - pub const WILDCARD: &'static str = "*/*"; -} - -#[derive(Serialize, Deserialize, Debug)] -pub(crate) struct Convert { - convert_type: String, -} - -impl Convert { - pub(crate) fn new(convert_type: C) -> Self - where - C: Into, - { - Self { - convert_type: convert_type.into(), - } - } -} - -/// A hex value. Custom background color using the color's hex value: `"#000000"`. -/// `white` or `black`. Only the colors white and black are supported as strings. -#[derive(Serialize, Deserialize, Debug)] -pub struct Color(pub &'static str); +/// The type `enum` defines the type of image to which it will be converted. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum Type { + #[serde(rename = "image/png")] + Png, -#[derive(Serialize, Deserialize, Debug)] -pub(crate) struct Transform { - background: String, -} + #[serde(rename = "image/jpeg")] + Jpeg, -impl Transform { - pub(crate) fn new(background: B) -> Self - where - B: AsRef + Into, - { - Self { - background: background.into(), - } - } -} + #[serde(rename = "image/webp")] + Webp, -#[derive(Serialize, Deserialize, Debug)] -pub(crate) struct JsonData { - pub(crate) convert: Convert, - transform: Option, + #[serde(rename = "*/*")] + WildCard, } -impl JsonData { - pub(crate) fn new(convert: Convert, transform: Option) -> Self { - Self { convert, transform } - } +/// # Converting images +/// +/// You can use the API to convert your images to your desired image type. Tinify currently supports converting between `WebP`, J`PEG`, and `PNG`. When you provide more than one image `type` in your convert request, the smallest version will be returned to you. +/// +/// Image converting will count as one additional compression. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Convert { + /// A vector of `types` + pub r#type: Vec, } diff --git a/src/error.rs b/src/error.rs index dc70014..59b8d88 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,31 +1,38 @@ +use serde::Deserialize; +use serde::Serialize; use std::error; use std::fmt; use std::io; -/// The Tinify API uses HTTP status codes to indicate success or failure. -/// -/// Status codes in the 4xx range indicate there was a problem with `Client` request. -/// -/// Status codes in the 5xx indicate a temporary problem with the Tinify API `Server`. +/// Tinify remote error message received. +#[derive(Serialize, Deserialize, Debug)] +pub struct Upstream { + pub error: String, + pub message: String, +} + +/// The `TinifyError` enum indicates whether a client or server error occurs. #[derive(Debug)] pub enum TinifyError { - ClientError, - ServerError, - ReadError { source: io::Error }, - WriteError { source: io::Error }, - IOError(io::Error), + ClientError { upstream: Upstream }, + ServerError { upstream: Upstream }, ReqwestError(reqwest::Error), + ReqwestConvertError(reqwest::header::ToStrError), + UrlParseError(url::ParseError), + JsonParseError(serde_json::Error), + IOError(io::Error), } impl error::Error for TinifyError { fn source(&self) -> Option<&(dyn error::Error + 'static)> { match *self { - TinifyError::ClientError => None, - TinifyError::ServerError => None, - TinifyError::ReadError { ref source } => Some(source), - TinifyError::WriteError { ref source } => Some(source), - TinifyError::IOError(_) => None, - TinifyError::ReqwestError(_) => None, + TinifyError::ClientError { .. } => None, + TinifyError::ServerError { .. } => None, + TinifyError::ReqwestError(ref source) => Some(source), + TinifyError::ReqwestConvertError(ref source) => Some(source), + TinifyError::UrlParseError(ref source) => Some(source), + TinifyError::JsonParseError(ref source) => Some(source), + TinifyError::IOError(ref source) => Some(source), } } } @@ -33,20 +40,17 @@ impl error::Error for TinifyError { impl fmt::Display for TinifyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match *self { - TinifyError::ClientError => { - write!(f, "There was a problem with the request.") - } - TinifyError::ServerError => { - write!(f, "There is a temporary problem with the Tinify API.") + TinifyError::ClientError { ref upstream } => { + write!(f, "Tinify Client Error: {}", upstream.message) } - TinifyError::ReadError { .. } => { - write!(f, "Read error") + TinifyError::ServerError { ref upstream } => { + write!(f, "Tinify Server Error: {}", upstream.message) } - TinifyError::WriteError { .. } => { - write!(f, "Write error") - } - TinifyError::IOError(ref err) => err.fmt(f), TinifyError::ReqwestError(ref err) => err.fmt(f), + TinifyError::ReqwestConvertError(ref err) => err.fmt(f), + TinifyError::UrlParseError(ref err) => err.fmt(f), + TinifyError::JsonParseError(ref err) => err.fmt(f), + TinifyError::IOError(ref err) => err.fmt(f), } } } @@ -62,3 +66,21 @@ impl From for TinifyError { TinifyError::ReqwestError(err) } } + +impl From for TinifyError { + fn from(err: reqwest::header::ToStrError) -> Self { + TinifyError::ReqwestConvertError(err) + } +} + +impl From for TinifyError { + fn from(err: url::ParseError) -> Self { + TinifyError::UrlParseError(err) + } +} + +impl From for TinifyError { + fn from(err: serde_json::Error) -> Self { + TinifyError::JsonParseError(err) + } +} diff --git a/src/lib.rs b/src/lib.rs index 1fab544..9217745 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,11 +6,35 @@ //! Read more at `https://tinify.com` // --snip-- +use convert::Convert; +use resize::Resize; +use serde::Deserialize; +use serde::Serialize; +use transform::Transform; + #[cfg(feature = "async")] pub mod async_bin; pub mod convert; pub mod error; pub mod resize; pub mod sync; +pub mod transform; + +pub(crate) const API_ENDPOINT: &str = "https://api.tinify.com"; + +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct SourceUrl { + url: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct Operations { + #[serde(skip_serializing_if = "Option::is_none")] + convert: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + resize: Option, -pub (crate) const API_ENDPOINT: &str = "https://api.tinify.com"; + #[serde(skip_serializing_if = "Option::is_none")] + transform: Option, +} diff --git a/src/resize.rs b/src/resize.rs index b4da94e..25f7cc1 100644 --- a/src/resize.rs +++ b/src/resize.rs @@ -1,51 +1,39 @@ use serde::Deserialize; use serde::Serialize; -/// The method describes the way the image will be resized. -#[derive(Serialize, Deserialize)] -pub struct Method(&'static str); +/// The method describes the way your image will be resized. The following methods are available: +#[derive(Serialize, Deserialize, Debug)] +pub enum Method { + /// Scales the image down proportionally. You must provide either a target `width` or a target `height`, but not both. The scaled image will have exactly the provided width or height. + #[serde(rename = "scale")] + Scale, -impl Method { - /// `Scales` the image down proportionally. - pub const SCALE: &'static str = "scale"; - /// `Scales` the image down proportionally so that it `fits within` the given dimensions. - pub const FIT: &'static str = "fit"; - /// `Scales` the image proportionally and `crops` it if necessary so that the result has exactly the given dimensions. - pub const COVER: &'static str = "cover"; - /// A more advanced implementation of cover that also detects `cut out images` with plain backgrounds. - pub const THUMB: &'static str = "thumb"; + /// Scales the image down proportionally so that it fits within the given dimensions. You must provide both a `width` and a `height`. The scaled image will not exceed either of these dimensions. + #[serde(rename = "fit")] + Fit, + + /// Scales the image proportionally and crops it if necessary so that the result has exactly the given dimensions. You must provide both a `width` and a `height`. Which parts of the image are cropped away is determined automatically. An intelligent algorithm determines the most important areas of your image. + #[serde(rename = "cover")] + Cover, + + /// A more advanced implementation of cover that also detects cut out images with plain backgrounds. The image is scaled down to the `width` and `height` you provide. If an image is detected with a free standing object it will add more background space where necessary or crop the unimportant parts. + #[serde(rename = "thumb")] + Thumb, } -/// Use the API to create resized versions of the uploaded images. +/// # Resizing images +/// Use the API to create resized versions of your uploaded images. By letting the API handle resizing you avoid having to write such code yourself and you will only have to upload your image once. The resized images will be optimally compressed with a nice and crisp appearance. /// -/// If the `target dimensions` are larger than the original dimensions, the image will not be scaled up. Scaling up is prevented in order to protect the quality of the images. -#[derive(Serialize, Deserialize)] +/// You can also take advantage of intelligent cropping to create thumbnails that focus on the most visually important areas of your image. +/// +/// Resizing counts as one additional compression. For example, if you upload a single image and retrieve the optimized version plus 2 resized versions this will count as 3 compressions in total. +#[derive(Serialize, Deserialize, Debug)] pub struct Resize { - method: String, - pub(crate) width: Option, - pub(crate) height: Option, -} + pub method: Method, -impl Resize { - pub fn new(method: M, width: Option, height: Option) -> Self - where - M: AsRef + Into, - { - Self { - method: method.into(), - width, - height, - } - } -} - -#[derive(Serialize, Deserialize)] -pub(crate) struct JsonData { - pub(crate) resize: Resize, -} + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, -impl JsonData { - pub(crate) fn new(resize: Resize) -> Self { - Self { resize } - } + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, } diff --git a/src/sync/client.rs b/src/sync/client.rs index b369613..8e59474 100644 --- a/src/sync/client.rs +++ b/src/sync/client.rs @@ -13,30 +13,11 @@ impl Client { K: AsRef, { Self { - source: Source::new(None, Some(key.as_ref())), + source: Source::new(Some(key.as_ref())), } } /// Choose a file to compress. - /// - /// # Examples - /// - /// ``` - /// use tinify::sync::Tinify; - /// use tinify::error::TinifyError; - /// - /// fn main() -> Result<(), TinifyError> { - /// let key = "tinify api key"; - /// let tinify = Tinify::new().set_key(key); - /// let client = tinify.get_client()?; - /// let _ = client - /// .from_file("./unoptimized.jpg")? - /// .to_file("./optimized.jpg")?; - /// - /// Ok(()) - /// } - /// ``` - #[allow(clippy::wrong_self_convention)] pub fn from_file

(self, path: P) -> Result where P: AsRef, @@ -45,54 +26,14 @@ impl Client { } /// Choose a buffer to compress. - /// - /// # Examples - /// - /// ``` - /// use tinify::sync::Tinify; - /// use tinify::error::TinifyError; - /// use std::fs; - /// - /// fn main() -> Result<(), TinifyError> { - /// let key = "tinify api key"; - /// let tinify = Tinify::new().set_key(key); - /// let client = tinify.get_client()?; - /// let bytes = fs::read("./unoptimized.jpg")?; - /// let _ = client - /// .from_buffer(&bytes)? - /// .to_file("./optimized.jpg")?; - /// - /// Ok(()) - /// } - /// ``` - #[allow(clippy::wrong_self_convention)] pub fn from_buffer(self, buffer: &[u8]) -> Result { self.source.from_buffer(buffer) } /// Choose an url image to compress. - /// - /// # Examples - /// - /// ``` - /// use tinify::sync::Tinify; - /// use tinify::error::TinifyError; - /// - /// fn main() -> Result<(), TinifyError> { - /// let key = "tinify api key"; - /// let tinify = Tinify::new().set_key(key); - /// let client = tinify.get_client()?; - /// let _ = client - /// .from_url("https://tinypng.com/images/panda-happy.png")? - /// .to_file("./optimized.png")?; - /// - /// Ok(()) - /// } - /// ``` - #[allow(clippy::wrong_self_convention)] pub fn from_url

(self, url: P) -> Result where - P: AsRef, + P: AsRef + Into, { self.source.from_url(url) } @@ -101,7 +42,7 @@ impl Client { #[cfg(test)] mod tests { use super::*; - use crate::convert::Color; + use crate::convert::Convert; use crate::convert::Type; use crate::resize::Method; use crate::resize::Resize; @@ -128,7 +69,7 @@ mod tests { .from_url("https://tinypng.com/images/panda-happy.png") .unwrap_err(); - assert_matches!(request, TinifyError::ClientError); + assert_matches!(request, TinifyError::ClientError { .. }); } #[test] @@ -177,7 +118,6 @@ mod tests { let remote_image = "https://tinypng.com/images/panda-happy.png"; let _ = Client::new(key).from_url(remote_image)?.to_file(output); let expected = fs::metadata(output)?.len(); - let actual = ReqwestClient::new().get(remote_image).send()?; if let Some(content_length) = actual.content_length() { @@ -215,7 +155,7 @@ mod tests { let output = Path::new("./optimized.jpg"); let tmp_image = Path::new("./tmp_image.jpg"); let client = Client::new(key); - let buffer = client.from_file(tmp_image)?.to_buffer(); + let buffer = client.from_file(tmp_image)?.to_buffer()?; assert_eq!(buffer.capacity(), 102051); @@ -232,7 +172,11 @@ mod tests { let output = Path::new("./tmp_resized.jpg"); let _ = Client::new(key) .from_file("./tmp_image.jpg")? - .resize(Resize::new(Method::SCALE, Some(400), None))? + .resize(Resize { + method: Method::Scale, + width: Some(400), + height: None, + })? .to_file(output); let (width, height) = match size(output) { @@ -255,7 +199,11 @@ mod tests { let output = Path::new("./tmp_resized.jpg"); let _ = Client::new(key) .from_file("./tmp_image.jpg")? - .resize(Resize::new(Method::SCALE, None, Some(400)))? + .resize(Resize { + method: Method::Scale, + width: None, + height: Some(400), + })? .to_file(output); let (width, height) = match size(output) { @@ -278,7 +226,11 @@ mod tests { let output = Path::new("./tmp_resized.jpg"); let _ = Client::new(key) .from_file("./tmp_image.jpg")? - .resize(Resize::new(Method::FIT, Some(400), Some(200)))? + .resize(Resize { + method: Method::Fit, + width: Some(400), + height: Some(200), + })? .to_file(output); let (width, height) = match size(output) { @@ -301,7 +253,11 @@ mod tests { let output = Path::new("./tmp_resized.jpg"); let _ = Client::new(key) .from_file("./tmp_image.jpg")? - .resize(Resize::new(Method::COVER, Some(400), Some(200)))? + .resize(Resize { + method: Method::Cover, + width: Some(400), + height: Some(200), + })? .to_file(output); let (width, height) = match size(output) { @@ -324,7 +280,11 @@ mod tests { let output = Path::new("./tmp_resized.jpg"); let _ = Client::new(key) .from_file("./tmp_image.jpg")? - .resize(Resize::new(Method::THUMB, Some(400), Some(200)))? + .resize(Resize { + method: Method::Thumb, + width: Some(400), + height: Some(200), + })? .to_file(output); let (width, height) = match size(output) { @@ -344,9 +304,13 @@ mod tests { #[test] fn test_error_transparent_png_to_jpeg() -> Result<(), TinifyError> { let key = get_key(); + let convert = Convert { + r#type: vec![Type::Jpeg], + }; let request = Client::new(key) .from_url("https://tinypng.com/images/panda-happy.png")? - .convert((Some(Type::JPEG), None, None), None) + .convert(convert)? + .to_file(Path::new("./tmp_transparent.jpg")) .unwrap_err(); assert_matches!(request, TinifyError::ClientError { .. }); @@ -354,33 +318,16 @@ mod tests { Ok(()) } - #[test] - fn test_transparent_png_to_jpeg() -> Result<(), TinifyError> { - let key = get_key(); - let output = Path::new("./panda-sticker.jpg"); - let _ = Client::new(key) - .from_url("https://tinypng.com/images/panda-happy.png")? - .convert((Some(Type::JPEG), None, None), Some(Color("#000000")))? - .to_file(output); - - let extension = output.extension().and_then(OsStr::to_str).unwrap(); - - assert_eq!(extension, "jpg"); - - if output.exists() { - fs::remove_file(output)?; - } - - Ok(()) - } - #[test] fn test_convert_from_jpg_to_png() -> Result<(), TinifyError> { let key = get_key(); - let output = Path::new("./tmp_converted.png"); + let output = Path::new("./panda-sticker.png"); + let convert = Convert { + r#type: vec![Type::Png], + }; let _ = Client::new(key) - .from_file(Path::new("./tmp_image.jpg"))? - .convert((Some(Type::PNG), None, None), None)? + .from_file("./tmp_image.jpg")? + .convert(convert)? .to_file(output); let extension = output.extension().and_then(OsStr::to_str).unwrap(); @@ -398,9 +345,12 @@ mod tests { fn test_convert_from_jpg_to_webp() -> Result<(), TinifyError> { let key = get_key(); let output = Path::new("./panda-sticker.webp"); + let convert = Convert { + r#type: vec![Type::Webp], + }; let _ = Client::new(key) - .from_url("https://tinypng.com/images/panda-happy.png")? - .convert((Some(Type::WEBP), None, None), None)? + .from_file("./tmp_image.jpg")? + .convert(convert)? .to_file(output); let extension = output.extension().and_then(OsStr::to_str).unwrap(); @@ -418,9 +368,12 @@ mod tests { fn test_convert_smallest_type() -> Result<(), TinifyError> { let key = get_key(); let output = Path::new("./panda-sticker.webp"); + let convert = Convert { + r#type: vec![Type::Jpeg, Type::Png, Type::Webp], + }; let _ = Client::new(key) .from_url("https://tinypng.com/images/panda-happy.png")? - .convert((Some(Type::PNG), Some(Type::WEBP), Some(Type::JPEG)), None)? + .convert(convert)? .to_file(output); let extension = output.extension().and_then(OsStr::to_str).unwrap(); @@ -438,9 +391,12 @@ mod tests { fn test_convert_smallest_wildcard_type() -> Result<(), TinifyError> { let key = get_key(); let output = Path::new("./panda-sticker.webp"); + let convert = Convert { + r#type: vec![Type::WildCard], + }; let _ = Client::new(key) .from_url("https://tinypng.com/images/panda-happy.png")? - .convert((Some(Type::WILDCARD), None, None), None)? + .convert(convert)? .to_file(output); let extension = output.extension().and_then(OsStr::to_str).unwrap(); diff --git a/src/sync/source.rs b/src/sync/source.rs index 6d4efe7..19de791 100644 --- a/src/sync/source.rs +++ b/src/sync/source.rs @@ -1,16 +1,17 @@ -use crate::convert::Color; use crate::convert::Convert; -use crate::convert::JsonData; -use crate::convert::Transform; use crate::error::TinifyError; -use crate::resize; +use crate::error::Upstream; +use crate::resize::Resize; +use crate::transform::Transform; +use crate::Operations; +use crate::SourceUrl; use crate::API_ENDPOINT; use reqwest::blocking::Client as ReqwestClient; -use reqwest::blocking::Response; use reqwest::header::HeaderValue; use reqwest::header::CONTENT_TYPE; -use reqwest::Method; use reqwest::StatusCode; +use serde_json::json; +use serde_json::Value; use std::fs::File; use std::io::BufReader; use std::io::BufWriter; @@ -19,300 +20,233 @@ use std::io::Write; use std::path::Path; use std::str; use std::time::Duration; +use url::Url; #[derive(Debug)] pub struct Source { - url: Option, key: Option, buffer: Option>, - request_client: ReqwestClient, + output: Option, + reqwest_client: ReqwestClient, + operations: Operations, } impl Source { - pub(crate) fn new(url: Option<&str>, key: Option<&str>) -> Self { - let url = url.map(|val| val.into()); + pub(crate) fn new(key: Option<&str>) -> Self { let key = key.map(|val| val.into()); - let request_client = ReqwestClient::new(); + let reqwest_client = ReqwestClient::new(); + let operations = Operations { + convert: None, + resize: None, + transform: None, + }; Self { - url, key, buffer: None, - request_client, + output: None, + reqwest_client, + operations, } } - pub(crate) fn request( - &self, - url: U, - method: Method, + fn get_source_from_response( + mut self, buffer: Option<&[u8]>, - ) -> Result - where - U: AsRef, - { - let full_url = format!("{}{}", API_ENDPOINT, url.as_ref()); - let response = match method { - Method::POST => self - .request_client - .post(full_url) - .body(buffer.unwrap().to_vec()) + json: Option, + ) -> Result { + let parse = Url::parse(API_ENDPOINT)?; + let url = parse.join("/shrink")?; + let compressed_image = if let Some(json) = json { + self + .reqwest_client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(json.to_string()) .basic_auth("api", self.key.as_ref()) .timeout(Duration::from_secs(300)) - .send()?, - Method::GET => self - .request_client - .get(url.as_ref()) + .send()? + } else { + self + .reqwest_client + .post(url) + .body(buffer.unwrap().to_vec()) + .basic_auth("api", self.key.as_ref()) .timeout(Duration::from_secs(300)) - .send()?, - _ => unreachable!(), + .send()? }; - match response.status() { - StatusCode::UNAUTHORIZED => Err(TinifyError::ClientError), - StatusCode::UNSUPPORTED_MEDIA_TYPE => Err(TinifyError::ClientError), - StatusCode::SERVICE_UNAVAILABLE => Err(TinifyError::ServerError), - _ => Ok(response), + match compressed_image.status() { + StatusCode::CREATED => { + if let Some(location) = compressed_image.headers().get("location") { + let location = location.to_str()?.to_string(); + let bytes = self + .reqwest_client + .get(&location) + .timeout(Duration::from_secs(300)) + .send()? + .bytes()? + .to_vec(); + + self.buffer = Some(bytes); + self.output = Some(location); + + Ok(self) + } else { + let upstream = Upstream { + error: "Empty".to_string(), + message: "The location of the compressed image is empty." + .to_string(), + }; + Err(TinifyError::ServerError { upstream }) + } + } + StatusCode::UNAUTHORIZED | StatusCode::UNSUPPORTED_MEDIA_TYPE => { + let upstream: Upstream = + serde_json::from_str(&compressed_image.text()?)?; + Err(TinifyError::ClientError { upstream }) + } + StatusCode::SERVICE_UNAVAILABLE => { + let upstream: Upstream = + serde_json::from_str(&compressed_image.text()?)?; + Err(TinifyError::ServerError { upstream }) + } + _ => unreachable!(), } } + #[allow(clippy::wrong_self_convention)] + pub(crate) fn from_buffer(self, buffer: &[u8]) -> Result { + self.get_source_from_response(Some(buffer), None) + } + #[allow(clippy::wrong_self_convention)] pub(crate) fn from_file

(self, path: P) -> Result where P: AsRef, { - let file = - File::open(path).map_err(|source| TinifyError::ReadError { source })?; + let file = File::open(path)?; let mut reader = BufReader::new(file); let mut buffer = Vec::with_capacity(reader.capacity()); reader.read_to_end(&mut buffer)?; - self.from_buffer(&buffer) - } - - #[allow(clippy::wrong_self_convention)] - pub(crate) fn from_buffer(self, buffer: &[u8]) -> Result { - let response = self.request("/shrink", Method::POST, Some(buffer))?; - - self.get_source_from_response(response) + self.get_source_from_response(Some(&buffer), None) } #[allow(clippy::wrong_self_convention)] - pub(crate) fn from_url(self, url: U) -> Result + pub(crate) fn from_url

(self, path: P) -> Result where - U: AsRef, + P: AsRef + Into, { - let get_request = self.request(url, Method::GET, None); - let buffer = get_request?.bytes()?; - let post_request = self.request("/shrink", Method::POST, Some(&buffer))?; + let json = json!({ + "source": SourceUrl { url: path.into() }, + }); - self.get_source_from_response(post_request) + self.get_source_from_response(None, Some(json)) } /// Resize the current compressed image. - /// - /// # Examples - /// - /// ``` - /// use tinify::sync::Tinify; - /// use tinify::sync::Client; - /// use tinify::error::TinifyError; - /// use tinify::resize::Method; - /// use tinify::resize::Resize; - /// - /// fn get_client() -> Result { - /// let key = "tinify api key"; - /// let tinify = Tinify::new(); - /// - /// tinify - /// .set_key(key) - /// .get_client() - /// } - /// - /// fn main() -> Result<(), TinifyError> { - /// let client = get_client()?; - /// let _ = client - /// .from_file("./unoptimized.jpg")? - /// .resize(Resize::new( - /// Method::FIT, - /// Some(400), - /// Some(200), - /// ))? - /// .to_file("./resized.jpg")?; - /// - /// Ok(()) - /// } - /// ``` - pub fn resize(self, resize: resize::Resize) -> Result { - let json_data = resize::JsonData::new(resize); - let mut json_string = serde_json::to_string(&json_data).unwrap(); - let width = json_data.resize.width; - let height = json_data.resize.height; - json_string = match ( - (width.is_some(), height.is_none()), - (height.is_some(), width.is_none()), - ) { - ((true, true), (_, _)) => json_string.replace(",\"height\":null", ""), - ((_, _), (true, true)) => json_string.replace(",\"width\":null", ""), - _ => json_string, - }; - let response = self - .request_client - .post(self.url.as_ref().unwrap()) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .body(json_string) - .basic_auth("api", self.key.as_ref()) - .timeout(Duration::from_secs(300)) - .send()?; + pub fn resize(mut self, resize: Resize) -> Result { + self.operations.resize = Some(resize); + Ok(self) + } - match response.status() { - StatusCode::BAD_REQUEST => Err(TinifyError::ClientError), - _ => self.get_source_from_response(response), - } + /// Convert the current compressed image. + pub fn convert(mut self, convert: Convert) -> Result { + self.operations.convert = Some(convert); + Ok(self) } - /// The following options are available as a type: - /// One image type, specified as a string `"image/webp"` - /// - /// Multiple image types, specified as a tuple (`"image/webp"`, `"image/png"`). - /// The smallest of the provided image types will be returned. - /// - /// The transform object specifies the stylistic transformations - /// that will be applied to the image. - /// - /// Include a background property to fill a transparent image's background. - /// - /// Specify a background color to convert an image with a transparent background - /// to an image type which does not support transparency (like JPEG). - /// - /// # Examples - /// - /// ``` - /// use tinify::sync::Tinify; - /// use tinify::error::TinifyError; - /// use tinify::convert::Color; - /// use tinify::convert::Type; - /// - /// fn main() -> Result<(), TinifyError> { - /// let _ = Tinify::new() - /// .set_key("api key") - /// .get_client()? - /// .from_url("https://tinypng.com/images/panda-happy.png")? - /// .convert(( - /// Some(Type::JPEG), - /// None, - /// None, - /// ), - /// Some(Color("#FF5733")), - /// )? - /// .to_file("./converted.webp"); - /// - /// Ok(()) - /// } - /// ``` - pub fn convert( - self, - convert_type: (Option, Option, Option), - transform: Option, - ) -> Result - where - T: Into + Copy, - { - let types = &[&convert_type.0, &convert_type.1, &convert_type.2]; - let count: Vec = types - .iter() - .filter_map(|&val| val.and_then(|x| Some(x.into()))) - .collect(); - let len = count.len(); - let parse_type = match len { - _ if len >= 2 => serde_json::to_string(&count).unwrap(), - _ => count.first().unwrap().to_string(), - }; - let template = if let Some(color) = transform { - JsonData::new(Convert::new(parse_type), Some(Transform::new(color.0))) - } else { - JsonData::new(Convert::new(parse_type), None) - }; + /// Transform the current compressed image. + pub fn transform( + mut self, + transform: Transform, + ) -> Result { + self.operations.transform = Some(transform); + Ok(self) + } - // Using replace to avoid invalid JSON string. - let json_string = serde_json::to_string(&template) - .unwrap() - .replace("\"convert_type\"", "\"type\"") - .replace(",\"transform\":null", "") - .replace("\"[", "[") - .replace("]\"", "]") - .replace("\\\"", "\""); - let response = self - .request_client - .post(self.url.as_ref().unwrap()) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .body(json_string) - .basic_auth("api", self.key.as_ref()) - .timeout(Duration::from_secs(300)) - .send()?; + fn run_operations(&mut self) -> Result<(), TinifyError> { + let operations = serde_json::to_string(&self.operations)?; - if response.status() == StatusCode::BAD_REQUEST { - return Err(TinifyError::ClientError); + if let Some(output) = self.output.take() { + let response = self + .reqwest_client + .post(output) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(operations) + .basic_auth("api", self.key.as_ref()) + .timeout(Duration::from_secs(300)) + .send()?; + + match response.status() { + StatusCode::OK => { + let bytes = response.bytes()?.to_vec(); + + self.buffer = Some(bytes); + + Ok(()) + } + StatusCode::BAD_REQUEST + | StatusCode::UNAUTHORIZED + | StatusCode::UNSUPPORTED_MEDIA_TYPE => { + let upstream: Upstream = serde_json::from_str(&response.text()?)?; + Err(TinifyError::ClientError { upstream }) + } + StatusCode::SERVICE_UNAVAILABLE => { + let upstream: Upstream = serde_json::from_str(&response.text()?)?; + Err(TinifyError::ServerError { upstream }) + } + _ => unreachable!(), + } + } else { + let upstream = Upstream { + error: "Empty".to_string(), + message: "Output of the compressed image is empty.".to_string(), + }; + Err(TinifyError::ClientError { upstream }) } - - self.get_source_from_response(response) } - - /// Save the compressed image to a file. - pub fn to_file

(&self, path: P) -> Result<(), TinifyError> + + /// Save the current compressed image to a file. + pub fn to_file

(&mut self, path: P) -> Result<(), TinifyError> where P: AsRef, { - let file = File::create(path) - .map_err(|source| TinifyError::WriteError { source })?; - let mut reader = BufWriter::new(file); - reader.write_all(self.buffer.as_ref().unwrap())?; - reader.flush()?; + if self.operations.convert.is_some() + || self.operations.resize.is_some() + || self.operations.transform.is_some() + { + self.run_operations()?; + } - Ok(()) - } + if let Some(ref buffer) = self.buffer { + let file = File::create(path)?; + let mut reader = BufWriter::new(file); + reader.write_all(buffer)?; + reader.flush()?; + } - /// Convert the compressed image to a buffer. - pub fn to_buffer(&self) -> Vec { - self.buffer.as_ref().unwrap().to_vec() + Ok(()) } - pub(crate) fn get_source_from_response( - mut self, - response: Response, - ) -> Result { - if let Some(location) = response.headers().get("location") { - let mut url = String::new(); - - if !location.is_empty() { - let slice = str::from_utf8(location.as_bytes()).unwrap(); - url.push_str(slice); - } + /// Save the current compressed image to a buffer. + pub fn to_buffer(&mut self) -> Result, TinifyError> { + if self.operations.convert.is_some() + || self.operations.resize.is_some() + || self.operations.transform.is_some() + { + self.run_operations()?; + } - let get_request = self.request(&url, Method::GET, None); - let bytes = get_request?.bytes()?.to_vec(); - self.buffer = Some(bytes); - self.url = Some(url); + if let Some(buffer) = self.buffer.as_ref() { + Ok(buffer.to_vec()) } else { - let bytes = response.bytes()?.to_vec(); - self.buffer = Some(bytes); - self.url = None; + let upstream = Upstream { + error: "Empty".to_string(), + message: "Buffer of the compressed image is empty.".to_string(), + }; + Err(TinifyError::ClientError { upstream }) } - - Ok(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use assert_matches::assert_matches; - - #[test] - fn test_request_error() { - let source = Source::new(None, None); - let request = source.request("", Method::GET, None).unwrap_err(); - - assert_matches!(request, TinifyError::ReqwestError { .. }); } } diff --git a/src/sync/tinify.rs b/src/sync/tinify.rs index d73aff2..3b6e9be 100644 --- a/src/sync/tinify.rs +++ b/src/sync/tinify.rs @@ -14,10 +14,7 @@ impl Tinify { } /// Set a Tinify Key. - pub fn set_key(mut self, key: K) -> Self - where - K: Into, - { + pub fn set_key(mut self, key: &str) -> Self { self.key = key.into(); self } @@ -58,7 +55,7 @@ mod tests { Ok(key) => key, Err(_err) => panic!("No such file or directory."), }; - let _ = Tinify::new().set_key(key).get_client()?; + let _ = Tinify::new().set_key(&key).get_client()?; Ok(()) } diff --git a/src/transform.rs b/src/transform.rs new file mode 100644 index 0000000..88982d6 --- /dev/null +++ b/src/transform.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; +use serde::Serialize; + +/// The transform object specifies the stylistic transformations that will be applied to your image. Include a `background property` to fill a transparent image's background. The following options are available to specify a background color: +/// - A hex value. Custom background color using the color's hex value: `#000000`. +/// - `white` or `black`. Only the colors white and black are supported as strings. +/// +/// You must specify a background color if you wish to convert an image with a transparent background to an image type which does not support transparency (like JPEG). +#[derive(Serialize, Deserialize, Debug)] +pub struct Transform { + pub background: String, +}