From eaf4c90fa81aeecc0ee4864578f08d92252b6bf8 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 4 Nov 2025 05:57:04 +0100 Subject: [PATCH] feat: implement media listing with sorting and filtering options --- libertas_api/src/config.rs | 5 ++ libertas_api/src/extractors/mod.rs | 1 + libertas_api/src/extractors/query_options.rs | 42 ++++++++++ libertas_api/src/factory.rs | 2 +- libertas_api/src/handlers/media_handlers.rs | 27 ++++--- libertas_api/src/main.rs | 2 + libertas_api/src/schema.rs | 18 +++++ libertas_api/src/services/media_service.rs | 6 +- libertas_core/src/config.rs | 1 + libertas_core/src/repositories.rs | 4 +- libertas_core/src/schema.rs | 26 ++++++ libertas_core/src/services.rs | 5 +- libertas_infra/src/factory.rs | 6 +- libertas_infra/src/lib.rs | 3 +- libertas_infra/src/query_builder.rs | 80 +++++++++++++++++++ .../src/repositories/media_repository.rs | 42 ++++++---- libertas_worker/src/config.rs | 5 ++ libertas_worker/src/main.rs | 2 +- 18 files changed, 238 insertions(+), 39 deletions(-) create mode 100644 libertas_api/src/extractors/mod.rs create mode 100644 libertas_api/src/extractors/query_options.rs create mode 100644 libertas_api/src/schema.rs create mode 100644 libertas_infra/src/query_builder.rs diff --git a/libertas_api/src/config.rs b/libertas_api/src/config.rs index 0236691..8aab546 100644 --- a/libertas_api/src/config.rs +++ b/libertas_api/src/config.rs @@ -15,5 +15,10 @@ pub fn load_config() -> CoreResult { broker_url: "nats://localhost:4222".to_string(), max_upload_size_mb: Some(100), default_storage_quota_gb: Some(10), + allowed_sort_columns: Some(vec![ + "date_taken".to_string(), + "created_at".to_string(), + "original_filename".to_string(), + ]), }) } diff --git a/libertas_api/src/extractors/mod.rs b/libertas_api/src/extractors/mod.rs new file mode 100644 index 0000000..e65a548 --- /dev/null +++ b/libertas_api/src/extractors/mod.rs @@ -0,0 +1 @@ +pub mod query_options; \ No newline at end of file diff --git a/libertas_api/src/extractors/query_options.rs b/libertas_api/src/extractors/query_options.rs new file mode 100644 index 0000000..cfce245 --- /dev/null +++ b/libertas_api/src/extractors/query_options.rs @@ -0,0 +1,42 @@ +use axum::{extract::{FromRequestParts, Query}, http::request::Parts}; +use libertas_core::{error::CoreError, schema::{FilterParams, ListMediaOptions, SortOrder, SortParams}}; + +use crate::{error::ApiError, schema::ListMediaParams, state::AppState}; + +pub struct ApiListMediaOptions(pub ListMediaOptions); + +impl From for ListMediaOptions { + fn from(params: ListMediaParams) -> Self { + let sort = params.sort_by.map(|field| { + let order = match params.order.as_deref() { + Some("asc") => SortOrder::Asc, + _ => SortOrder::Desc, + }; + SortParams { + sort_by: field, + sort_order: order, + } + }); + + let filter = Some(FilterParams { + // e.g., mime_type: params.mime_type + }); + + ListMediaOptions { sort, filter } + } +} + +impl FromRequestParts for ApiListMediaOptions { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let Query(params) = Query::::from_request_parts(parts, state) + .await + .map_err(|e| ApiError::from(CoreError::Validation(e.to_string())))?; + + Ok(ApiListMediaOptions(params.into())) + } +} \ No newline at end of file diff --git a/libertas_api/src/factory.rs b/libertas_api/src/factory.rs index 97f9bb2..3a1a26e 100644 --- a/libertas_api/src/factory.rs +++ b/libertas_api/src/factory.rs @@ -27,7 +27,7 @@ pub async fn build_app_state(config: Config) -> CoreResult { let db_pool = build_database_pool(&config.database).await?; let user_repo = build_user_repository(&config.database, db_pool.clone()).await?; - let media_repo = build_media_repository(&config.database, db_pool.clone()).await?; + let media_repo = build_media_repository(&config, db_pool.clone()).await?; let album_repo = build_album_repository(&config.database, db_pool.clone()).await?; let album_share_repo = build_album_share_repository(&config.database, db_pool.clone()).await?; diff --git a/libertas_api/src/handlers/media_handlers.rs b/libertas_api/src/handlers/media_handlers.rs index 3a84c4c..223fc24 100644 --- a/libertas_api/src/handlers/media_handlers.rs +++ b/libertas_api/src/handlers/media_handlers.rs @@ -7,23 +7,14 @@ use axum::{ }; use futures::TryStreamExt; use libertas_core::{error::CoreError, models::Media, schema::UploadMediaData}; -use serde::Serialize; use std::{io, path::PathBuf}; use tower::ServiceExt; use tower_http::services::ServeFile; use uuid::Uuid; -use crate::{error::ApiError, middleware::auth::UserId, state::AppState}; +use crate::{error::ApiError, extractors::query_options::ApiListMediaOptions, middleware::auth::UserId, schema::MediaResponse, state::AppState}; -#[derive(Serialize)] -pub struct MediaResponse { - id: uuid::Uuid, - storage_path: String, - original_filename: String, - mime_type: String, - hash: String, -} impl From for MediaResponse { fn from(media: Media) -> Self { @@ -39,7 +30,7 @@ impl From for MediaResponse { pub fn media_routes(max_upload_size: usize) -> Router { Router::new() - .route("/", post(upload_media)) + .route("/", post(upload_media).get(list_user_media)) .route("/{id}", get(get_media_details).delete(delete_media)) .route("/{id}/file", get(get_media_file)) .layer(DefaultBodyLimit::max(max_upload_size)) @@ -123,3 +114,17 @@ async fn delete_media( state.media_service.delete_media(id, user_id).await?; Ok(StatusCode::NO_CONTENT) } + +async fn list_user_media( + State(state): State, + UserId(user_id): UserId, + ApiListMediaOptions(options): ApiListMediaOptions, +) -> Result>, ApiError> { + let media_list = state + .media_service + .list_user_media(user_id, options) + .await?; + + let response = media_list.into_iter().map(MediaResponse::from).collect(); + Ok(Json(response)) +} \ No newline at end of file diff --git a/libertas_api/src/main.rs b/libertas_api/src/main.rs index 1942e9b..fb6452b 100644 --- a/libertas_api/src/main.rs +++ b/libertas_api/src/main.rs @@ -11,6 +11,8 @@ pub mod routes; pub mod security; pub mod services; pub mod state; +pub mod extractors; +pub mod schema; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs new file mode 100644 index 0000000..b00167f --- /dev/null +++ b/libertas_api/src/schema.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct MediaResponse { + pub id: uuid::Uuid, + pub storage_path: String, + pub original_filename: String, + pub mime_type: String, + pub hash: String, +} + +#[derive(Deserialize)] +pub struct ListMediaParams { + pub sort_by: Option, + pub order: Option, + // You can add future filters here, e.g.: + // pub mime_type: Option, +} \ No newline at end of file diff --git a/libertas_api/src/services/media_service.rs b/libertas_api/src/services/media_service.rs index 646297d..2e40c63 100644 --- a/libertas_api/src/services/media_service.rs +++ b/libertas_api/src/services/media_service.rs @@ -9,7 +9,7 @@ use libertas_core::{ error::{CoreError, CoreResult}, models::Media, repositories::{AlbumShareRepository, MediaRepository, UserRepository}, - schema::UploadMediaData, + schema::{ListMediaOptions, UploadMediaData}, services::MediaService, }; use serde_json::json; @@ -95,8 +95,8 @@ impl MediaService for MediaServiceImpl { Err(CoreError::Auth("Access denied".to_string())) } - async fn list_user_media(&self, user_id: Uuid) -> CoreResult> { - self.repo.list_by_user(user_id).await + async fn list_user_media(&self, user_id: Uuid, options: ListMediaOptions) -> CoreResult> { + self.repo.list_by_user(user_id, &options).await } async fn get_media_filepath(&self, id: Uuid, user_id: Uuid) -> CoreResult { diff --git a/libertas_core/src/config.rs b/libertas_core/src/config.rs index 99b572c..97de708 100644 --- a/libertas_core/src/config.rs +++ b/libertas_core/src/config.rs @@ -21,4 +21,5 @@ pub struct Config { pub broker_url: String, pub max_upload_size_mb: Option, pub default_storage_quota_gb: Option, + pub allowed_sort_columns: Option>, } diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index f027e5c..577973e 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::{ error::CoreResult, - models::{Album, AlbumPermission, Media, User}, + models::{Album, AlbumPermission, Media, User}, schema::ListMediaOptions, }; #[async_trait] @@ -11,7 +11,7 @@ pub trait MediaRepository: Send + Sync { async fn find_by_hash(&self, hash: &str) -> CoreResult>; async fn create(&self, media: &Media) -> CoreResult<()>; async fn find_by_id(&self, id: Uuid) -> CoreResult>; - async fn list_by_user(&self, user_id: Uuid) -> CoreResult>; + async fn list_by_user(&self, user_id: Uuid, options: &ListMediaOptions) -> CoreResult>; async fn update_metadata( &self, id: Uuid, diff --git a/libertas_core/src/schema.rs b/libertas_core/src/schema.rs index 63561d4..fd613ce 100644 --- a/libertas_core/src/schema.rs +++ b/libertas_core/src/schema.rs @@ -52,3 +52,29 @@ pub struct UserResponse { pub username: String, pub email: String, } + +#[derive(Debug, Clone)] +pub enum SortOrder { + Asc, + Desc, +} + +#[derive(Debug, Clone)] +pub struct SortParams { + pub sort_by: String, + pub sort_order: SortOrder, +} + +#[derive(Debug, Clone, Default)] +pub struct FilterParams { + // In the future, you can add fields like: + // pub mime_type: Option, + // pub date_range: Option<(chrono::DateTime, chrono::DateTime)>, +} + +#[derive(Debug, Clone)] +pub struct ListMediaOptions { + pub sort: Option, + pub filter: Option, + // pub pagination: Option, +} \ No newline at end of file diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index f9c2b15..c3b07d0 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -5,8 +5,7 @@ use crate::{ error::CoreResult, models::{Album, Media, User}, schema::{ - AddMediaToAlbumData, CreateAlbumData, CreateUserData, LoginUserData, ShareAlbumData, - UpdateAlbumData, UploadMediaData, + AddMediaToAlbumData, CreateAlbumData, CreateUserData, ListMediaOptions, LoginUserData, ShareAlbumData, UpdateAlbumData, UploadMediaData }, }; @@ -14,7 +13,7 @@ use crate::{ pub trait MediaService: Send + Sync { async fn upload_media(&self, data: UploadMediaData<'_>) -> CoreResult; async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult; - async fn list_user_media(&self, user_id: Uuid) -> CoreResult>; + async fn list_user_media(&self, user_id: Uuid, options: ListMediaOptions) -> CoreResult>; async fn get_media_filepath(&self, id: Uuid, user_id: Uuid) -> CoreResult; async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()>; } diff --git a/libertas_infra/src/factory.rs b/libertas_infra/src/factory.rs index a45d007..3a4181e 100644 --- a/libertas_infra/src/factory.rs +++ b/libertas_infra/src/factory.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use libertas_core::{ - config::{DatabaseConfig, DatabaseType}, + config::{Config, DatabaseConfig, DatabaseType}, error::{CoreError, CoreResult}, repositories::UserRepository, }; @@ -47,12 +47,12 @@ pub async fn build_user_repository( } pub async fn build_media_repository( - _db_config: &DatabaseConfig, + config: &Config, pool: DatabasePool, ) -> CoreResult> { match pool { DatabasePool::Postgres(pg_pool) => Ok(Arc::new( - crate::repositories::media_repository::PostgresMediaRepository::new(pg_pool), + crate::repositories::media_repository::PostgresMediaRepository::new(pg_pool, config), )), DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database( "Sqlite media repository not implemented".to_string(), diff --git a/libertas_infra/src/lib.rs b/libertas_infra/src/lib.rs index 1be526d..19f8cf6 100644 --- a/libertas_infra/src/lib.rs +++ b/libertas_infra/src/lib.rs @@ -1,4 +1,5 @@ pub mod factory; pub mod repositories; pub mod db_models; -pub mod mappers; \ No newline at end of file +pub mod mappers; +pub mod query_builder; \ No newline at end of file diff --git a/libertas_infra/src/query_builder.rs b/libertas_infra/src/query_builder.rs new file mode 100644 index 0000000..b9ca840 --- /dev/null +++ b/libertas_infra/src/query_builder.rs @@ -0,0 +1,80 @@ +use libertas_core::{error::{CoreError, CoreResult}, schema::{ListMediaOptions, SortOrder}}; +use sqlx::QueryBuilder as SqlxQueryBuilder; + +pub trait QueryBuilder { + fn apply_options_to_query<'a>( + &self, + query: SqlxQueryBuilder<'a, sqlx::Postgres>, + options: &'a T, + ) -> CoreResult>; +} + +pub struct MediaQueryBuilder { + allowed_sort_columns: Vec, +} + +impl MediaQueryBuilder { + pub fn new(allowed_sort_columns: Vec) -> Self { + Self { + allowed_sort_columns, + } + } + + fn validate_sort_column<'a>(&self, column: &'a str) -> CoreResult<&'a str> { + if self.allowed_sort_columns.contains(&column.to_string()) { + Ok(column) + } else { + Err(CoreError::Validation(format!( + "Sorting by '{}' is not supported", + column + ))) + } + } +} + +impl QueryBuilder for MediaQueryBuilder { + fn apply_options_to_query<'a>( + &self, + mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, + options: &'a ListMediaOptions, + ) -> CoreResult> { + if let Some(filter) = &options.filter { + // In the future, you would add logic here: + // if let Some(mime) = &filter.mime_type { + // query.push(" AND mime_type = "); + // query.push_bind(mime); + // } + } + + if let Some(sort) = &options.sort { + let column = self.validate_sort_column(&sort.sort_by)?; + + let direction = match sort.sort_order { + SortOrder::Asc => "ASC", + SortOrder::Desc => "DESC", + }; + + let nulls_order = if direction == "ASC" { + "NULLS LAST" + } else { + "NULLS FIRST" + }; + + let order_by_clause = format!("ORDER BY {} {} {}", column, direction, nulls_order); + query.push(order_by_clause); + + } else { + query.push("ORDER BY date_taken DESC NULLS FIRST"); + } + + // --- 3. Apply Pagination (Future-Proofing Stub) --- + // if let Some(pagination) = &options.pagination { + // query.push(" LIMIT "); + // query.push_bind(pagination.limit); + // query.push(" OFFSET "); + // query.push_bind(pagination.offset); + // } + + Ok(query) + } +} \ No newline at end of file diff --git a/libertas_infra/src/repositories/media_repository.rs b/libertas_infra/src/repositories/media_repository.rs index 01dbb2b..653f1a1 100644 --- a/libertas_infra/src/repositories/media_repository.rs +++ b/libertas_infra/src/repositories/media_repository.rs @@ -1,22 +1,28 @@ +use std::sync::Arc; + use async_trait::async_trait; use libertas_core::{ - error::{CoreError, CoreResult}, - models::Media, - repositories::MediaRepository, + config::Config, error::{CoreError, CoreResult}, models::Media, repositories::MediaRepository, schema::ListMediaOptions }; use sqlx::PgPool; use uuid::Uuid; -use crate::db_models::PostgresMedia; +use crate::{db_models::PostgresMedia, query_builder::{MediaQueryBuilder, QueryBuilder}}; #[derive(Clone)] pub struct PostgresMediaRepository { pool: PgPool, + query_builder: Arc, } impl PostgresMediaRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } + pub fn new(pool: PgPool, config: &Config) -> Self { + let allowed_columns = config + .allowed_sort_columns + .clone() + .unwrap_or_else(|| vec!["created_at".to_string()]); + + Self { pool, query_builder: Arc::new(MediaQueryBuilder::new(allowed_columns)) } } } @@ -81,22 +87,30 @@ impl MediaRepository for PostgresMediaRepository { Ok(pg_media.map(|m| m.into())) } - async fn list_by_user(&self, user_id: Uuid) -> CoreResult> { - let pg_media = sqlx::query_as!( - PostgresMedia, + async fn list_by_user(&self, user_id: Uuid, options: &ListMediaOptions) -> CoreResult> { + let mut query = sqlx::QueryBuilder::new( r#" SELECT id, owner_id, storage_path, original_filename, mime_type, hash, created_at, extracted_location, width, height, date_taken FROM media - WHERE owner_id = $1 + WHERE owner_id = "#, - user_id - ) + ); + + query.push_bind(user_id); + + query = self + .query_builder + .apply_options_to_query(query, options)?; + + let pg_media = query + .build_query_as::() .fetch_all(&self.pool) .await .map_err(|e| CoreError::Database(e.to_string()))?; - - Ok(pg_media.into_iter().map(|m| m.into()).collect()) + + let media_list = pg_media.into_iter().map(|m| m.into()).collect(); + Ok(media_list) } async fn update_metadata( diff --git a/libertas_worker/src/config.rs b/libertas_worker/src/config.rs index 0236691..8aab546 100644 --- a/libertas_worker/src/config.rs +++ b/libertas_worker/src/config.rs @@ -15,5 +15,10 @@ pub fn load_config() -> CoreResult { broker_url: "nats://localhost:4222".to_string(), max_upload_size_mb: Some(100), default_storage_quota_gb: Some(10), + allowed_sort_columns: Some(vec![ + "date_taken".to_string(), + "created_at".to_string(), + "original_filename".to_string(), + ]), }) } diff --git a/libertas_worker/src/main.rs b/libertas_worker/src/main.rs index 1b3f2bf..4844970 100644 --- a/libertas_worker/src/main.rs +++ b/libertas_worker/src/main.rs @@ -34,7 +34,7 @@ async fn main() -> anyhow::Result<()> { let db_pool = build_database_pool(&config.database).await?; println!("Worker connected to database."); - let media_repo = build_media_repository(&config.database, db_pool.clone()).await?; + let media_repo = build_media_repository(&config, db_pool.clone()).await?; let album_repo = build_album_repository(&config.database, db_pool.clone()).await?; let user_repo = build_user_repository(&config.database, db_pool.clone()).await?;