Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
[package]
|
||||
name = "image-converter"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
config = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
ravif = { version = "0.11", default-features = false }
|
||||
webp = "0.3"
|
||||
tokio = { workspace = true, features = ["rt"] }
|
||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||
ravif = { workspace = true }
|
||||
webp = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
object-storage = { workspace = true }
|
||||
object_store = "0.11"
|
||||
uuid = { workspace = true }
|
||||
object_store = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
@@ -49,7 +49,3 @@ impl PeriodicJob for ConversionBackfillJob {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/backfill.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -18,34 +18,22 @@ pub struct ConversionConfig {
|
||||
}
|
||||
|
||||
impl ConversionConfig {
|
||||
pub fn from_env() -> anyhow::Result<Option<Self>> {
|
||||
Self::from_vars(
|
||||
std::env::var("IMAGE_CONVERSION_ENABLED").ok().as_deref(),
|
||||
std::env::var("IMAGE_CONVERSION_FORMAT").ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn from_vars(enabled: Option<&str>, format: Option<&str>) -> anyhow::Result<Option<Self>> {
|
||||
if enabled != Some("true") {
|
||||
pub fn from_config(config: &::config::ImageConversionConfig) -> anyhow::Result<Option<Self>> {
|
||||
if !config.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let format_str = format.ok_or_else(|| {
|
||||
anyhow::anyhow!("IMAGE_CONVERSION_FORMAT required when IMAGE_CONVERSION_ENABLED=true")
|
||||
})?;
|
||||
|
||||
let format = match format_str {
|
||||
"avif" => Format::Avif,
|
||||
"webp" => Format::Webp,
|
||||
other => anyhow::bail!(
|
||||
let format = match config.format.as_deref() {
|
||||
Some("avif") => Format::Avif,
|
||||
Some("webp") => Format::Webp,
|
||||
Some(other) => anyhow::bail!(
|
||||
"Unknown IMAGE_CONVERSION_FORMAT: {other:?}. Valid values: avif, webp"
|
||||
),
|
||||
None => {
|
||||
anyhow::bail!("IMAGE_CONVERSION_FORMAT required when IMAGE_CONVERSION_ENABLED=true")
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(Self { format }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/config.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -31,6 +31,10 @@ impl ImageConversionHandler {
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for ImageConversionHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"image_conversion"
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let key = match event {
|
||||
DomainEvent::ImageStored { key } => key.clone(),
|
||||
@@ -41,6 +45,10 @@ impl EventHandler for ImageConversionHandler {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.storage.exists(&key).await? {
|
||||
tracing::warn!(%key, "original image already gone, treating conversion as done");
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = self.storage.get(&key).await?;
|
||||
let format = self.format;
|
||||
|
||||
@@ -99,7 +107,3 @@ fn convert(bytes: Vec<u8>, format: Format) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/handler.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -14,12 +14,13 @@ use std::sync::Arc;
|
||||
type ConversionPair = (Arc<dyn EventHandler>, Arc<dyn PeriodicJob>);
|
||||
|
||||
pub fn build(
|
||||
config: &::config::ImageConversionConfig,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
image_ref_command: Arc<dyn ImageRefCommand>,
|
||||
image_ref_query: Arc<dyn ImageRefQuery>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
) -> anyhow::Result<Option<ConversionPair>> {
|
||||
let config = match ConversionConfig::from_env()? {
|
||||
let config = match ConversionConfig::from_config(config)? {
|
||||
Some(c) => c,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn disabled_by_default() {
|
||||
assert!(ConversionConfig::from_vars(None, None).unwrap().is_none());
|
||||
assert!(ConversionConfig::from_vars(Some("false"), None)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_avif() {
|
||||
let cfg = ConversionConfig::from_vars(Some("true"), Some("avif"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.format, Format::Avif);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_webp() {
|
||||
let cfg = ConversionConfig::from_vars(Some("true"), Some("webp"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.format, Format::Webp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_format_is_error() {
|
||||
assert!(ConversionConfig::from_vars(Some("true"), Some("gif")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_format_when_enabled_is_error() {
|
||||
assert!(ConversionConfig::from_vars(Some("true"), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avif_extension() {
|
||||
assert_eq!(Format::Avif.extension(), ".avif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_extension() {
|
||||
assert_eq!(Format::Webp.extension(), ".webp");
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{EventPublisher, ImageRefQuery, PeriodicJob},
|
||||
};
|
||||
use image_converter::ConversionBackfillJob;
|
||||
|
||||
struct MockImageRef {
|
||||
keys: Vec<String>,
|
||||
@@ -0,0 +1,54 @@
|
||||
use image_converter::{ConversionConfig, Format};
|
||||
|
||||
fn section(enabled: bool, format: Option<&str>) -> config::ImageConversionConfig {
|
||||
config::ImageConversionConfig {
|
||||
enabled,
|
||||
format: format.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(enabled: bool, format: Option<&str>) -> anyhow::Result<Option<ConversionConfig>> {
|
||||
ConversionConfig::from_config(§ion(enabled, format))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_disabled_by_default() {
|
||||
assert!(
|
||||
ConversionConfig::from_config(&config::ImageConversionConfig::default())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(convert(false, Some("webp")).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_with_avif_format() {
|
||||
let cfg = convert(true, Some("avif")).unwrap().unwrap();
|
||||
assert_eq!(cfg.format, Format::Avif);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_with_webp_format() {
|
||||
let cfg = convert(true, Some("webp")).unwrap().unwrap();
|
||||
assert_eq!(cfg.format, Format::Webp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_format_is_rejected() {
|
||||
assert!(convert(true, Some("gif")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_format_when_enabled_is_rejected() {
|
||||
assert!(convert(true, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avif_extension_is_dot_avif() {
|
||||
assert_eq!(Format::Avif.extension(), ".avif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_extension_is_dot_webp() {
|
||||
assert_eq!(Format::Webp.extension(), ".webp");
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{EventHandler, ImageRefCommand, ObjectStorage},
|
||||
};
|
||||
use image_converter::{Format, ImageConversionHandler};
|
||||
use object_storage::ObjectStorageAdapter;
|
||||
use object_store::memory::InMemory;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockImageRef {
|
||||
swaps: Mutex<Vec<(String, String)>>,
|
||||
@@ -156,3 +162,32 @@ async fn converts_jpeg_to_webp_and_swaps_key() {
|
||||
);
|
||||
assert!(storage.get("avatars/u1.webp").await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_conversion_is_idempotent_when_run_twice() {
|
||||
for format in [Format::Webp, Format::Avif] {
|
||||
let storage = in_memory_storage();
|
||||
storage.store("posters/m1", &tiny_jpeg()).await.unwrap();
|
||||
let image_ref = MockImageRef::new();
|
||||
let handler = ImageConversionHandler::new(
|
||||
Arc::clone(&storage) as Arc<dyn ObjectStorage>,
|
||||
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
|
||||
format,
|
||||
);
|
||||
let event = DomainEvent::ImageStored {
|
||||
key: "posters/m1".into(),
|
||||
};
|
||||
let new_key = format!("posters/m1{}", format.extension());
|
||||
|
||||
handler.handle(&event).await.unwrap();
|
||||
let converted = storage.get(&new_key).await.unwrap();
|
||||
handler.handle(&event).await.unwrap();
|
||||
|
||||
assert_eq!(image_ref.swaps().len(), 1, "one swap");
|
||||
assert_eq!(
|
||||
storage.get(&new_key).await.unwrap(),
|
||||
converted,
|
||||
"converted bytes are not re-encoded"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user