mod client;
mod error;
mod options;
mod result;
mod source;
pub use client::{Client, ClientBuilder, RateLimit, RetryConfig};
pub use error::{Result, TinifyError};
pub use options::{
ConvertOptions, GCSOptions, ImageFormat, PreserveMetadata, PreserveOptions, ResizeMethod,
ResizeOptions, S3Options, StoreOptions, StoreRequest,
};
pub use result::TinifyResult;
pub use source::Source;
use mime::Mime;
use serde_json::json;
use std::{path::Path, sync::Arc};
use tokio::io::AsyncRead;
use tracing::{info, instrument};
const SHRINK_ENDPOINT: &str = "https://api.tinify.com/shrink";
const MAX_FILE_SIZE: u64 = 5 * 1024 * 1024; const SUPPORTED_FORMATS: &[&str] = &["png", "jpg", "jpeg", "webp"];
#[derive(Clone)]
pub struct Tinify {
client: Arc<Client>,
}
impl Tinify {
pub fn new(api_key: String) -> Result<Self> {
let client = Client::new(api_key)?;
Ok(Self {
client: Arc::new(client),
})
}
pub fn builder() -> TinifyBuilder {
TinifyBuilder::new()
}
fn validate_image_format<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.ok_or_else(|| TinifyError::UnsupportedFormat {
format: "unknown".to_string(),
})?;
let ext_lower = extension.to_lowercase();
if !SUPPORTED_FORMATS.contains(&ext_lower.as_str()) {
return Err(TinifyError::UnsupportedFormat { format: ext_lower });
}
Ok(())
}
fn validate_dimensions(width: Option<u32>, height: Option<u32>) -> Result<()> {
match (width, height) {
(None, None) => Err(TinifyError::InvalidDimensions { width, height }),
(Some(0), _) => Err(TinifyError::InvalidDimensions { width, height }),
(_, Some(0)) => Err(TinifyError::InvalidDimensions { width, height }),
(Some(w), _) if w > 10000 => Err(TinifyError::InvalidDimensions { width, height }),
(_, Some(h)) if h > 10000 => Err(TinifyError::InvalidDimensions { width, height }),
_ => Ok(()),
}
}
#[instrument(skip(self), fields(path = %path.as_ref().display()))]
pub async fn source_from_file<P: AsRef<Path>>(&self, path: P) -> Result<Source> {
let path = path.as_ref();
info!("Creating source from file: {}", path.display());
if !path.exists() {
return Err(TinifyError::FileNotFound {
path: path.to_path_buf(),
});
}
let metadata = tokio::fs::metadata(path).await?;
if metadata.len() > MAX_FILE_SIZE {
return Err(TinifyError::FileTooLarge {
size: metadata.len(),
max_size: MAX_FILE_SIZE,
});
}
Self::validate_image_format(path)?;
let data = tokio::fs::read(path).await?;
self.source_from_buffer(data).await
}
#[instrument(skip(self, data), fields(data_size = data.len()))]
pub async fn source_from_buffer(&self, data: Vec<u8>) -> Result<Source> {
info!("Creating source from buffer of {} bytes", data.len());
if data.len() as u64 > MAX_FILE_SIZE {
return Err(TinifyError::FileTooLarge {
size: data.len() as u64,
max_size: MAX_FILE_SIZE,
});
}
let response = self.client.post(SHRINK_ENDPOINT, Some(data)).await?;
let location = response
.headers()
.get("Location")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| TinifyError::UnknownError {
message: "Missing Location header in server response".to_string(),
})?;
Ok(Source::new(location.to_string(), Arc::clone(&self.client)))
}
#[instrument(skip(self), fields(url = %url.as_ref()))]
pub async fn source_from_url<S: AsRef<str>>(&self, url: S) -> Result<Source> {
let url_str = url.as_ref();
info!("Creating source from URL: {}", url_str);
let _parsed_url = url::Url::parse(url_str)?;
let body = serde_json::to_vec(&json!({ "source": { "url": url_str } }))?;
let response = self.client.post(SHRINK_ENDPOINT, Some(body)).await?;
let location = response
.headers()
.get("Location")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| TinifyError::UnknownError {
message: "Missing Location header in server response".to_string(),
})?;
Ok(Source::new(location.to_string(), Arc::clone(&self.client)))
}
#[instrument(skip(self, stream), fields(content_type = %content_type))]
pub async fn source_from_stream<R>(&self, stream: R, content_type: &str) -> Result<Source>
where
R: AsyncRead + Send + Sync + 'static,
{
info!(
"Creating source from stream with content type: {}",
content_type
);
let _mime: Mime = content_type
.parse()
.map_err(|_| TinifyError::UnsupportedFormat {
format: content_type.to_string(),
})?;
let response = self
.client
.post_stream(SHRINK_ENDPOINT, stream, content_type)
.await?;
let location = response
.headers()
.get("Location")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| TinifyError::UnknownError {
message: "Missing Location header in server response".to_string(),
})?;
Ok(Source::new(location.to_string(), Arc::clone(&self.client)))
}
pub fn api_key(&self) -> &str {
self.client.api_key()
}
}
pub struct TinifyBuilder {
inner: ClientBuilder,
}
impl TinifyBuilder {
pub fn new() -> Self {
Self {
inner: ClientBuilder::new(),
}
}
pub fn api_key<S: Into<String>>(mut self, key: S) -> Self {
self.inner = self.inner.api_key(key);
self
}
pub fn app_identifier<S: Into<String>>(mut self, identifier: S) -> Self {
self.inner = self.inner.app_identifier(identifier);
self
}
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
self.inner = self.inner.timeout(timeout);
self
}
pub fn retry_config(mut self, config: RetryConfig) -> Self {
self.inner = self.inner.retry_config(config);
self
}
pub fn rate_limit(mut self, limit: RateLimit) -> Self {
self.inner = self.inner.rate_limit(limit);
self
}
pub fn max_retry_attempts(mut self, attempts: u32) -> Self {
self.inner = self.inner.max_retry_attempts(attempts);
self
}
pub fn requests_per_minute(mut self, rpm: u32) -> Self {
self.inner = self.inner.requests_per_minute(rpm);
self
}
pub fn build(self) -> Result<Tinify> {
let client = self.inner.build()?;
Ok(Tinify {
client: Arc::new(client),
})
}
}
impl Default for TinifyBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use tempfile::NamedTempFile;
use tracing_test::traced_test;
fn get_test_api_key() -> String {
env::var("TINIFY_API_KEY").unwrap_or_else(|_| {
println!(
"Warning: TINIFY_API_KEY environment variable not set. Using mock key for tests."
);
"test-api-key-for-unit-tests".to_string()
})
}
#[tokio::test]
#[traced_test]
async fn test_client_creation() {
let client = Tinify::new("test-api-key".to_string());
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(client.api_key(), "test-api-key");
}
#[tokio::test]
#[traced_test]
async fn test_client_builder() {
let client = Tinify::builder()
.api_key("test-key")
.app_identifier("TestApp/1.0")
.max_retry_attempts(5)
.requests_per_minute(200)
.build();
assert!(client.is_ok());
}
#[tokio::test]
#[traced_test]
async fn test_invalid_api_key() {
let result = Tinify::builder().build();
assert!(matches!(result, Err(TinifyError::InvalidApiKey)));
}
#[tokio::test]
#[traced_test]
async fn test_file_not_found() {
let client = Tinify::new(get_test_api_key()).unwrap();
let result = client.source_from_file("nonexistent.png").await;
assert!(matches!(result, Err(TinifyError::FileNotFound { .. })));
}
#[tokio::test]
#[traced_test]
async fn test_unsupported_format() {
let client = Tinify::new(get_test_api_key()).unwrap();
let temp_file = NamedTempFile::with_suffix(".txt").unwrap();
let result = client.source_from_file(temp_file.path()).await;
assert!(matches!(result, Err(TinifyError::UnsupportedFormat { .. })));
}
#[tokio::test]
#[traced_test]
async fn test_buffer_too_large() {
let client = Tinify::new(get_test_api_key()).unwrap();
let large_buffer = vec![0u8; (MAX_FILE_SIZE + 1) as usize];
let result = client.source_from_buffer(large_buffer).await;
assert!(matches!(result, Err(TinifyError::FileTooLarge { .. })));
}
#[tokio::test]
#[traced_test]
async fn test_invalid_url() {
let client = Tinify::new(get_test_api_key()).unwrap();
let result = client.source_from_url("not-a-url").await;
assert!(matches!(result, Err(TinifyError::UrlParseError(_))));
}
#[tokio::test]
#[traced_test]
async fn test_integration_from_file() {
let api_key = match env::var("TINIFY_API_KEY") {
Ok(key) => key,
Err(_) => {
println!("Skipping integration test - TINIFY_API_KEY not set");
return;
}
};
if !std::path::Path::new("./test_file.png").exists() {
println!("Skipping integration test - test_file.png not found");
return;
}
let client = Tinify::new(api_key).unwrap();
let result = client.source_from_file("./test_file.png").await;
match &result {
Ok(_) => println!("Integration test passed: source_from_file"),
Err(e) => println!("Integration test failed: {:?}", e),
}
}
}