From ef64e864395b1e03451fe5c199c79c34d90c8321 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 21:10:58 +0200 Subject: [PATCH] feat: serve derivative files via GET /assets/{id}/derivatives/{profile} - ReadDerivativeHandler queries DerivativeRepository + FileStoragePort - Profile URL param: thumbnail, thumbnail_large, web_optimized, video_sd - Immutable cache headers (derivatives don't change once generated) - Wired into bootstrap catalog service builder --- crates/application/src/catalog/mod.rs | 3 + crates/application/src/catalog/queries/mod.rs | 1 + .../src/catalog/queries/read_derivative.rs | 68 +++++++++++++++++++ crates/bootstrap/src/services/catalog.rs | 17 +++-- crates/presentation/src/handlers/assets.rs | 38 ++++++++++- crates/presentation/src/routes.rs | 4 ++ crates/presentation/src/state.rs | 5 +- 7 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 crates/application/src/catalog/queries/read_derivative.rs diff --git a/crates/application/src/catalog/mod.rs b/crates/application/src/catalog/mod.rs index 0ac4ec7..b20beae 100644 --- a/crates/application/src/catalog/mod.rs +++ b/crates/application/src/catalog/mod.rs @@ -7,4 +7,7 @@ pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler pub use queries::get_asset::{GetAssetHandler, GetAssetQuery}; pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery}; pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery}; +pub use queries::read_derivative::{ + DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery, +}; pub use visibility::VisibilityFilteredAssetRepository; diff --git a/crates/application/src/catalog/queries/mod.rs b/crates/application/src/catalog/queries/mod.rs index 3ca1429..1263d02 100644 --- a/crates/application/src/catalog/queries/mod.rs +++ b/crates/application/src/catalog/queries/mod.rs @@ -1,3 +1,4 @@ pub mod get_asset; pub mod get_timeline; pub mod read_asset_file; +pub mod read_derivative; diff --git a/crates/application/src/catalog/queries/read_derivative.rs b/crates/application/src/catalog/queries/read_derivative.rs new file mode 100644 index 0000000..9d96901 --- /dev/null +++ b/crates/application/src/catalog/queries/read_derivative.rs @@ -0,0 +1,68 @@ +use bytes::Bytes; +use domain::{ + entities::{DerivativeProfile, GenerationStatus}, + errors::DomainError, + ports::{DerivativeRepository, FileStoragePort}, + value_objects::SystemId, +}; +use std::sync::Arc; + +pub struct ReadDerivativeQuery { + pub asset_id: SystemId, + pub profile: DerivativeProfile, +} + +pub struct DerivativeFileResult { + pub data: Bytes, + pub mime_type: String, +} + +pub struct ReadDerivativeHandler { + derivative_repo: Arc, + file_storage: Arc, +} + +impl ReadDerivativeHandler { + pub fn new( + derivative_repo: Arc, + file_storage: Arc, + ) -> Self { + Self { + derivative_repo, + file_storage, + } + } + + pub async fn execute( + &self, + query: ReadDerivativeQuery, + ) -> Result { + let derivative = self + .derivative_repo + .find_by_asset_and_profile(&query.asset_id, query.profile) + .await? + .ok_or_else(|| { + DomainError::NotFound(format!( + "Derivative {:?} not found for asset {}", + query.profile, query.asset_id + )) + })?; + + if derivative.generation_status != GenerationStatus::Ready { + return Err(DomainError::NotFound(format!( + "Derivative {:?} not ready for asset {}", + query.profile, query.asset_id + ))); + } + + let data = self + .file_storage + .read_file(&derivative.storage_path) + .await?; + + Ok(DerivativeFileResult { + data, + mime_type: derivative.mime_type, + }) + } +} diff --git a/crates/bootstrap/src/services/catalog.rs b/crates/bootstrap/src/services/catalog.rs index 15b6aa1..3138fff 100644 --- a/crates/bootstrap/src/services/catalog.rs +++ b/crates/bootstrap/src/services/catalog.rs @@ -1,13 +1,13 @@ use std::sync::Arc; use adapters_postgres::{ - PgPool, PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresDuplicateRepository, - PostgresIngestTransaction, + PgPool, PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresDerivativeRepository, + PostgresDuplicateRepository, PostgresIngestTransaction, }; use adapters_storage::LocalFileStorage; use application::catalog::{ - GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, RegisterAssetHandler, - UpdateMetadataHandler, + GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, ReadDerivativeHandler, + RegisterAssetHandler, UpdateMetadataHandler, }; use application::storage::IngestAssetHandler; use domain::ports::EventPublisher; @@ -23,6 +23,7 @@ pub fn build( ) -> CatalogHandlers { let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone())); let metadata_repo = Arc::new(PostgresAssetMetadataRepository::new(pool.clone())); + let derivative_repo = Arc::new(PostgresDerivativeRepository::new(pool.clone())); let duplicate_repo = Arc::new(PostgresDuplicateRepository::new(pool.clone())); let ingest_tx = Arc::new(PostgresIngestTransaction::new(pool.clone())); @@ -49,7 +50,12 @@ pub fn build( event_publisher.clone(), )); - let read_asset_file = Arc::new(ReadAssetFileHandler::new(asset_repo.clone(), file_storage)); + let read_asset_file = Arc::new(ReadAssetFileHandler::new( + asset_repo.clone(), + file_storage.clone(), + )); + + let read_derivative = Arc::new(ReadDerivativeHandler::new(derivative_repo, file_storage)); let register_asset = Arc::new(RegisterAssetHandler::new( asset_repo, @@ -63,6 +69,7 @@ pub fn build( get_timeline, update_metadata, read_asset_file, + read_derivative, register_asset, } } diff --git a/crates/presentation/src/handlers/assets.rs b/crates/presentation/src/handlers/assets.rs index 63a0307..3880676 100644 --- a/crates/presentation/src/handlers/assets.rs +++ b/crates/presentation/src/handlers/assets.rs @@ -10,8 +10,8 @@ use api_types::{ }; use application::{ catalog::{ - GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery, RegisterAssetCommand, - UpdateMetadataCommand, + GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery, ReadDerivativeQuery, + RegisterAssetCommand, UpdateMetadataCommand, }, organization::TagAssetCommand, storage::IngestAssetCommand, @@ -152,6 +152,40 @@ pub async fn tag_asset( Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag)))) } +pub async fn serve_derivative( + State(state): State, + _claims: JwtClaims, + Path((asset_id, profile)): Path<(uuid::Uuid, String)>, +) -> Result { + let profile = parse_derivative_profile(&profile)?; + let query = ReadDerivativeQuery { + asset_id: SystemId::from_uuid(asset_id), + profile, + }; + let result = state.catalog.read_derivative.execute(query).await?; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &result.mime_type) + .header(header::CONTENT_LENGTH, result.data.len()) + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .body(Body::from(result.data)) + .map_err(|e| AppError::from(DomainError::Internal(e.to_string()))) +} + +fn parse_derivative_profile(s: &str) -> Result { + use domain::entities::DerivativeProfile; + match s { + "thumbnail" | "thumbnail_square" => Ok(DerivativeProfile::ThumbnailSquare), + "thumbnail_large" => Ok(DerivativeProfile::ThumbnailLarge), + "web" | "web_optimized" => Ok(DerivativeProfile::WebOptimized), + "video_sd" => Ok(DerivativeProfile::VideoSd), + _ => Err(AppError::from(DomainError::Validation(format!( + "Unknown derivative profile: {s}" + )))), + } +} + fn parse_asset_type(s: &str) -> Result { match s { "image" => Ok(AssetType::Image), diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 51eb975..a4c6bd9 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -29,6 +29,10 @@ pub fn api_v1_router() -> Router { .route("/assets/{id}", get(assets::get_asset)) .route("/assets/{id}/metadata", put(assets::update_metadata)) .route("/assets/{id}/file", get(assets::serve_file)) + .route( + "/assets/{id}/derivatives/{profile}", + get(assets::serve_derivative), + ) .route("/assets/{id}/tags", post(assets::tag_asset)) // sharing .route("/sharing", post(sharing::share_resource)) diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 6490395..c7eb042 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use application::{ catalog::{ - GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, RegisterAssetHandler, - UpdateMetadataHandler, + GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, ReadDerivativeHandler, + RegisterAssetHandler, UpdateMetadataHandler, }, identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler}, organization::{ @@ -41,6 +41,7 @@ pub struct CatalogHandlers { pub get_timeline: Arc, pub update_metadata: Arc, pub read_asset_file: Arc, + pub read_derivative: Arc, pub register_asset: Arc, }