diff --git a/src/application/dtos/i18n_dto.rs b/src/application/dtos/i18n_dto.rs index 4830a45b..454f9a54 100644 --- a/src/application/dtos/i18n_dto.rs +++ b/src/application/dtos/i18n_dto.rs @@ -1,8 +1,9 @@ use crate::domain::services::i18n_service::Locale; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; /// DTO for locale information -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct LocaleDto { /// Locale code (e.g., "en", "es") pub code: String, @@ -29,7 +30,7 @@ impl From for LocaleDto { } /// DTO for translation request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct TranslationRequestDto { /// The translation key pub key: String, @@ -39,7 +40,7 @@ pub struct TranslationRequestDto { } /// DTO for translation response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct TranslationResponseDto { /// The translation key pub key: String, @@ -52,7 +53,7 @@ pub struct TranslationResponseDto { } /// DTO for translation error -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct TranslationErrorDto { /// The translation key that was not found pub key: String, diff --git a/src/application/dtos/playlist_dto.rs b/src/application/dtos/playlist_dto.rs index 5c738532..1986831f 100644 --- a/src/application/dtos/playlist_dto.rs +++ b/src/application/dtos/playlist_dto.rs @@ -1,8 +1,9 @@ use crate::domain::entities::playlist::{AudioFileMetadata, Playlist, PlaylistItem}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct PlaylistDto { pub id: String, pub name: String, @@ -58,7 +59,7 @@ impl PlaylistDto { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct PlaylistItemDto { pub id: String, pub playlist_id: String, @@ -112,14 +113,14 @@ impl From for PlaylistItemDto { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreatePlaylistDto { pub name: String, pub description: Option, pub is_public: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdatePlaylistDto { pub name: Option, pub description: Option, @@ -127,17 +128,17 @@ pub struct UpdatePlaylistDto { pub cover_file_id: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AddTracksDto { pub file_ids: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ReorderTracksDto { pub item_ids: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SharePlaylistDto { pub user_id: String, pub can_write: Option, diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 802ff064..37649b94 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; // ============================================================================ // OIDC Settings DTOs (Admin Panel) @@ -24,7 +25,7 @@ pub struct OidcSettingsDto { } /// Request body for saving OIDC settings from the admin panel -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SaveOidcSettingsDto { pub enabled: bool, pub issuer_url: String, @@ -62,26 +63,26 @@ pub struct OidcTestResultDto { // ============================================================================ /// Request body for updating a user's role -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateUserRoleDto { pub role: String, } /// Request body for updating a user's active status -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateUserActiveDto { pub active: bool, } /// Request body for updating a user's storage quota -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateUserQuotaDto { /// Quota in bytes. Use 0 for unlimited. pub quota_bytes: i64, } /// Request body for admin-created users -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct AdminCreateUserDto { pub username: String, pub password: String, @@ -96,7 +97,7 @@ pub struct AdminCreateUserDto { } /// Request body for admin password reset -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AdminResetPasswordDto { pub new_password: String, } @@ -156,7 +157,7 @@ pub struct StorageSettingsDto { } /// Request body for saving storage settings from the admin panel -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SaveStorageSettingsDto { pub backend: String, pub s3_endpoint_url: Option, @@ -210,14 +211,14 @@ pub struct MigrationStateDto { } /// Request body for `POST /api/admin/storage/migration/start`. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct StartMigrationDto { /// How many blobs to copy in parallel (default: 4). pub concurrency: Option, } /// Request body (empty) for `POST /api/admin/storage/migration/verify`. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct VerifyMigrationDto { /// Number of random blobs to sample-check (default: 100). pub sample_size: Option, diff --git a/src/application/ports/chunked_upload_ports.rs b/src/application/ports/chunked_upload_ports.rs index bbd40a97..93621a4d 100644 --- a/src/application/ports/chunked_upload_ports.rs +++ b/src/application/ports/chunked_upload_ports.rs @@ -8,6 +8,7 @@ use crate::common::errors::DomainError; use bytes::Bytes; use serde::Serialize; use std::path::PathBuf; +use utoipa::ToSchema; use uuid::Uuid; /// Default chunk size (5 MB) — optimised for parallel transfers. @@ -17,7 +18,7 @@ pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024; pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024; /// Response returned when a new upload session is created. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct CreateUploadResponseDto { pub upload_id: String, pub chunk_size: usize, @@ -26,7 +27,7 @@ pub struct CreateUploadResponseDto { } /// Response returned after a single chunk is uploaded. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct ChunkUploadResponseDto { pub chunk_index: usize, pub bytes_received: u64, @@ -35,7 +36,7 @@ pub struct ChunkUploadResponseDto { } /// Response for querying upload session status. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct UploadStatusResponseDto { pub upload_id: String, pub filename: String, diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index f1ff4e6f..d960b04e 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -100,7 +100,17 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, Str } /// GET /api/admin/settings/oidc — get OIDC settings for the admin panel -async fn get_oidc_settings( +#[utoipa::path( + get, + path = "/api/admin/settings/oidc", + responses( + (status = 200, description = "OIDC settings"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn get_oidc_settings( State(state): State>, headers: HeaderMap, ) -> Result { @@ -120,7 +130,17 @@ async fn get_oidc_settings( } /// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload -async fn save_oidc_settings( +#[utoipa::path( + put, + path = "/api/admin/settings/oidc", + responses( + (status = 200, description = "OIDC settings saved"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn save_oidc_settings( State(state): State>, headers: HeaderMap, Json(dto): Json, @@ -170,7 +190,17 @@ async fn test_oidc_connection( // ───────────────────────────────────────────────────── /// GET /api/admin/settings/storage — get storage backend settings -async fn get_storage_settings( +#[utoipa::path( + get, + path = "/api/admin/settings/storage", + responses( + (status = 200, description = "Storage settings"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn get_storage_settings( State(state): State>, headers: HeaderMap, ) -> Result { @@ -190,7 +220,17 @@ async fn get_storage_settings( } /// PUT /api/admin/settings/storage — save storage backend settings -async fn save_storage_settings( +#[utoipa::path( + put, + path = "/api/admin/settings/storage", + responses( + (status = 200, description = "Storage settings saved"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn save_storage_settings( State(state): State>, headers: HeaderMap, Json(dto): Json, @@ -240,7 +280,17 @@ async fn test_storage_connection( // ───────────────────────────────────────────────────── /// GET /api/admin/storage/migration — current migration progress -async fn get_migration_status( +#[utoipa::path( + get, + path = "/api/admin/storage/migration", + responses( + (status = 200, description = "Current migration status"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn get_migration_status( State(state): State>, headers: HeaderMap, ) -> Result { @@ -250,7 +300,18 @@ async fn get_migration_status( } /// POST /api/admin/storage/migration/start — begin background migration -async fn start_migration( +#[utoipa::path( + post, + path = "/api/admin/storage/migration/start", + responses( + (status = 200, description = "Migration started"), + (status = 400, description = "Migration already running"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn start_migration( State(state): State>, headers: HeaderMap, Json(dto): Json, @@ -316,7 +377,18 @@ async fn start_migration( } /// POST /api/admin/storage/migration/pause — pause running migration -async fn pause_migration( +#[utoipa::path( + post, + path = "/api/admin/storage/migration/pause", + responses( + (status = 200, description = "Migration paused"), + (status = 400, description = "No running migration"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn pause_migration( State(state): State>, headers: HeaderMap, ) -> Result { @@ -335,7 +407,18 @@ async fn pause_migration( } /// POST /api/admin/storage/migration/resume — resume paused migration -async fn resume_migration( +#[utoipa::path( + post, + path = "/api/admin/storage/migration/resume", + responses( + (status = 200, description = "Migration resumed"), + (status = 400, description = "No paused migration"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn resume_migration( State(state): State>, headers: HeaderMap, ) -> Result { @@ -355,7 +438,18 @@ async fn resume_migration( } /// POST /api/admin/storage/migration/complete — finalize migration -async fn complete_migration( +#[utoipa::path( + post, + path = "/api/admin/storage/migration/complete", + responses( + (status = 200, description = "Migration finalized"), + (status = 400, description = "Migration not completed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn complete_migration( State(state): State>, headers: HeaderMap, ) -> Result { @@ -383,7 +477,18 @@ async fn complete_migration( } /// POST /api/admin/storage/migration/verify — run integrity check -async fn verify_migration( +#[utoipa::path( + post, + path = "/api/admin/storage/migration/verify", + responses( + (status = 200, description = "Verification result"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 500, description = "Verification failed") + ), + tag = "admin" +)] +pub async fn verify_migration( State(state): State>, headers: HeaderMap, Json(dto): Json, @@ -450,7 +555,17 @@ fn migration_state_to_dto( } /// POST /api/admin/settings/storage/generate-key — generate a random AES-256 key. -async fn generate_encryption_key( +#[utoipa::path( + post, + path = "/api/admin/settings/storage/generate-key", + responses( + (status = 200, description = "Generated AES-256 key"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn generate_encryption_key( State(state): State>, headers: HeaderMap, ) -> Result { @@ -496,7 +611,17 @@ fn build_backend_from_config( } /// GET /api/admin/settings/general — system overview (backward compat) -async fn get_general_settings( +#[utoipa::path( + get, + path = "/api/admin/settings/general", + responses( + (status = 200, description = "General system settings"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn get_general_settings( State(state): State>, headers: HeaderMap, ) -> Result { @@ -527,7 +652,17 @@ async fn get_general_settings( // ============================================================================ /// GET /api/admin/dashboard — full dashboard statistics -async fn get_dashboard_stats( +#[utoipa::path( + get, + path = "/api/admin/dashboard", + responses( + (status = 200, description = "Dashboard statistics"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn get_dashboard_stats( State(state): State>, headers: HeaderMap, ) -> Result { @@ -603,7 +738,21 @@ async fn get_dashboard_stats( // ============================================================================ /// GET /api/admin/users?limit=50&offset=0 — list all users -async fn list_users( +#[utoipa::path( + get, + path = "/api/admin/users", + params( + ("limit" = Option, Query, description = "Max users to return (default 100, max 500)"), + ("offset" = Option, Query, description = "Pagination offset") + ), + responses( + (status = 200, description = "List of users"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn list_users( State(state): State>, headers: HeaderMap, Query(query): Query, @@ -639,7 +788,19 @@ async fn list_users( } /// GET /api/admin/users/:id — get single user -async fn get_user( +#[utoipa::path( + get, + path = "/api/admin/users/{id}", + params(("id" = String, Path, description = "User UUID")), + responses( + (status = 200, description = "User details"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 404, description = "User not found") + ), + tag = "admin" +)] +pub async fn get_user( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -663,7 +824,19 @@ async fn get_user( } /// DELETE /api/admin/users/:id — delete a user -async fn delete_user( +#[utoipa::path( + delete, + path = "/api/admin/users/{id}", + params(("id" = String, Path, description = "User UUID")), + responses( + (status = 200, description = "User deleted"), + (status = 400, description = "Cannot delete own account"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn delete_user( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -700,7 +873,19 @@ async fn delete_user( } /// PUT /api/admin/users/:id/role — change user role -async fn update_user_role( +#[utoipa::path( + put, + path = "/api/admin/users/{id}/role", + params(("id" = String, Path, description = "User UUID")), + responses( + (status = 200, description = "Role updated"), + (status = 400, description = "Cannot change own role"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn update_user_role( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -738,7 +923,19 @@ async fn update_user_role( } /// PUT /api/admin/users/:id/active — activate/deactivate user -async fn update_user_active( +#[utoipa::path( + put, + path = "/api/admin/users/{id}/active", + params(("id" = String, Path, description = "User UUID")), + responses( + (status = 200, description = "User active status updated"), + (status = 400, description = "Cannot deactivate own account"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn update_user_active( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -781,7 +978,18 @@ async fn update_user_active( } /// PUT /api/admin/users/:id/quota — update user storage quota -async fn update_user_quota( +#[utoipa::path( + put, + path = "/api/admin/users/{id}/quota", + params(("id" = String, Path, description = "User UUID")), + responses( + (status = 200, description = "Quota updated"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn update_user_quota( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -815,7 +1023,18 @@ async fn update_user_quota( // ============================================================================ /// POST /api/admin/users — create a new user (admin only) -async fn create_user( +#[utoipa::path( + post, + path = "/api/admin/users", + responses( + (status = 201, description = "User created"), + (status = 400, description = "Invalid user data"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn create_user( State(state): State>, headers: HeaderMap, Json(dto): Json, @@ -843,7 +1062,19 @@ async fn create_user( } /// PUT /api/admin/users/:id/password — reset a user's password (admin only) -async fn reset_user_password( +#[utoipa::path( + put, + path = "/api/admin/users/{id}/password", + params(("id" = String, Path, description = "User UUID")), + responses( + (status = 200, description = "Password reset"), + (status = 400, description = "Invalid password"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn reset_user_password( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -882,7 +1113,17 @@ async fn reset_user_password( // ============================================================================ /// GET /api/admin/settings/registration — check if public registration is enabled -async fn get_registration_setting( +#[utoipa::path( + get, + path = "/api/admin/settings/registration", + responses( + (status = 200, description = "Registration setting"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn get_registration_setting( State(state): State>, headers: HeaderMap, ) -> Result { @@ -901,7 +1142,18 @@ async fn get_registration_setting( } /// PUT /api/admin/settings/registration — enable/disable public registration -async fn set_registration_setting( +#[utoipa::path( + put, + path = "/api/admin/settings/registration", + responses( + (status = 200, description = "Registration setting updated"), + (status = 400, description = "Missing field"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + tag = "admin" +)] +pub async fn set_registration_setting( State(state): State>, headers: HeaderMap, Json(body): Json, diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 903b1ff7..8edf7e81 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -5,6 +5,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use utoipa::ToSchema; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; @@ -25,7 +26,7 @@ pub struct BatchHandlerState { } /// DTO for batch file operation requests -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct BatchFileOperationRequest { /// IDs of the files to process pub file_ids: Vec, @@ -35,7 +36,7 @@ pub struct BatchFileOperationRequest { } /// DTO for batch folder operation requests -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct BatchFolderOperationRequest { /// IDs of the folders to process pub folder_ids: Vec, @@ -48,14 +49,14 @@ pub struct BatchFolderOperationRequest { } /// DTO for batch folder creation requests -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct BatchCreateFoldersRequest { /// Details of the folders to create pub folders: Vec, } /// Detail for folder creation -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct CreateFolderDetail { /// Folder name pub name: String, @@ -132,6 +133,17 @@ where } /// Handler for moving multiple files in batch +#[utoipa::path( + post, + path = "/api/batch/files/move", + responses( + (status = 200, description = "All files moved"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn move_files_batch( State(state): State, auth_user: AuthUser, @@ -188,6 +200,17 @@ pub async fn move_files_batch( } /// Handler for copying multiple files in batch +#[utoipa::path( + post, + path = "/api/batch/files/copy", + responses( + (status = 200, description = "All files copied"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn copy_files_batch( State(state): State, auth_user: AuthUser, @@ -244,6 +267,17 @@ pub async fn copy_files_batch( } /// Handler for deleting multiple files in batch +#[utoipa::path( + post, + path = "/api/batch/files/delete", + responses( + (status = 200, description = "All files deleted"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn delete_files_batch( State(state): State, auth_user: AuthUser, @@ -308,6 +342,17 @@ pub async fn delete_files_batch( } /// Handler for deleting multiple folders in batch +#[utoipa::path( + post, + path = "/api/batch/folders/delete", + responses( + (status = 200, description = "All folders deleted"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn delete_folders_batch( State(state): State, auth_user: AuthUser, @@ -372,6 +417,17 @@ pub async fn delete_folders_batch( } /// Handler for creating multiple folders in batch +#[utoipa::path( + post, + path = "/api/batch/folders/create", + responses( + (status = 201, description = "All folders created"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn create_folders_batch( State(state): State, auth_user: AuthUser, @@ -435,6 +491,17 @@ pub async fn create_folders_batch( } /// Handler for getting multiple files in batch +#[utoipa::path( + post, + path = "/api/batch/files/get", + responses( + (status = 200, description = "Batch file details"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn get_files_batch( State(state): State, auth_user: AuthUser, @@ -491,6 +558,17 @@ pub async fn get_files_batch( } /// Handler for getting multiple folders in batch +#[utoipa::path( + post, + path = "/api/batch/folders/get", + responses( + (status = 200, description = "Batch folder details"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn get_folders_batch( State(state): State, auth_user: AuthUser, @@ -547,7 +625,7 @@ pub async fn get_folders_batch( } /// DTO for batch trash operation requests -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct BatchTrashRequest { /// IDs of the files to move to trash #[serde(default)] @@ -558,7 +636,7 @@ pub struct BatchTrashRequest { } /// DTO for batch download requests -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct BatchDownloadRequest { /// IDs of the files to include in the ZIP #[serde(default)] @@ -569,6 +647,17 @@ pub struct BatchDownloadRequest { } /// Handler for moving multiple files and folders to trash in batch +#[utoipa::path( + post, + path = "/api/batch/trash", + responses( + (status = 200, description = "All items trashed"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn trash_batch( State(state): State, auth_user: AuthUser, @@ -681,6 +770,17 @@ pub async fn trash_batch( } /// Handler for moving multiple folders in batch +#[utoipa::path( + post, + path = "/api/batch/folders/move", + responses( + (status = 200, description = "All folders moved"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] pub async fn move_folders_batch( State(state): State, auth_user: AuthUser, @@ -736,6 +836,17 @@ pub async fn move_folders_batch( /// /// The ZIP is written to a temporary file and streamed to the client, /// so RAM usage is O(buffer_size) regardless of archive size. +#[utoipa::path( + post, + path = "/api/batch/download", + responses( + (status = 200, description = "ZIP archive stream"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "ZIP creation failed") + ), + tag = "batch" +)] pub async fn download_batch( State(state): State, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 8fce8b93..61c14209 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -9,13 +9,14 @@ use axum::{ Json, - extract::{Path, Query, State}, + extract::{Path, Query, Request, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use bytes::Bytes; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use utoipa::ToSchema; use crate::application::ports::chunked_upload_ports::ChunkedUploadPort; use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE; @@ -26,7 +27,7 @@ use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; /// Request body for creating an upload session -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct CreateUploadRequest { pub filename: String, pub folder_id: Option, @@ -43,7 +44,7 @@ pub struct ChunkUploadParams { } /// Final response after completing upload -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct CompleteUploadResponse { pub file_id: String, pub filename: String, @@ -52,9 +53,21 @@ pub struct CompleteUploadResponse { } /// Chunked Upload Handler +/// +/// The handler struct exists as a named grouping. All route functions are free +/// functions at module scope — see the section below the impl block for the reason. pub struct ChunkedUploadHandler; impl ChunkedUploadHandler { + // ── Why no #[utoipa::path] here? ───────────────────────────────────────────── + // utoipa 5.4.0's proc macro generates helper structs / impls inside its expansion. + // Rust allows struct definitions at module scope but forbids them inside impl blocks, + // so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP + // verb or annotation content. The same macro works fine on FileHandler / FolderHandler + // (root cause in utoipa unknown — likely a 5.4.x bug). All five route handlers are + // therefore declared as free functions below, which delegate to these `*_impl` methods. + // TODO: try removing free-function indirection after a utoipa upgrade. + /// POST /api/uploads - Create a new upload session /// /// Request body: @@ -77,7 +90,7 @@ impl ChunkedUploadHandler { /// "expires_at": 86400 /// } /// ``` - pub async fn create_upload( + pub(super) async fn create_upload_impl( State(state): State>, auth_user: AuthUser, Json(request): Json, @@ -171,7 +184,7 @@ impl ChunkedUploadHandler { /// - checksum: Optional MD5 checksum for verification /// /// Body: Raw bytes of the chunk - pub async fn upload_chunk( + pub(super) async fn upload_chunk_impl( State(state): State>, auth_user: AuthUser, Path(upload_id): Path, @@ -220,7 +233,7 @@ impl ChunkedUploadHandler { /// HEAD /api/uploads/:upload_id - Get upload status /// /// Returns upload progress and pending chunks - pub async fn get_upload_status( + pub(super) async fn get_upload_status_impl( State(state): State>, auth_user: AuthUser, Path(upload_id): Path, @@ -251,7 +264,7 @@ impl ChunkedUploadHandler { /// POST /api/uploads/:upload_id/complete - Finalize upload /// /// Assembles all chunks into the final file and creates the file record - pub async fn complete_upload( + pub(super) async fn complete_upload_impl( State(state): State>, auth_user: AuthUser, Path(upload_id): Path, @@ -324,7 +337,7 @@ impl ChunkedUploadHandler { /// DELETE /api/uploads/:upload_id - Cancel upload /// /// Cancels an in-progress upload and cleans up temp files - pub async fn cancel_upload( + pub(super) async fn cancel_upload_impl( State(state): State>, auth_user: AuthUser, Path(upload_id): Path, @@ -342,3 +355,134 @@ impl ChunkedUploadHandler { } } } + +// ── Route handlers (free functions) ────────────────────────────────────────── +// +// All five route functions live here rather than as methods on ChunkedUploadHandler +// because utoipa 5.4.0's #[utoipa::path] macro generates helper structs inside its +// expansion. Rust allows struct definitions at module scope but forbids them inside +// impl blocks — so every #[utoipa::path] annotation on a ChunkedUploadHandler method +// fails to compile regardless of HTTP verb or annotation content. +// +// FileHandler and FolderHandler are not affected (root cause in utoipa unknown, likely +// a 5.4.x regression). All logic lives in the ChunkedUploadHandler::*_impl methods +// above; these thin wrappers exist solely to carry the OpenAPI annotation at a scope +// where utoipa can generate its helper types. +// +// routes.rs calls these free functions directly. +// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue. + +#[utoipa::path( + post, + path = "/api/uploads", + request_body(content = CreateUploadRequest, content_type = "application/json", description = "Upload session parameters"), + responses( + (status = 201, description = "Upload session created", body = crate::application::ports::chunked_upload_ports::CreateUploadResponseDto), + (status = 400, description = "Invalid request (empty filename, zero size, chunk too small)"), + (status = 507, description = "Storage quota exceeded"), + ), + tag = "uploads", + security(("bearerAuth" = [])) +)] +pub async fn create_upload( + state: State>, + auth_user: AuthUser, + request: Json, +) -> impl IntoResponse { + ChunkedUploadHandler::create_upload_impl(state, auth_user, request).await +} + +#[utoipa::path( + patch, + path = "/api/uploads/{upload_id}", + params( + ("upload_id" = String, Path, description = "Upload session ID"), + ("chunk_index" = usize, Query, description = "Zero-based chunk index"), + ("checksum" = Option, Query, description = "Optional MD5 checksum for integrity verification"), + ), + request_body(content_type = "application/octet-stream", description = "Raw chunk bytes"), + responses( + (status = 200, description = "Chunk received", body = crate::application::ports::chunked_upload_ports::ChunkUploadResponseDto), + (status = 400, description = "Invalid chunk or checksum mismatch"), + (status = 404, description = "Upload session not found"), + ), + tag = "uploads", + security(("bearerAuth" = [])) +)] +pub async fn upload_chunk( + state: State>, + auth_user: AuthUser, + path: Path, + query: Query, + headers: HeaderMap, + request: Request, +) -> impl IntoResponse { + let body = axum::body::to_bytes(request.into_body(), usize::MAX) + .await + .unwrap_or_default(); + ChunkedUploadHandler::upload_chunk_impl(state, auth_user, path, query, headers, body).await +} + +#[utoipa::path( + head, + path = "/api/uploads/{upload_id}", + params( + ("upload_id" = String, Path, description = "Upload session ID"), + ), + responses( + (status = 200, description = "Upload status in response headers and body", body = crate::application::ports::chunked_upload_ports::UploadStatusResponseDto), + (status = 404, description = "Upload session not found"), + ), + tag = "uploads", + security(("bearerAuth" = [])) +)] +pub async fn get_upload_status( + state: State>, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + ChunkedUploadHandler::get_upload_status_impl(state, auth_user, path).await +} + +#[utoipa::path( + post, + path = "/api/uploads/{upload_id}/complete", + params( + ("upload_id" = String, Path, description = "Upload session ID"), + ), + responses( + (status = 201, description = "File assembled and created", body = CompleteUploadResponse), + (status = 404, description = "Upload session not found"), + (status = 500, description = "Assembly or file creation failed"), + ), + tag = "uploads", + security(("bearerAuth" = [])) +)] +pub async fn complete_upload( + state: State>, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + ChunkedUploadHandler::complete_upload_impl(state, auth_user, path).await +} + +#[utoipa::path( + delete, + path = "/api/uploads/{upload_id}", + params( + ("upload_id" = String, Path, description = "Upload session ID"), + ), + responses( + (status = 204, description = "Upload cancelled and temp files cleaned up"), + (status = 500, description = "Cancel failed"), + ), + tag = "uploads", + security(("bearerAuth" = [])) +)] +pub async fn cancel_upload( + state: State>, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + ChunkedUploadHandler::cancel_upload_impl(state, auth_user, path).await +} diff --git a/src/interfaces/api/handlers/dedup_handler.rs b/src/interfaces/api/handlers/dedup_handler.rs index 49a5c355..97dbfa6c 100644 --- a/src/interfaces/api/handlers/dedup_handler.rs +++ b/src/interfaces/api/handlers/dedup_handler.rs @@ -6,6 +6,7 @@ use axum::{ }; use serde::Serialize; use tokio::io::AsyncWriteExt; +use utoipa::ToSchema; use crate::application::ports::dedup_ports::DedupResultDto; use crate::common::di::AppState; @@ -16,7 +17,7 @@ use std::sync::Arc; type GlobalState = Arc; /// Response for hash check endpoint -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct HashCheckResponse { /// Whether a blob with this hash already exists pub exists: bool, @@ -31,7 +32,7 @@ pub struct HashCheckResponse { } /// Response for upload with dedup endpoint -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct DedupUploadResponse { /// Whether this was a new file or an existing one pub is_new: bool, @@ -46,7 +47,7 @@ pub struct DedupUploadResponse { } /// Response for dedup stats endpoint -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct StatsResponse { /// Total number of unique blobs stored pub unique_blobs: u64, @@ -64,8 +65,10 @@ pub struct StatsResponse { pub savings_percentage: f64, } -/// Handler for deduplication-related endpoints +/// Handler for deduplication-related endpoints. /// +/// All route functions are free functions at module scope — see the section +/// below the impl block for the reason (utoipa 5.4.0 limitation). /// Provides endpoints for: /// - Checking if content already exists (by hash) /// - Uploading files with automatic deduplication @@ -73,13 +76,19 @@ pub struct StatsResponse { pub struct DedupHandler; impl DedupHandler { + // ── Why no #[utoipa::path] here? ───────────────────────────────────────────── + // Same utoipa 5.4.0 limitation as ChunkedUploadHandler: the macro generates + // helper structs inside its expansion and Rust forbids structs inside impl blocks. + // All route handlers are free functions below; they delegate to these *_impl methods. + // TODO: collapse back into the impl block after a utoipa upgrade. + /// Check if the authenticated user already has a file with the given hash. /// /// User-scoped: only reveals whether **this user** owns a file that /// references the blob — never exposes global existence or ref_count. /// /// GET /api/dedup/check/{hash} - pub async fn check_hash( + pub(super) async fn check_hash_impl( State(state): State, auth_user: AuthUser, Path(hash): Path, @@ -142,7 +151,7 @@ impl DedupHandler { /// the pre-computed hash so the file is never re-read for hashing. /// /// POST /api/dedup/upload - pub async fn upload_with_dedup( + pub(super) async fn upload_with_dedup_impl( State(state): State, _auth_user: AuthUser, mut multipart: Multipart, @@ -300,7 +309,7 @@ impl DedupHandler { /// - Total references /// - Bytes saved /// - Deduplication ratio - pub async fn get_stats( + pub(super) async fn get_stats_impl( State(state): State, auth_user: AuthUser, ) -> impl IntoResponse { @@ -349,7 +358,7 @@ impl DedupHandler { /// Returns the raw content of a blob **only if** the authenticated user /// owns at least one file that references it. Returns 404 otherwise /// (does not reveal whether the blob exists globally). - pub async fn get_blob( + pub(super) async fn get_blob_impl( State(state): State, auth_user: AuthUser, Path(hash): Path, @@ -423,7 +432,7 @@ impl DedupHandler { /// /// Verifies integrity and returns current statistics. /// Useful for health checks and auditing. - pub async fn recalculate_stats( + pub(super) async fn recalculate_stats_impl( State(state): State, auth_user: AuthUser, ) -> impl IntoResponse { @@ -485,6 +494,112 @@ impl DedupHandler { } } +// ── Route handlers (free functions) ────────────────────────────────────────── +// +// Same utoipa 5.4.0 limitation as ChunkedUploadHandler: #[utoipa::path] cannot +// be applied to methods on DedupHandler because the macro generates helper structs +// that Rust forbids inside impl blocks. All logic lives in the DedupHandler::*_impl +// methods above; these thin wrappers carry the OpenAPI annotation at module scope. +// +// routes.rs calls these free functions directly instead of DedupHandler::method. +// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue. + +#[utoipa::path( + get, + path = "/api/dedup/check/{hash}", + params( + ("hash" = String, Path, description = "SHA-256 hash (64 hex characters)"), + ), + responses( + (status = 200, description = "Hash check result (user-scoped)", body = HashCheckResponse), + (status = 400, description = "Invalid hash format"), + ), + tag = "dedup", + security(("bearerAuth" = [])) +)] +pub async fn check_hash( + state: State, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + DedupHandler::check_hash_impl(state, auth_user, path).await +} + +#[utoipa::path( + post, + path = "/api/dedup/upload", + request_body(content_type = "multipart/form-data", description = "Multipart form with a 'file' field"), + responses( + (status = 201, description = "New blob stored", body = DedupUploadResponse), + (status = 200, description = "Blob already existed (dedup hit)", body = DedupUploadResponse), + (status = 400, description = "No file field or empty file"), + (status = 500, description = "Upload failed"), + ), + tag = "dedup", + security(("bearerAuth" = [])) +)] +pub async fn upload_with_dedup( + state: State, + auth_user: AuthUser, + multipart: Multipart, +) -> impl IntoResponse { + DedupHandler::upload_with_dedup_impl(state, auth_user, multipart).await +} + +#[utoipa::path( + get, + path = "/api/dedup/stats", + responses( + (status = 200, description = "Deduplication statistics", body = StatsResponse), + (status = 403, description = "Admin role required"), + ), + tag = "dedup", + security(("bearerAuth" = [])) +)] +pub async fn get_stats(state: State, auth_user: AuthUser) -> impl IntoResponse { + DedupHandler::get_stats_impl(state, auth_user).await +} + +#[utoipa::path( + get, + path = "/api/dedup/blob/{hash}", + params( + ("hash" = String, Path, description = "SHA-256 hash of the blob (64 hex characters)"), + ), + responses( + (status = 200, description = "Raw blob content (user-scoped)"), + (status = 400, description = "Invalid hash format"), + (status = 404, description = "Blob not found or not owned by this user"), + ), + tag = "dedup", + security(("bearerAuth" = [])) +)] +pub async fn get_blob( + state: State, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + DedupHandler::get_blob_impl(state, auth_user, path).await +} + +#[utoipa::path( + post, + path = "/api/dedup/recalculate", + responses( + (status = 200, description = "Statistics after integrity verification", body = StatsResponse), + (status = 403, description = "Admin role required"), + (status = 500, description = "Integrity verification failed"), + ), + tag = "dedup", + security(("bearerAuth" = [])) +)] +pub async fn recalculate_stats( + state: State, + auth_user: AuthUser, +) -> impl IntoResponse { + DedupHandler::recalculate_stats_impl(state, auth_user).await +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 08ca33fc..8ff0c570 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use std::collections::HashMap; use utoipa::ToSchema; +use crate::application::dtos::file_dto::FileDto; use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, @@ -39,6 +40,13 @@ type GlobalState = Arc; pub struct FileHandler; impl FileHandler { + // ── Why no #[utoipa::path] here? ───────────────────────────────────────────── + // utoipa 5.4.0's proc macro generates helper structs / impls inside its expansion. + // Rust allows struct definitions at module scope but forbids them inside impl blocks, + // so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP + // verb or annotation content. All route handlers are free functions below. + // TODO: collapse after utoipa upgrade. + // ═══════════════════════════════════════════════════════════════════════ // UPLOAD // ═══════════════════════════════════════════════════════════════════════ @@ -312,7 +320,7 @@ impl FileHandler { /// The DB path is only taken on a **cache miss for images** where the /// thumbnail hasn't been generated yet (first access after upload if /// background generation hasn't finished). - pub async fn get_thumbnail( + pub(super) async fn get_thumbnail_impl( State(state): State, auth_user: AuthUser, headers: HeaderMap, @@ -462,7 +470,7 @@ impl FileHandler { /// subsequent `GET …/thumbnail/{size}` requests are served instantly. /// /// **Max body: 512 KB** — thumbnails are small. - pub async fn upload_thumbnail( + pub(super) async fn upload_thumbnail_impl( State(state): State, auth_user: AuthUser, Path((id, size)): Path<(String, String)>, @@ -527,7 +535,7 @@ impl FileHandler { /// streaming) is fully handled by `FileRetrievalUseCase::get_file_optimized`. /// This handler only deals with HTTP concerns: ETag, Range, Content-Disposition, /// and optional compression. - pub async fn download_file( + pub(super) async fn download_file_impl( State(state): State, auth_user: AuthUser, Path(id): Path, @@ -694,10 +702,8 @@ impl FileHandler { // LIST // ═══════════════════════════════════════════════════════════════════════ - /// Lists files, extracting `folder_id` from query parameters. - /// - /// Axum-compatible handler wrapper around [`Self::list_files`]. - pub async fn list_files_query( + /// Lists files in a folder, extracting `folder_id` from query parameters. + pub(super) async fn list_files_query_impl( State(state): State, auth_user: AuthUser, headers: HeaderMap, @@ -745,7 +751,7 @@ impl FileHandler { /// Delegates to [`Self::upload_file_inner`] and, on success, spawns /// a background task to generate all thumbnail sizes before serialising /// the `FileDto` once. - pub async fn upload_file_with_thumbnails( + pub(super) async fn upload_file_with_thumbnails_impl( State(state): State, auth_user: AuthUser, multipart: Multipart, @@ -813,7 +819,7 @@ impl FileHandler { /// Returns EXIF/media metadata for a file. /// /// Used by the Photos lightbox and for testing EXIF extraction. - pub async fn get_file_metadata( + pub(super) async fn get_file_metadata_impl( State(state): State, auth_user: AuthUser, Path(file_id): Path, @@ -859,7 +865,7 @@ impl FileHandler { /// /// When auth is available, uses trash-first deletion; otherwise falls back /// to permanent delete so the endpoint works with or without auth. - pub async fn delete_file( + pub(super) async fn delete_file_impl( State(state): State, auth_user: AuthUser, Path(id): Path, @@ -889,7 +895,7 @@ impl FileHandler { // ═══════════════════════════════════════════════════════════════════════ /// Renames a file (ownership-verified) - pub async fn rename_file( + pub(super) async fn rename_file_impl( State(state): State, auth_user: AuthUser, Path(id): Path, @@ -936,8 +942,8 @@ impl FileHandler { } } - /// Moves a file to a different folder (simplified payload, ownership-verified) - pub async fn move_file_simple( + /// Moves a file to a different folder (ownership-verified) + pub(super) async fn move_file_simple_impl( State(state): State, auth_user: AuthUser, Path(id): Path, @@ -1066,3 +1072,207 @@ pub struct MoveFilePayload { /// Target folder ID (None means root) pub folder_id: Option, } + +// ── Route handlers (free functions) ────────────────────────────────────────── +// +// All annotated route functions live here rather than as methods on FileHandler +// because utoipa 5.4.0's #[utoipa::path] macro generates helper structs inside +// its expansion. Rust allows struct definitions at module scope but forbids them +// inside impl blocks — so every #[utoipa::path] annotation on a FileHandler +// method fails to compile regardless of HTTP verb or annotation content. +// +// All logic lives in the FileHandler::*_impl methods above; these thin wrappers +// exist solely to carry the OpenAPI annotation at a scope where utoipa can +// generate its helper types. +// +// routes.rs calls these free functions directly. +// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue. + +#[utoipa::path( + get, + path = "/api/files", + params(("folder_id" = Option, Query, description = "Filter by folder ID")), + responses( + (status = 200, description = "List of files", body = Vec), + (status = 304, description = "Not modified"), + ), + tag = "files" +)] +pub async fn list_files_query( + state: State, + auth_user: AuthUser, + headers: HeaderMap, + query: Query>, +) -> impl IntoResponse { + FileHandler::list_files_query_impl(state, auth_user, headers, query).await +} + +#[utoipa::path( + post, + path = "/api/files/upload", + request_body(content_type = "multipart/form-data", description = "File data + optional folder_id field"), + responses( + (status = 201, description = "File uploaded", body = FileDto), + (status = 400, description = "Invalid request"), + (status = 507, description = "Storage quota exceeded"), + ), + tag = "files" +)] +pub async fn upload_file_with_thumbnails( + state: State, + auth_user: AuthUser, + multipart: Multipart, +) -> impl IntoResponse { + FileHandler::upload_file_with_thumbnails_impl(state, auth_user, multipart).await +} + +#[utoipa::path( + get, + path = "/api/files/{id}", + params( + ("id" = String, Path, description = "File ID"), + ("metadata" = Option, Query, description = "Return metadata JSON instead of file content"), + ("original" = Option, Query, description = "Skip WebP transcoding"), + ("inline" = Option, Query, description = "Content-Disposition: inline"), + ), + responses( + (status = 200, description = "File content"), + (status = 206, description = "Partial content (Range request)"), + (status = 304, description = "Not modified"), + (status = 404, description = "File not found"), + ), + tag = "files" +)] +pub async fn download_file( + state: State, + auth_user: AuthUser, + path: Path, + query: Query>, + headers: HeaderMap, +) -> impl IntoResponse { + FileHandler::download_file_impl(state, auth_user, path, query, headers).await +} + +#[utoipa::path( + get, + path = "/api/files/{id}/thumbnail/{size}", + params( + ("id" = String, Path, description = "File ID"), + ("size" = String, Path, description = "Thumbnail size: icon | preview | large"), + ), + responses( + (status = 200, description = "Thumbnail image (image/jpeg or image/webp)"), + (status = 204, description = "No thumbnail available for this file type"), + (status = 304, description = "Not modified"), + (status = 404, description = "File not found"), + ), + tag = "files" +)] +pub async fn get_thumbnail( + state: State, + auth_user: AuthUser, + headers: HeaderMap, + path: Path<(String, String)>, +) -> impl IntoResponse { + FileHandler::get_thumbnail_impl(state, auth_user, headers, path).await +} + +#[utoipa::path( + put, + path = "/api/files/{id}/thumbnail/{size}", + params( + ("id" = String, Path, description = "File ID"), + ("size" = String, Path, description = "Thumbnail size: icon | preview | large"), + ), + request_body(content_type = "application/octet-stream", description = "Raw image bytes (max 512 KB)"), + responses( + (status = 201, description = "Thumbnail stored"), + (status = 400, description = "Invalid image or size too large"), + (status = 404, description = "File not found"), + ), + tag = "files" +)] +pub async fn upload_thumbnail( + state: State, + auth_user: AuthUser, + path: Path<(String, String)>, + body: Bytes, +) -> impl IntoResponse { + FileHandler::upload_thumbnail_impl(state, auth_user, path, body).await +} + +#[utoipa::path( + get, + path = "/api/files/{id}/metadata", + params(("id" = String, Path, description = "File ID")), + responses( + (status = 200, description = "File metadata (EXIF, dimensions, duration, etc.)"), + (status = 404, description = "File not found"), + ), + tag = "files" +)] +pub async fn get_file_metadata( + state: State, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + FileHandler::get_file_metadata_impl(state, auth_user, path).await +} + +#[utoipa::path( + delete, + path = "/api/files/{id}", + params(("id" = String, Path, description = "File ID")), + responses( + (status = 204, description = "File deleted (moved to trash if enabled)"), + (status = 404, description = "File not found"), + ), + tag = "files" +)] +pub async fn delete_file( + state: State, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + FileHandler::delete_file_impl(state, auth_user, path).await +} + +#[utoipa::path( + put, + path = "/api/files/{id}/rename", + params(("id" = String, Path, description = "File ID")), + request_body(content_type = "application/json", description = r#"{"name": "new-name.txt"}"#), + responses( + (status = 200, description = "Renamed file", body = FileDto), + (status = 404, description = "File not found"), + ), + tag = "files" +)] +pub async fn rename_file( + state: State, + auth_user: AuthUser, + path: Path, + json: Json, +) -> impl IntoResponse { + FileHandler::rename_file_impl(state, auth_user, path, json).await +} + +#[utoipa::path( + put, + path = "/api/files/{id}/move", + params(("id" = String, Path, description = "File ID")), + request_body(content = MoveFilePayload, content_type = "application/json", description = "MoveFilePayload"), + responses( + (status = 200, description = "Moved file", body = FileDto), + (status = 404, description = "File or destination not found"), + ), + tag = "files" +)] +pub async fn move_file_simple( + state: State, + auth_user: AuthUser, + path: Path, + json: Json, +) -> impl IntoResponse { + FileHandler::move_file_simple_impl(state, auth_user, path, json).await +} diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 331ba6ac..8caea7b8 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -10,7 +10,9 @@ use std::hash::{Hash, Hasher}; use std::sync::Arc; use tokio_util::io::ReaderStream; -use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto}; +use crate::application::dtos::folder_dto::{ + CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, +}; use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::file_ports::FileRetrievalUseCase; @@ -27,10 +29,17 @@ type AppState = Arc; pub struct FolderHandler; impl FolderHandler { + // ── Why no #[utoipa::path] here? ───────────────────────────────────────────── + // utoipa 5.4.0's proc macro generates helper structs / impls inside its expansion. + // Rust allows struct definitions at module scope but forbids them inside impl blocks, + // so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP + // verb or annotation content. All route handlers are free functions below. + // TODO: collapse after utoipa upgrade. + /// Creates a new folder. /// When parent_id is not provided, the folder is created inside the /// authenticated user's home folder rather than at the storage root. - pub async fn create_folder( + pub(super) async fn create_folder_impl( State(service): State, auth_user: AuthUser, Json(mut dto): Json, @@ -93,7 +102,7 @@ impl FolderHandler { /// Gets a folder by ID. /// Validates that the authenticated user owns the folder. - pub async fn get_folder( + pub(super) async fn get_folder_impl( State(service): State, auth_user: AuthUser, Path(id): Path, @@ -120,7 +129,7 @@ impl FolderHandler { /// Lists root folders for the authenticated user. /// Only returns folders owned by this user — no information disclosure. - pub async fn list_root_folders( + pub(super) async fn list_root_folders_impl( State(service): State, auth_user: AuthUser, ) -> axum::response::Response { @@ -129,7 +138,7 @@ impl FolderHandler { /// Lists contents of a specific folder by its ID. /// Scoped to the authenticated user's folders. - pub async fn list_folder_contents( + pub(super) async fn list_folder_contents_impl( State(service): State, auth_user: AuthUser, Path(id): Path, @@ -137,8 +146,9 @@ impl FolderHandler { Self::list_folders_scoped(service, Some(&id), &auth_user).await } - /// Lists root folders with pagination support. - pub async fn list_root_folders_paginated( + /// Lists root folders with pagination. + /// Scoped to the authenticated user — only returns folders owned by this user. + pub(super) async fn list_root_folders_paginated_impl( State(service): State, auth_user: AuthUser, _pagination: Query, @@ -146,9 +156,8 @@ impl FolderHandler { Self::list_folders_scoped(service, None, &auth_user).await } - /// Lists contents of a specific folder with pagination. - /// Scoped to the authenticated user — only returns folders owned by this user. - pub async fn list_folder_contents_paginated( + /// Lists sub-folders inside a folder with pagination. + pub(super) async fn list_folder_contents_paginated_impl( State(service): State, auth_user: AuthUser, Path(id): Path, @@ -204,7 +213,7 @@ impl FolderHandler { /// /// Both queries run concurrently via `tokio::join!`. /// Supports `If-None-Match` / ETag for conditional responses (304). - pub async fn list_folder_listing( + pub(super) async fn list_folder_listing_impl( State(state): State>, auth_user: AuthUser, headers: HeaderMap, @@ -246,8 +255,8 @@ impl FolderHandler { } } - /// Renames a folder (ownership enforced by service layer) - pub async fn rename_folder( + /// Renames a folder (ownership enforced). + pub(super) async fn rename_folder_impl( State(service): State, auth_user: AuthUser, Path(id): Path, @@ -259,8 +268,8 @@ impl FolderHandler { } } - /// Moves a folder to a new parent (ownership enforced by service layer) - pub async fn move_folder( + /// Moves a folder to a new parent (ownership enforced). + pub(super) async fn move_folder_impl( State(service): State, auth_user: AuthUser, Path(id): Path, @@ -284,8 +293,8 @@ impl FolderHandler { } } - /// Deletes a folder with trash functionality (ownership enforced by service layer) - pub async fn delete_folder_with_trash( + /// Deletes a folder (moves to trash if enabled, otherwise permanent). + pub(super) async fn delete_folder_with_trash_impl( State(state): State>, auth_user: AuthUser, Path(id): Path, @@ -322,8 +331,8 @@ impl FolderHandler { } } - /// Downloads a folder as a ZIP file (ownership enforced) - pub async fn download_folder_zip( + /// Downloads a folder and all its contents as a ZIP archive. + pub(super) async fn download_folder_zip_impl( State(state): State>, auth_user: AuthUser, Path(id): Path, @@ -427,3 +436,224 @@ impl FolderHandler { } } } + +// ── Route handlers (free functions) ────────────────────────────────────────── +// +// All annotated route functions live here rather than as methods on FolderHandler +// because utoipa 5.4.0's #[utoipa::path] macro generates helper structs inside +// its expansion. Rust allows struct definitions at module scope but forbids them +// inside impl blocks — so every #[utoipa::path] annotation on a FolderHandler +// method fails to compile regardless of HTTP verb or annotation content. +// +// All logic lives in the FolderHandler::*_impl methods above; these thin wrappers +// exist solely to carry the OpenAPI annotation at a scope where utoipa can +// generate its helper types. +// +// routes.rs calls these free functions directly. +// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue. + +#[utoipa::path( + post, + path = "/api/folders", + request_body(content = CreateFolderDto, content_type = "application/json", description = "Folder creation payload"), + responses( + (status = 201, description = "Folder created", body = FolderDto), + (status = 400, description = "Invalid request"), + ), + tag = "folders" +)] +pub async fn create_folder( + state: State, + auth_user: AuthUser, + json: Json, +) -> impl IntoResponse { + FolderHandler::create_folder_impl(state, auth_user, json).await +} + +#[utoipa::path( + get, + path = "/api/folders/{id}", + params(("id" = String, Path, description = "Folder ID")), + responses( + (status = 200, description = "Folder", body = FolderDto), + (status = 404, description = "Folder not found"), + ), + tag = "folders" +)] +pub async fn get_folder( + state: State, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + FolderHandler::get_folder_impl(state, auth_user, path).await +} + +#[utoipa::path( + get, + path = "/api/folders", + responses( + (status = 200, description = "List of root folders", body = Vec), + ), + tag = "folders" +)] +pub async fn list_root_folders( + state: State, + auth_user: AuthUser, +) -> axum::response::Response { + FolderHandler::list_root_folders_impl(state, auth_user).await +} + +#[utoipa::path( + get, + path = "/api/folders/{id}/contents", + params(("id" = String, Path, description = "Folder ID")), + responses( + (status = 200, description = "List of sub-folders", body = Vec), + (status = 404, description = "Folder not found"), + ), + tag = "folders" +)] +pub async fn list_folder_contents( + state: State, + auth_user: AuthUser, + path: Path, +) -> axum::response::Response { + FolderHandler::list_folder_contents_impl(state, auth_user, path).await +} + +#[utoipa::path( + get, + path = "/api/folders/paginated", + params(PaginationRequestDto), + responses( + (status = 200, description = "Paginated list of root folders"), + ), + tag = "folders" +)] +pub async fn list_root_folders_paginated( + state: State, + auth_user: AuthUser, + pagination: Query, +) -> axum::response::Response { + FolderHandler::list_root_folders_paginated_impl(state, auth_user, pagination).await +} + +#[utoipa::path( + get, + path = "/api/folders/{id}/contents/paginated", + params( + ("id" = String, Path, description = "Folder ID"), + PaginationRequestDto, + ), + responses( + (status = 200, description = "Paginated list of sub-folders"), + (status = 404, description = "Folder not found"), + ), + tag = "folders" +)] +pub async fn list_folder_contents_paginated( + state: State, + auth_user: AuthUser, + path: Path, + pagination: Query, +) -> axum::response::Response { + FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await +} + +#[utoipa::path( + get, + path = "/api/folders/{id}/listing", + params(("id" = String, Path, description = "Folder ID")), + responses( + (status = 200, description = "Folder listing (sub-folders + files)", body = FolderListingDto), + (status = 304, description = "Not modified"), + (status = 404, description = "Folder not found"), + ), + tag = "folders" +)] +pub async fn list_folder_listing( + state: State>, + auth_user: AuthUser, + headers: HeaderMap, + path: Path, +) -> axum::response::Response { + FolderHandler::list_folder_listing_impl(state, auth_user, headers, path).await +} + +#[utoipa::path( + put, + path = "/api/folders/{id}/rename", + params(("id" = String, Path, description = "Folder ID")), + request_body(content = RenameFolderDto, content_type = "application/json", description = "Rename payload"), + responses( + (status = 200, description = "Renamed folder", body = FolderDto), + (status = 404, description = "Folder not found"), + ), + tag = "folders" +)] +pub async fn rename_folder( + state: State, + auth_user: AuthUser, + path: Path, + json: Json, +) -> impl IntoResponse { + FolderHandler::rename_folder_impl(state, auth_user, path, json).await +} + +#[utoipa::path( + put, + path = "/api/folders/{id}/move", + params(("id" = String, Path, description = "Folder ID")), + request_body(content = MoveFolderDto, content_type = "application/json", description = "Move payload"), + responses( + (status = 200, description = "Moved folder", body = FolderDto), + (status = 404, description = "Folder or destination not found"), + ), + tag = "folders" +)] +pub async fn move_folder( + state: State, + auth_user: AuthUser, + path: Path, + json: Json, +) -> impl IntoResponse { + FolderHandler::move_folder_impl(state, auth_user, path, json).await +} + +#[utoipa::path( + delete, + path = "/api/folders/{id}", + params(("id" = String, Path, description = "Folder ID")), + responses( + (status = 204, description = "Folder deleted"), + (status = 404, description = "Folder not found"), + ), + tag = "folders" +)] +pub async fn delete_folder_with_trash( + state: State>, + auth_user: AuthUser, + path: Path, +) -> impl IntoResponse { + FolderHandler::delete_folder_with_trash_impl(state, auth_user, path).await +} + +#[utoipa::path( + get, + path = "/api/folders/{id}/download", + params(("id" = String, Path, description = "Folder ID")), + responses( + (status = 200, description = "ZIP archive stream (application/zip)"), + (status = 404, description = "Folder not found"), + (status = 501, description = "ZIP service not available"), + ), + tag = "folders" +)] +pub async fn download_folder_zip( + state: State>, + auth_user: AuthUser, + path: Path, + query: Query>, +) -> impl IntoResponse { + FolderHandler::download_folder_zip_impl(state, auth_user, path, query).await +} diff --git a/src/interfaces/api/handlers/i18n_handler.rs b/src/interfaces/api/handlers/i18n_handler.rs index ea1bfdd4..3978c15e 100644 --- a/src/interfaces/api/handlers/i18n_handler.rs +++ b/src/interfaces/api/handlers/i18n_handler.rs @@ -18,16 +18,21 @@ type AppState = Arc; pub struct I18nHandler; impl I18nHandler { - /// Gets a list of available locales - pub async fn get_locales(State(service): State) -> impl IntoResponse { + // ── Why no #[utoipa::path] here? ───────────────────────────────────────────── + // utoipa 5.4.0's proc macro generates helper structs / impls inside its expansion. + // Rust allows struct definitions at module scope but forbids them inside impl blocks, + // so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP + // verb or annotation content. All route handlers are free functions below. + // TODO: collapse after utoipa upgrade. + pub(super) async fn get_locales_impl(State(service): State) -> impl IntoResponse { let locales = service.available_locales().await; let locale_dtos: Vec = locales.into_iter().map(LocaleDto::from).collect(); (StatusCode::OK, Json(locale_dtos)).into_response() } - /// Translates a key to the requested locale - pub async fn translate( + /// Translates a single key to the requested locale. + pub(super) async fn translate_impl( State(service): State, Query(query): Query, ) -> impl IntoResponse { @@ -79,8 +84,8 @@ impl I18nHandler { } } - /// Gets all translations for a locale (Axum-compatible: extracts locale from path) - pub async fn get_translations_by_locale( + /// Returns all translations for a locale as a flat key→value object. + pub(super) async fn get_translations_by_locale_impl( State(service): State, Path(locale_code): Path, ) -> impl IntoResponse { @@ -116,3 +121,68 @@ impl I18nHandler { .into_response() } } + +// ── Route handlers (free functions) ────────────────────────────────────────── +// +// All three route functions live here rather than as methods on I18nHandler +// because utoipa 5.4.0's #[utoipa::path] macro generates helper structs inside +// its expansion. Rust allows struct definitions at module scope but forbids them +// inside impl blocks — so every #[utoipa::path] annotation on an I18nHandler +// method fails to compile regardless of HTTP verb or annotation content. +// +// All logic lives in the I18nHandler::*_impl methods above; these thin wrappers +// exist solely to carry the OpenAPI annotation at a scope where utoipa can +// generate its helper types. +// +// routes.rs calls these free functions directly. +// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue. + +#[utoipa::path( + get, + path = "/api/i18n/locales", + responses( + (status = 200, description = "Available locales", body = Vec), + ), + tag = "i18n" +)] +pub async fn get_locales(state: State) -> impl IntoResponse { + I18nHandler::get_locales_impl(state).await +} + +#[utoipa::path( + get, + path = "/api/i18n/translate", + params( + ("key" = String, Query, description = "Translation key"), + ("locale" = Option, Query, description = "Target locale code (defaults to en)"), + ), + responses( + (status = 200, description = "Translation", body = TranslationResponseDto), + (status = 400, description = "Unsupported locale", body = TranslationErrorDto), + (status = 404, description = "Key not found"), + ), + tag = "i18n" +)] +pub async fn translate( + state: State, + query: Query, +) -> impl IntoResponse { + I18nHandler::translate_impl(state, query).await +} + +#[utoipa::path( + get, + path = "/api/i18n/locales/{locale_code}", + params(("locale_code" = String, Path, description = "Locale code, e.g. en, fr, de")), + responses( + (status = 200, description = "All translations for this locale"), + (status = 400, description = "Unsupported locale"), + ), + tag = "i18n" +)] +pub async fn get_translations_by_locale( + state: State, + path: Path, +) -> impl IntoResponse { + I18nHandler::get_translations_by_locale_impl(state, path).await +} diff --git a/src/interfaces/api/handlers/music_handler.rs b/src/interfaces/api/handlers/music_handler.rs index 191a0156..58bcce65 100644 --- a/src/interfaces/api/handlers/music_handler.rs +++ b/src/interfaces/api/handlers/music_handler.rs @@ -23,6 +23,16 @@ pub struct PaginationQuery { pub offset: Option, } +#[utoipa::path( + post, + path = "/api/playlists", + responses( + (status = 201, description = "Playlist created"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "playlists" +)] pub async fn create_playlist( State(music_service): State>, auth_user: AuthUser, @@ -34,6 +44,17 @@ pub async fn create_playlist( } } +#[utoipa::path( + get, + path = "/api/playlists/{playlist_id}", + params(("playlist_id" = String, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Playlist details"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist not found") + ), + tag = "playlists" +)] pub async fn get_playlist( State(music_service): State>, auth_user: AuthUser, @@ -45,6 +66,15 @@ pub async fn get_playlist( } } +#[utoipa::path( + get, + path = "/api/playlists", + responses( + (status = 200, description = "List of playlists"), + (status = 401, description = "Unauthorized") + ), + tag = "playlists" +)] pub async fn list_playlists( State(music_service): State>, auth_user: AuthUser, @@ -62,6 +92,17 @@ pub struct IncludeSharedQuery { pub include_public: Option, } +#[utoipa::path( + put, + path = "/api/playlists/{playlist_id}", + params(("playlist_id" = String, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Playlist updated"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist not found") + ), + tag = "playlists" +)] pub async fn update_playlist( State(music_service): State>, auth_user: AuthUser, @@ -77,6 +118,17 @@ pub async fn update_playlist( } } +#[utoipa::path( + delete, + path = "/api/playlists/{playlist_id}", + params(("playlist_id" = String, Path, description = "Playlist ID")), + responses( + (status = 204, description = "Playlist deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist not found") + ), + tag = "playlists" +)] pub async fn delete_playlist( State(music_service): State>, auth_user: AuthUser, @@ -91,6 +143,17 @@ pub async fn delete_playlist( } } +#[utoipa::path( + post, + path = "/api/playlists/{playlist_id}/tracks", + params(("playlist_id" = String, Path, description = "Playlist ID")), + responses( + (status = 201, description = "Tracks added"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist not found") + ), + tag = "playlists" +)] pub async fn add_tracks( State(music_service): State>, auth_user: AuthUser, @@ -106,6 +169,20 @@ pub async fn add_tracks( } } +#[utoipa::path( + delete, + path = "/api/playlists/{playlist_id}/tracks/{file_id}", + params( + ("playlist_id" = String, Path, description = "Playlist ID"), + ("file_id" = String, Path, description = "File ID to remove") + ), + responses( + (status = 204, description = "Track removed"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist or track not found") + ), + tag = "playlists" +)] pub async fn remove_track( State(music_service): State>, auth_user: AuthUser, @@ -120,6 +197,17 @@ pub async fn remove_track( } } +#[utoipa::path( + put, + path = "/api/playlists/{playlist_id}/reorder", + params(("playlist_id" = String, Path, description = "Playlist ID")), + responses( + (status = 204, description = "Tracks reordered"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist not found") + ), + tag = "playlists" +)] pub async fn reorder_tracks( State(music_service): State>, auth_user: AuthUser, @@ -135,6 +223,17 @@ pub async fn reorder_tracks( } } +#[utoipa::path( + get, + path = "/api/playlists/{playlist_id}/tracks", + params(("playlist_id" = String, Path, description = "Playlist ID")), + responses( + (status = 200, description = "List of playlist tracks"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist not found") + ), + tag = "playlists" +)] pub async fn list_playlist_tracks( State(music_service): State>, auth_user: AuthUser, @@ -149,6 +248,17 @@ pub async fn list_playlist_tracks( } } +#[utoipa::path( + post, + path = "/api/playlists/{playlist_id}/share", + params(("playlist_id" = String, Path, description = "Playlist ID")), + responses( + (status = 204, description = "Playlist shared"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist not found") + ), + tag = "playlists" +)] pub async fn share_playlist( State(music_service): State>, auth_user: AuthUser, @@ -164,6 +274,20 @@ pub async fn share_playlist( } } +#[utoipa::path( + delete, + path = "/api/playlists/{playlist_id}/share/{user_id}", + params( + ("playlist_id" = String, Path, description = "Playlist ID"), + ("user_id" = String, Path, description = "User ID to remove share") + ), + responses( + (status = 204, description = "Share removed"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist or share not found") + ), + tag = "playlists" +)] pub async fn remove_share( State(music_service): State>, auth_user: AuthUser, @@ -178,6 +302,17 @@ pub async fn remove_share( } } +#[utoipa::path( + get, + path = "/api/playlists/{playlist_id}/shares", + params(("playlist_id" = String, Path, description = "Playlist ID")), + responses( + (status = 200, description = "List of playlist shares"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Playlist not found") + ), + tag = "playlists" +)] pub async fn get_playlist_shares( State(music_service): State>, auth_user: AuthUser, @@ -192,6 +327,17 @@ pub async fn get_playlist_shares( } } +#[utoipa::path( + get, + path = "/api/playlists/audio-metadata/{file_id}", + params(("file_id" = String, Path, description = "Audio file ID")), + responses( + (status = 200, description = "Audio metadata"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "File not found") + ), + tag = "playlists" +)] pub async fn get_audio_metadata( State(music_service): State>, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs index f660a3a0..83f8dab5 100644 --- a/src/interfaces/api/handlers/photos_handler.rs +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -26,6 +26,20 @@ pub struct PhotosQueryParams { /// /// Supports cursor-based pagination via the `before` parameter. /// The `X-Next-Cursor` response header contains the cursor for the next page. +#[utoipa::path( + get, + path = "/api/photos", + params( + ("before" = Option, Query, description = "Cursor: only return items with sort_date before this epoch value"), + ("limit" = Option, Query, description = "Max items to return (default 200, max 500)") + ), + responses( + (status = 200, description = "List of media files sorted by capture date"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + tag = "photos" +)] pub async fn list_photos( State(state): State>, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index 0ee4b622..f6bd8844 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -6,7 +6,9 @@ use axum::{ use serde_json::json; use tracing::{error, info}; -use crate::application::dtos::search_dto::SearchCriteriaDto; +use crate::application::dtos::search_dto::{ + SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto, +}; use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::interfaces::middleware::auth::AuthUser; @@ -22,8 +24,13 @@ use std::sync::Arc; pub struct SearchHandler; impl SearchHandler { - /// GET /search — simple query-parameter-based search. - pub async fn search_files_get( + // ── Why no #[utoipa::path] here? ───────────────────────────────────────────── + // utoipa 5.4.0's proc macro generates helper structs / impls inside its expansion. + // Rust allows struct definitions at module scope but forbids them inside impl blocks, + // so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP + // verb or annotation content. All route handlers are free functions below. + // TODO: collapse after utoipa upgrade. + pub(super) async fn search_files_get_impl( State(state): State>, auth_user: AuthUser, Query(params): Query, @@ -81,8 +88,8 @@ impl SearchHandler { } } - /// POST /search/advanced — full criteria in the request body. - pub async fn search_files_post( + /// Advanced search with full criteria in the request body. + pub(super) async fn search_files_post_impl( State(state): State>, auth_user: AuthUser, Json(criteria): Json, @@ -122,8 +129,8 @@ impl SearchHandler { } } - /// GET /search/suggest — lightweight autocomplete suggestions. - pub async fn suggest_files( + /// Autocomplete suggestions for search. + pub(super) async fn suggest_files_impl( State(state): State>, Query(params): Query, ) -> impl IntoResponse { @@ -167,7 +174,9 @@ impl SearchHandler { } /// DELETE /search/cache — clears the search results cache. - pub async fn clear_search_cache(State(state): State>) -> impl IntoResponse { + pub(super) async fn clear_search_cache_impl( + State(state): State>, + ) -> impl IntoResponse { info!("API: Clearing search cache"); let search_service = match &state.applications.search_service { @@ -259,3 +268,95 @@ pub struct SuggestParams { /// Maximum number of suggestions (default 10, max 20) pub limit: Option, } + +// ── Route handlers (free functions) ────────────────────────────────────────── +// +// All four route functions live here rather than as methods on SearchHandler +// because utoipa 5.4.0's #[utoipa::path] macro generates helper structs inside +// its expansion. Rust allows struct definitions at module scope but forbids them +// inside impl blocks — so every #[utoipa::path] annotation on a SearchHandler +// method fails to compile regardless of HTTP verb or annotation content. +// +// All logic lives in the SearchHandler::*_impl methods above; these thin wrappers +// exist solely to carry the OpenAPI annotation at a scope where utoipa can +// generate its helper types. +// +// routes.rs calls these free functions directly. +// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue. + +#[utoipa::path( + get, + path = "/api/search", + params( + ("query" = Option, Query, description = "Text to search in names"), + ("type" = Option, Query, description = "Comma-separated MIME type filter"), + ("folder_id" = Option, Query, description = "Restrict search to this folder"), + ("recursive" = Option, Query, description = "Include sub-folders"), + ("limit" = Option, Query, description = "Max results"), + ("offset" = Option, Query, description = "Pagination offset"), + ), + responses( + (status = 200, description = "Search results", body = SearchResultsDto), + (status = 503, description = "Search service unavailable"), + ), + tag = "search" +)] +pub async fn search_files_get( + state: State>, + auth_user: AuthUser, + query: Query, +) -> impl IntoResponse { + SearchHandler::search_files_get_impl(state, auth_user, query).await +} + +#[utoipa::path( + post, + path = "/api/search/advanced", + request_body(content = SearchCriteriaDto, content_type = "application/json", description = "Search criteria"), + responses( + (status = 200, description = "Search results", body = SearchResultsDto), + (status = 503, description = "Search service unavailable"), + ), + tag = "search" +)] +pub async fn search_files_post( + state: State>, + auth_user: AuthUser, + json: Json, +) -> impl IntoResponse { + SearchHandler::search_files_post_impl(state, auth_user, json).await +} + +#[utoipa::path( + get, + path = "/api/search/suggest", + params( + ("query" = String, Query, description = "Partial name to complete"), + ("folder_id" = Option, Query, description = "Restrict to this folder"), + ("limit" = Option, Query, description = "Max suggestions (default 10, max 20)"), + ), + responses( + (status = 200, description = "Suggestions", body = SearchSuggestionsDto), + (status = 503, description = "Search service unavailable"), + ), + tag = "search" +)] +pub async fn suggest_files( + state: State>, + query: Query, +) -> impl IntoResponse { + SearchHandler::suggest_files_impl(state, query).await +} + +#[utoipa::path( + delete, + path = "/api/search/cache", + responses( + (status = 200, description = "Cache cleared"), + (status = 503, description = "Search service unavailable"), + ), + tag = "search" +)] +pub async fn clear_search_cache(state: State>) -> impl IntoResponse { + SearchHandler::clear_search_cache_impl(state).await +} diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index e76a9af7..7df43dbb 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -280,6 +280,19 @@ pub async fn verify_shared_item_password( /// /// Validates the share token, checks it refers to a file (not folder), /// then streams the file content to the caller. +#[utoipa::path( + get, + path = "/s/{token}/download", + params(("token" = String, Path, description = "Share token")), + responses( + (status = 200, description = "File content stream"), + (status = 401, description = "Password required"), + (status = 404, description = "Share not found"), + (status = 410, description = "Share expired"), + (status = 503, description = "Sharing disabled") + ), + tag = "shares" +)] pub async fn download_shared_file( State(state): State>, Path(token): Path, diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index f53dde99..28831f25 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -15,6 +15,9 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; use crate::application::dtos::folder_listing_dto::FolderListingDto; +use crate::application::dtos::i18n_dto::{ + LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto, +}; use crate::application::dtos::pagination::{PaginationDto, PaginationRequestDto}; use crate::application::dtos::recent_dto::RecentItemDto; use crate::application::dtos::search_dto::{ @@ -31,17 +34,72 @@ use crate::application::dtos::user_dto::{ AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto, }; +use crate::application::ports::chunked_upload_ports::{ + ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto, +}; +use crate::interfaces::api::handlers::chunked_upload_handler::{ + CompleteUploadResponse, CreateUploadRequest, +}; +use crate::interfaces::api::handlers::dedup_handler::{ + DedupUploadResponse, HashCheckResponse, StatsResponse, +}; use crate::interfaces::api::handlers::file_handler::MoveFilePayload; #[derive(OpenApi)] #[openapi( paths( + // File handlers (free functions — see file_handler.rs for why) + handlers::file_handler::list_files_query, + handlers::file_handler::upload_file_with_thumbnails, + handlers::file_handler::download_file, + handlers::file_handler::get_thumbnail, + handlers::file_handler::upload_thumbnail, + handlers::file_handler::get_file_metadata, + handlers::file_handler::delete_file, + handlers::file_handler::rename_file, + handlers::file_handler::move_file_simple, + // Folder handlers (free functions — see folder_handler.rs for why) + handlers::folder_handler::create_folder, + handlers::folder_handler::get_folder, + handlers::folder_handler::list_root_folders, + handlers::folder_handler::list_folder_contents, + handlers::folder_handler::list_root_folders_paginated, + handlers::folder_handler::list_folder_contents_paginated, + handlers::folder_handler::list_folder_listing, + handlers::folder_handler::rename_folder, + handlers::folder_handler::move_folder, + handlers::folder_handler::delete_folder_with_trash, + handlers::folder_handler::download_folder_zip, + // Search handlers (free functions — see search_handler.rs for why) + handlers::search_handler::search_files_get, + handlers::search_handler::search_files_post, + handlers::search_handler::suggest_files, + handlers::search_handler::clear_search_cache, + // i18n handlers (free functions — see i18n_handler.rs for why) + handlers::i18n_handler::get_locales, + handlers::i18n_handler::translate, + handlers::i18n_handler::get_translations_by_locale, + // Chunked upload handlers — all five are free functions (not impl methods) because + // utoipa 5.4.0 cannot annotate methods on ChunkedUploadHandler; see handler file. + handlers::chunked_upload_handler::create_upload, + handlers::chunked_upload_handler::upload_chunk, + handlers::chunked_upload_handler::get_upload_status, + handlers::chunked_upload_handler::complete_upload, + handlers::chunked_upload_handler::cancel_upload, + // Dedup handlers — all free functions for the same utoipa reason as chunked uploads. + handlers::dedup_handler::check_hash, + handlers::dedup_handler::upload_with_dedup, + handlers::dedup_handler::get_stats, + handlers::dedup_handler::get_blob, + handlers::dedup_handler::recalculate_stats, + // Trash handlers (free functions) handlers::trash_handler::get_trash_items, handlers::trash_handler::move_file_to_trash, handlers::trash_handler::move_folder_to_trash, handlers::trash_handler::restore_from_trash, handlers::trash_handler::delete_permanently, handlers::trash_handler::empty_trash, + // Share handlers (free functions) handlers::share_handler::create_shared_link, handlers::share_handler::get_shared_link, handlers::share_handler::get_user_shares, @@ -49,14 +107,68 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::share_handler::delete_shared_link, handlers::share_handler::access_shared_item, handlers::share_handler::verify_shared_item_password, + handlers::share_handler::download_shared_file, + // Favorites handlers (free functions) handlers::favorites_handler::get_favorites, handlers::favorites_handler::add_favorite, handlers::favorites_handler::remove_favorite, handlers::favorites_handler::batch_add_favorites, + // Recent handlers (free functions) handlers::recent_handler::get_recent_items, handlers::recent_handler::record_item_access, handlers::recent_handler::remove_from_recent, handlers::recent_handler::clear_recent_items, + // Photos handler (free function) + handlers::photos_handler::list_photos, + // Batch handlers (free functions) + handlers::batch_handler::move_files_batch, + handlers::batch_handler::copy_files_batch, + handlers::batch_handler::delete_files_batch, + handlers::batch_handler::get_files_batch, + handlers::batch_handler::delete_folders_batch, + handlers::batch_handler::create_folders_batch, + handlers::batch_handler::get_folders_batch, + handlers::batch_handler::move_folders_batch, + handlers::batch_handler::trash_batch, + handlers::batch_handler::download_batch, + // Music/playlist handlers (free functions) + handlers::music_handler::create_playlist, + handlers::music_handler::list_playlists, + handlers::music_handler::get_playlist, + handlers::music_handler::update_playlist, + handlers::music_handler::delete_playlist, + handlers::music_handler::list_playlist_tracks, + handlers::music_handler::add_tracks, + handlers::music_handler::remove_track, + handlers::music_handler::reorder_tracks, + handlers::music_handler::share_playlist, + handlers::music_handler::remove_share, + handlers::music_handler::get_playlist_shares, + handlers::music_handler::get_audio_metadata, + // Admin handlers (pub free functions) + handlers::admin_handler::get_dashboard_stats, + handlers::admin_handler::list_users, + handlers::admin_handler::get_user, + handlers::admin_handler::create_user, + handlers::admin_handler::delete_user, + handlers::admin_handler::update_user_role, + handlers::admin_handler::update_user_active, + handlers::admin_handler::update_user_quota, + handlers::admin_handler::reset_user_password, + handlers::admin_handler::get_registration_setting, + handlers::admin_handler::set_registration_setting, + handlers::admin_handler::get_general_settings, + handlers::admin_handler::get_oidc_settings, + handlers::admin_handler::save_oidc_settings, + handlers::admin_handler::get_storage_settings, + handlers::admin_handler::save_storage_settings, + handlers::admin_handler::get_migration_status, + handlers::admin_handler::start_migration, + handlers::admin_handler::pause_migration, + handlers::admin_handler::resume_migration, + handlers::admin_handler::complete_migration, + handlers::admin_handler::verify_migration, + handlers::admin_handler::generate_encryption_key, ), components( schemas( @@ -102,16 +214,38 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; BatchFavoritesStats, // Recent schemas RecentItemDto, + // i18n schemas + LocaleDto, + TranslationRequestDto, + TranslationResponseDto, + TranslationErrorDto, + // Chunked upload schemas + CreateUploadRequest, + CompleteUploadResponse, + CreateUploadResponseDto, + ChunkUploadResponseDto, + UploadStatusResponseDto, + // Dedup schemas + HashCheckResponse, + DedupUploadResponse, + StatsResponse, ) ), tags( - (name = "folders", description = "Folder management endpoints"), (name = "files", description = "File management endpoints"), + (name = "folders", description = "Folder management endpoints"), (name = "trash", description = "Trash / recycle bin endpoints"), (name = "search", description = "Search endpoints"), (name = "shares", description = "Shared links endpoints"), (name = "favorites", description = "Favorites management endpoints"), (name = "recent", description = "Recent items endpoints"), + (name = "photos", description = "Photos timeline endpoints"), + (name = "i18n", description = "Internationalisation endpoints"), + (name = "uploads", description = "Chunked / resumable upload endpoints"), + (name = "dedup", description = "Content deduplication endpoints"), + (name = "batch", description = "Batch operation endpoints"), + (name = "playlists", description = "Music playlist endpoints"), + (name = "admin", description = "Admin management endpoints"), ), info( title = "OxiCloud API", diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 062e74cd..a7e1e996 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -25,10 +25,24 @@ async fn get_openapi_spec() -> AxumJson { use crate::interfaces::api::handlers::admin_handler; use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState}; -use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; -use crate::interfaces::api::handlers::file_handler::FileHandler; -use crate::interfaces::api::handlers::folder_handler::FolderHandler; -use crate::interfaces::api::handlers::i18n_handler::I18nHandler; +use crate::interfaces::api::handlers::chunked_upload_handler::{ + cancel_upload, complete_upload, create_upload, get_upload_status, upload_chunk, +}; +use crate::interfaces::api::handlers::file_handler::{ + delete_file, download_file, get_file_metadata, get_thumbnail, list_files_query, + move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail, +}; +use crate::interfaces::api::handlers::folder_handler::{ + create_folder, delete_folder_with_trash, download_folder_zip, get_folder, list_folder_contents, + list_folder_contents_paginated, list_folder_listing, list_root_folders, + list_root_folders_paginated, move_folder, rename_folder, +}; +use crate::interfaces::api::handlers::i18n_handler::{ + get_locales, get_translations_by_locale, translate, +}; +use crate::interfaces::api::handlers::search_handler::{ + clear_search_cache, search_files_get, search_files_post, suggest_files, +}; use crate::interfaces::api::handlers::trash_handler; /// Creates public API routes that should NOT require authentication. @@ -62,12 +76,9 @@ pub fn create_public_api_routes(app_state: &Arc) -> Router) -> Router> { // Create the basic folders router with service operations let folders_basic_router = Router::new() - .route("/", post(FolderHandler::create_folder)) - .route("/", get(FolderHandler::list_root_folders)) - .route( - "/paginated", - get(FolderHandler::list_root_folders_paginated), - ) - .route("/{id}", get(FolderHandler::get_folder)) - .route("/{id}/contents", get(FolderHandler::list_folder_contents)) + .route("/", post(create_folder)) + .route("/", get(list_root_folders)) + .route("/paginated", get(list_root_folders_paginated)) + .route("/{id}", get(get_folder)) + .route("/{id}/contents", get(list_folder_contents)) .route( "/{id}/contents/paginated", - get(FolderHandler::list_folder_contents_paginated), + get(list_folder_contents_paginated), ) - .route("/{id}/rename", put(FolderHandler::rename_folder)) - .route("/{id}/move", put(FolderHandler::move_folder)) + .route("/{id}/rename", put(rename_folder)) + .route("/{id}/move", put(move_folder)) .with_state(folder_service.clone()); // Special route for ZIP download that requires AppState instead of just FolderService let folder_zip_router = Router::new() - .route("/{id}/download", get(FolderHandler::download_folder_zip)) + .route("/{id}/download", get(download_folder_zip)) .with_state(app_state.clone()); // Combined listing endpoint: returns both sub-folders AND files in one // response. Needs full AppState because it calls both FolderService // and FileRetrievalService concurrently. let folder_listing_router = Router::new() - .route("/{id}/listing", get(FolderHandler::list_folder_listing)) + .route("/{id}/listing", get(list_folder_listing)) .with_state(app_state.clone()); // Create folder operations that use trash (requires full AppState) - let folders_ops_router = - Router::new().route("/{id}", delete(FolderHandler::delete_folder_with_trash)); + let folders_ops_router = Router::new().route("/{id}", delete(delete_folder_with_trash)); // Merge the routers let folders_router = folders_basic_router @@ -154,14 +161,14 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // Create file routes for basic operations and trash-enabled delete let basic_file_router = Router::new() - .route("/", get(FileHandler::list_files_query)) - .route("/upload", post(FileHandler::upload_file_with_thumbnails)) - .route("/{id}", get(FileHandler::download_file)) + .route("/", get(list_files_query)) + .route("/upload", post(upload_file_with_thumbnails)) + .route("/{id}", get(download_file)) .route( "/{id}/thumbnail/{size}", - get(FileHandler::get_thumbnail).put(FileHandler::upload_thumbnail), + get(get_thumbnail).put(upload_thumbnail), ) - .route("/{id}/metadata", get(FileHandler::get_file_metadata)) + .route("/{id}/metadata", get(get_file_metadata)) .layer(DefaultBodyLimit::max({ // Use architecture-appropriate body limit: 10 GB on 64-bit, 1 GB on 32-bit #[cfg(target_pointer_width = "64")] @@ -174,9 +181,9 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // File operations with trash support let file_operations_router = Router::new() - .route("/{id}", delete(FileHandler::delete_file)) - .route("/{id}/move", put(FileHandler::move_file_simple)) - .route("/{id}/rename", put(FileHandler::rename_file)); + .route("/{id}", delete(delete_file)) + .route("/{id}/move", put(move_file_simple)) + .route("/{id}/rename", put(rename_file)); // Merge the routers let files_router = basic_file_router.merge(file_operations_router); @@ -201,17 +208,15 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // Create search routes if the service is available let search_router = if search_service.is_some() { - use crate::interfaces::api::handlers::search_handler::SearchHandler; - Router::new() // Simple search with query parameters - .route("/", get(SearchHandler::search_files_get)) + .route("/", get(search_files_get)) // Lightweight autocomplete suggestions - .route("/suggest", get(SearchHandler::suggest_files)) + .route("/suggest", get(suggest_files)) // Advanced search with full criteria object - .route("/advanced", post(SearchHandler::search_files_post)) + .route("/advanced", post(search_files_post)) // Clear search cache - .route("/cache", delete(SearchHandler::clear_search_cache)) + .route("/cache", delete(clear_search_cache)) .with_state(app_state.clone()) } else { Router::new() @@ -275,49 +280,32 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { Router::new() }; - // Create routes for chunked uploads (large files >10MB) + // Create routes for chunked uploads (large files >10MB). + // All five handlers are free functions — see chunked_upload_handler.rs for why + // #[utoipa::path] cannot be applied to ChunkedUploadHandler impl methods directly. let chunked_upload_router = Router::new() - .route("/", post(ChunkedUploadHandler::create_upload)) - .route( - "/{upload_id}", - axum::routing::patch(ChunkedUploadHandler::upload_chunk), - ) - .route( - "/{upload_id}", - axum::routing::head(ChunkedUploadHandler::get_upload_status), - ) - .route( - "/{upload_id}/complete", - post(ChunkedUploadHandler::complete_upload), - ) - .route("/{upload_id}", delete(ChunkedUploadHandler::cancel_upload)) + .route("/", post(create_upload)) + .route("/{upload_id}", axum::routing::patch(upload_chunk)) + .route("/{upload_id}", axum::routing::head(get_upload_status)) + .route("/{upload_id}/complete", post(complete_upload)) + .route("/{upload_id}", delete(cancel_upload)) .with_state(app_state.clone()); - // Create routes for deduplication endpoints + // Create routes for deduplication endpoints. + // All handlers are free functions — see dedup_handler.rs for why + // #[utoipa::path] cannot be applied to DedupHandler impl methods directly. + use super::handlers::dedup_handler::{ + check_hash, get_blob, get_stats, recalculate_stats, upload_with_dedup, + }; let dedup_router = Router::new() - .route( - "/check/{hash}", - get(super::handlers::dedup_handler::DedupHandler::check_hash), - ) - .route( - "/upload", - post(super::handlers::dedup_handler::DedupHandler::upload_with_dedup), - ) - .route( - "/stats", - get(super::handlers::dedup_handler::DedupHandler::get_stats), - ) - .route( - "/blob/{hash}", - get(super::handlers::dedup_handler::DedupHandler::get_blob), - ) + .route("/check/{hash}", get(check_hash)) + .route("/upload", post(upload_with_dedup)) + .route("/stats", get(get_stats)) + .route("/blob/{hash}", get(get_blob)) // NOTE: remove_reference is intentionally NOT exposed as a public // endpoint — ref_count management is an internal concern handled // automatically when files are deleted via the file API. - .route( - "/recalculate", - post(super::handlers::dedup_handler::DedupHandler::recalculate_stats), - ) + .route("/recalculate", post(recalculate_stats)) .with_state(app_state.clone()); let mut router = Router::new()