feat(poster-storage): implement S3/Minio storage adapter and configuration
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
15
crates/adapters/poster-storage/Cargo.toml
Normal file
15
crates/adapters/poster-storage/Cargo.toml
Normal file
@@ -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 }
|
||||
38
crates/adapters/poster-storage/src/config.rs
Normal file
38
crates/adapters/poster-storage/src/config.rs
Normal file
@@ -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<Self> {
|
||||
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<Arc<dyn ObjectStore>> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
85
crates/adapters/poster-storage/src/lib.rs
Normal file
85
crates/adapters/poster-storage/src/lib.rs
Normal file
@@ -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<dyn ObjectStore>,
|
||||
}
|
||||
|
||||
impl PosterStorageAdapter {
|
||||
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub fn from_config(config: StorageConfig) -> anyhow::Result<Self> {
|
||||
Ok(Self::new(config.build_store()?))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PosterStorage for PosterStorageAdapter {
|
||||
async fn store_poster(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
image_bytes: &[u8],
|
||||
) -> Result<PosterPath, DomainError> {
|
||||
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<Vec<u8>, 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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<PosterPath, DomainError> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"poster storage not implemented".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_poster(&self, _path: &PosterPath) -> Result<Vec<u8>, 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<AppState> {
|
||||
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<AppState> {
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user