diff --git a/.env.example b/.env.example index 2f9b67e..9ca8ec7 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,8 @@ PORT=3000 JWT_SECRET= JWT_TTL_SECONDS= ALLOW_REGISTRATION=true -OMDB_API_KEY= \ No newline at end of file +OMDB_API_KEY= +MINIO_ENDPOINT= +MINIO_ACCESS_KEY_ID= +MINIO_SECRET_ACCESS_KEY= +MINIO_BUCKET= \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7a2d767..9d72ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -637,6 +637,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -681,6 +696,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -699,8 +725,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -899,6 +927,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.9.0" @@ -931,6 +965,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1122,6 +1157,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1328,7 +1372,7 @@ version = "0.1.0" dependencies = [ "async-trait", "domain", - "reqwest", + "reqwest 0.13.3", "serde", ] @@ -1430,6 +1474,36 @@ dependencies = [ "libm", ] +[[package]] +name = "object_store" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cfccb68961a56facde1163f9319e0d15743352344e7808a11795fb99698dcaf" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "humantime", + "hyper", + "itertools", + "md-5", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.8.6", + "reqwest 0.12.28", + "ring", + "serde", + "serde_json", + "snafu", + "tokio", + "tracing", + "url", + "walkdir", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1546,6 +1620,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "poster-storage" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "domain", + "object_store", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1584,6 +1671,7 @@ dependencies = [ "dotenvy", "http-body-util", "metadata", + "poster-storage", "serde", "serde_json", "sqlite", @@ -1617,6 +1705,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1788,6 +1886,48 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -2189,6 +2329,27 @@ dependencies = [ "serde", ] +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3006,6 +3167,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index d28bc20..35a2225 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] members = [ "crates/adapters/auth", - "crates/adapters/metadata", + "crates/adapters/metadata", "crates/adapters/poster-storage", "crates/adapters/rss", "crates/adapters/sqlite", "crates/adapters/template-askama", @@ -31,6 +31,7 @@ sqlx = { version = "0.8.6", features = [ "macros", ] } reqwest = { version = "0.13", features = ["json", "query"] } +object_store = { version = "0.11", features = ["aws"] } domain = { path = "crates/domain" } common = { path = "crates/common" } @@ -38,6 +39,7 @@ application = { path = "crates/application" } presentation = { path = "crates/presentation" } auth = { path = "crates/adapters/auth" } metadata = { path = "crates/adapters/metadata" } +poster-storage = { path = "crates/adapters/poster-storage" } rss = { path = "crates/adapters/rss" } sqlite = { path = "crates/adapters/sqlite" } template-askama = { path = "crates/adapters/template-askama" } diff --git a/crates/adapters/poster-storage/Cargo.toml b/crates/adapters/poster-storage/Cargo.toml new file mode 100644 index 0000000..3b425d9 --- /dev/null +++ b/crates/adapters/poster-storage/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "poster-storage" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +object_store = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +uuid = { workspace = true } diff --git a/crates/adapters/poster-storage/src/config.rs b/crates/adapters/poster-storage/src/config.rs new file mode 100644 index 0000000..11a1390 --- /dev/null +++ b/crates/adapters/poster-storage/src/config.rs @@ -0,0 +1,38 @@ +use anyhow::Context; +use object_store::{aws::AmazonS3Builder, ObjectStore}; +use std::sync::Arc; + +pub struct StorageConfig { + endpoint: String, + access_key_id: String, + secret_access_key: String, + bucket: String, + region: String, +} + +impl StorageConfig { + pub fn from_env() -> anyhow::Result { + Ok(Self { + endpoint: std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?, + access_key_id: std::env::var("MINIO_ACCESS_KEY_ID") + .context("MINIO_ACCESS_KEY_ID required")?, + secret_access_key: std::env::var("MINIO_SECRET_ACCESS_KEY") + .context("MINIO_SECRET_ACCESS_KEY required")?, + bucket: std::env::var("MINIO_BUCKET").context("MINIO_BUCKET required")?, + region: std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()), + }) + } + + pub fn build_store(self) -> anyhow::Result> { + let store = AmazonS3Builder::new() + .with_endpoint(self.endpoint) + .with_access_key_id(self.access_key_id) + .with_secret_access_key(self.secret_access_key) + .with_bucket_name(self.bucket) + .with_region(self.region) + .with_allow_http(true) + .build() + .context("Failed to build S3/Minio store")?; + Ok(Arc::new(store)) + } +} diff --git a/crates/adapters/poster-storage/src/lib.rs b/crates/adapters/poster-storage/src/lib.rs new file mode 100644 index 0000000..50b9008 --- /dev/null +++ b/crates/adapters/poster-storage/src/lib.rs @@ -0,0 +1,85 @@ +mod config; +pub use config::StorageConfig; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::PosterStorage, + value_objects::{MovieId, PosterPath}, +}; +use object_store::{path::Path, ObjectStore}; +use std::sync::Arc; + +pub struct PosterStorageAdapter { + store: Arc, +} + +impl PosterStorageAdapter { + pub fn new(store: Arc) -> Self { + Self { store } + } + + pub fn from_config(config: StorageConfig) -> anyhow::Result { + Ok(Self::new(config.build_store()?)) + } +} + +#[async_trait] +impl PosterStorage for PosterStorageAdapter { + async fn store_poster( + &self, + movie_id: &MovieId, + image_bytes: &[u8], + ) -> Result { + let path = Path::from(movie_id.value().to_string()); + self.store + .put(&path, image_bytes.to_vec().into()) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + PosterPath::new(path.to_string()) + } + + async fn get_poster(&self, poster_path: &PosterPath) -> Result, DomainError> { + let path = Path::from(poster_path.value().to_string()); + let result = self.store.get(&path).await.map_err(|e| match e { + object_store::Error::NotFound { .. } => DomainError::NotFound("Poster not found".into()), + _ => DomainError::InfrastructureError(e.to_string()), + })?; + result + .bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use object_store::memory::InMemory; + use uuid::Uuid; + + fn adapter() -> PosterStorageAdapter { + PosterStorageAdapter::new(Arc::new(InMemory::new())) + } + + #[tokio::test] + async fn store_and_retrieve_round_trip() { + let adapter = adapter(); + let movie_id = MovieId::from_uuid(Uuid::new_v4()); + let bytes = b"fake-image-bytes"; + + let path = adapter.store_poster(&movie_id, bytes).await.unwrap(); + let retrieved = adapter.get_poster(&path).await.unwrap(); + + assert_eq!(retrieved, bytes); + } + + #[tokio::test] + async fn get_missing_returns_not_found() { + let adapter = adapter(); + let path = PosterPath::new("nonexistent".into()).unwrap(); + let result = adapter.get_poster(&path).await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); + } +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index e0a50f4..f4f6c34 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -23,6 +23,7 @@ domain = { workspace = true } application = { workspace = true } auth = { workspace = true } metadata = { workspace = true } +poster-storage = { workspace = true } sqlite = { workspace = true } sqlx = { workspace = true } template-askama = { workspace = true } diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index daf9e0a..94c8afd 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -5,8 +5,8 @@ use async_trait::async_trait; use domain::{ errors::DomainError, events::DomainEvent, - ports::{EventPublisher, PosterFetcherClient, PosterStorage}, - value_objects::{MovieId, PosterPath, PosterUrl}, + ports::{EventPublisher, PosterFetcherClient}, + value_objects::PosterUrl, }; use sqlx::SqlitePool; use tokio::net::TcpListener; @@ -15,6 +15,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use application::{config::AppConfig, context::AppContext}; use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService}; use metadata::MetadataClientImpl; +use poster_storage::{PosterStorageAdapter, StorageConfig}; use sqlite::{SqliteMovieRepository, SqliteUserRepository}; use template_askama::AskamaHtmlRenderer; @@ -31,27 +32,6 @@ impl PosterFetcherClient for StubPosterFetcher { } } -struct StubPosterStorage; - -#[async_trait] -impl PosterStorage for StubPosterStorage { - async fn store_poster( - &self, - _movie_id: &MovieId, - _bytes: &[u8], - ) -> Result { - Err(DomainError::InfrastructureError( - "poster storage not implemented".into(), - )) - } - - async fn get_poster(&self, _path: &PosterPath) -> Result, DomainError> { - Err(DomainError::InfrastructureError( - "poster storage not implemented".into(), - )) - } -} - struct StubEventPublisher; #[async_trait] @@ -81,6 +61,7 @@ async fn main() -> anyhow::Result<()> { async fn wire_dependencies() -> anyhow::Result { let auth_config = AuthConfig::from_env()?; + let storage_config = StorageConfig::from_env()?; let app_config = AppConfig::from_env(); let omdb_api_key = std::env::var("OMDB_API_KEY").context("OMDB_API_KEY must be set")?; @@ -101,7 +82,7 @@ async fn wire_dependencies() -> anyhow::Result { repository: Arc::new(movie_repo), metadata_client: Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)), poster_fetcher: Arc::new(StubPosterFetcher), - poster_storage: Arc::new(StubPosterStorage), + poster_storage: Arc::new(PosterStorageAdapter::from_config(storage_config)?), event_publisher: Arc::new(StubEventPublisher), auth_service: Arc::new(JwtAuthService::new(auth_config)), password_hasher: Arc::new(Argon2PasswordHasher),