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
This commit is contained in:
2026-05-31 21:10:58 +02:00
parent f85c0cb246
commit ef64e86439
7 changed files with 127 additions and 9 deletions

View File

@@ -7,4 +7,7 @@ pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery}; pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery}; pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery};
pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery}; pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
pub use queries::read_derivative::{
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,
};
pub use visibility::VisibilityFilteredAssetRepository; pub use visibility::VisibilityFilteredAssetRepository;

View File

@@ -1,3 +1,4 @@
pub mod get_asset; pub mod get_asset;
pub mod get_timeline; pub mod get_timeline;
pub mod read_asset_file; pub mod read_asset_file;
pub mod read_derivative;

View File

@@ -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<dyn DerivativeRepository>,
file_storage: Arc<dyn FileStoragePort>,
}
impl ReadDerivativeHandler {
pub fn new(
derivative_repo: Arc<dyn DerivativeRepository>,
file_storage: Arc<dyn FileStoragePort>,
) -> Self {
Self {
derivative_repo,
file_storage,
}
}
pub async fn execute(
&self,
query: ReadDerivativeQuery,
) -> Result<DerivativeFileResult, DomainError> {
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,
})
}
}

View File

@@ -1,13 +1,13 @@
use std::sync::Arc; use std::sync::Arc;
use adapters_postgres::{ use adapters_postgres::{
PgPool, PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresDuplicateRepository, PgPool, PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresDerivativeRepository,
PostgresIngestTransaction, PostgresDuplicateRepository, PostgresIngestTransaction,
}; };
use adapters_storage::LocalFileStorage; use adapters_storage::LocalFileStorage;
use application::catalog::{ use application::catalog::{
GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, RegisterAssetHandler, GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, ReadDerivativeHandler,
UpdateMetadataHandler, RegisterAssetHandler, UpdateMetadataHandler,
}; };
use application::storage::IngestAssetHandler; use application::storage::IngestAssetHandler;
use domain::ports::EventPublisher; use domain::ports::EventPublisher;
@@ -23,6 +23,7 @@ pub fn build(
) -> CatalogHandlers { ) -> CatalogHandlers {
let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone())); let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone()));
let metadata_repo = Arc::new(PostgresAssetMetadataRepository::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 duplicate_repo = Arc::new(PostgresDuplicateRepository::new(pool.clone()));
let ingest_tx = Arc::new(PostgresIngestTransaction::new(pool.clone())); let ingest_tx = Arc::new(PostgresIngestTransaction::new(pool.clone()));
@@ -49,7 +50,12 @@ pub fn build(
event_publisher.clone(), 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( let register_asset = Arc::new(RegisterAssetHandler::new(
asset_repo, asset_repo,
@@ -63,6 +69,7 @@ pub fn build(
get_timeline, get_timeline,
update_metadata, update_metadata,
read_asset_file, read_asset_file,
read_derivative,
register_asset, register_asset,
} }
} }

View File

@@ -10,8 +10,8 @@ use api_types::{
}; };
use application::{ use application::{
catalog::{ catalog::{
GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery, RegisterAssetCommand, GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery, ReadDerivativeQuery,
UpdateMetadataCommand, RegisterAssetCommand, UpdateMetadataCommand,
}, },
organization::TagAssetCommand, organization::TagAssetCommand,
storage::IngestAssetCommand, storage::IngestAssetCommand,
@@ -152,6 +152,40 @@ pub async fn tag_asset(
Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag)))) Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag))))
} }
pub async fn serve_derivative(
State(state): State<AppState>,
_claims: JwtClaims,
Path((asset_id, profile)): Path<(uuid::Uuid, String)>,
) -> Result<Response, AppError> {
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<domain::entities::DerivativeProfile, AppError> {
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<AssetType, AppError> { fn parse_asset_type(s: &str) -> Result<AssetType, AppError> {
match s { match s {
"image" => Ok(AssetType::Image), "image" => Ok(AssetType::Image),

View File

@@ -29,6 +29,10 @@ pub fn api_v1_router() -> Router<AppState> {
.route("/assets/{id}", get(assets::get_asset)) .route("/assets/{id}", get(assets::get_asset))
.route("/assets/{id}/metadata", put(assets::update_metadata)) .route("/assets/{id}/metadata", put(assets::update_metadata))
.route("/assets/{id}/file", get(assets::serve_file)) .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)) .route("/assets/{id}/tags", post(assets::tag_asset))
// sharing // sharing
.route("/sharing", post(sharing::share_resource)) .route("/sharing", post(sharing::share_resource))

View File

@@ -2,8 +2,8 @@ use std::sync::Arc;
use application::{ use application::{
catalog::{ catalog::{
GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, RegisterAssetHandler, GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, ReadDerivativeHandler,
UpdateMetadataHandler, RegisterAssetHandler, UpdateMetadataHandler,
}, },
identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler}, identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler},
organization::{ organization::{
@@ -41,6 +41,7 @@ pub struct CatalogHandlers {
pub get_timeline: Arc<GetTimelineHandler>, pub get_timeline: Arc<GetTimelineHandler>,
pub update_metadata: Arc<UpdateMetadataHandler>, pub update_metadata: Arc<UpdateMetadataHandler>,
pub read_asset_file: Arc<ReadAssetFileHandler>, pub read_asset_file: Arc<ReadAssetFileHandler>,
pub read_derivative: Arc<ReadDerivativeHandler>,
pub register_asset: Arc<RegisterAssetHandler>, pub register_asset: Arc<RegisterAssetHandler>,
} }