Merge pull request #331 from EdouardVanbelle/feat/full-openapi-coverage

This commit is contained in:
Dionisio Pozo
2026-05-04 22:08:33 +02:00
committed by GitHub
17 changed files with 1723 additions and 191 deletions
+5 -4
View File
@@ -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<Locale> 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,
+8 -7
View File
@@ -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<PlaylistItem> for PlaylistItemDto {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreatePlaylistDto {
pub name: String,
pub description: Option<String>,
pub is_public: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdatePlaylistDto {
pub name: Option<String>,
pub description: Option<String>,
@@ -127,17 +128,17 @@ pub struct UpdatePlaylistDto {
pub cover_file_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AddTracksDto {
pub file_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ReorderTracksDto {
pub item_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SharePlaylistDto {
pub user_id: String,
pub can_write: Option<bool>,
+10 -9
View File
@@ -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<String>,
@@ -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<usize>,
}
/// 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<usize>,
@@ -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,
+275 -23
View File
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<SaveOidcSettingsDto>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<SaveStorageSettingsDto>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<StartMigrationDto>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<VerifyMigrationDto>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<i64>, Query, description = "Max users to return (default 100, max 500)"),
("offset" = Option<i64>, 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<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
+117 -6
View File
@@ -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<String>,
@@ -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<String>,
@@ -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<CreateFolderDetail>,
}
/// 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<BatchHandlerState>,
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<BatchHandlerState>,
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<BatchHandlerState>,
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<BatchHandlerState>,
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<BatchHandlerState>,
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<BatchHandlerState>,
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<BatchHandlerState>,
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<BatchHandlerState>,
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<BatchHandlerState>,
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<BatchHandlerState>,
auth_user: AuthUser,
@@ -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<String>,
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
Json(request): Json<CreateUploadRequest>,
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
Path(upload_id): Path<String>,
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
Path(upload_id): Path<String>,
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
Path(upload_id): Path<String>,
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
Path(upload_id): Path<String>,
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
request: Json<CreateUploadRequest>,
) -> 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<String>, 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<Arc<AppState>>,
auth_user: AuthUser,
path: Path<String>,
query: Query<ChunkUploadParams>,
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<Arc<AppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<Arc<AppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<Arc<AppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> impl IntoResponse {
ChunkedUploadHandler::cancel_upload_impl(state, auth_user, path).await
}
+124 -9
View File
@@ -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<AppState>;
/// 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<GlobalState>,
auth_user: AuthUser,
Path(hash): Path<String>,
@@ -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<GlobalState>,
_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<GlobalState>,
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<GlobalState>,
auth_user: AuthUser,
Path(hash): Path<String>,
@@ -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<GlobalState>,
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<GlobalState>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<GlobalState>,
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<GlobalState>, 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<GlobalState>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<GlobalState>,
auth_user: AuthUser,
) -> impl IntoResponse {
DedupHandler::recalculate_stats_impl(state, auth_user).await
}
#[cfg(test)]
mod tests {
use super::*;
+223 -13
View File
@@ -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<AppState>;
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<GlobalState>,
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<GlobalState>,
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<GlobalState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<GlobalState>,
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<GlobalState>,
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<GlobalState>,
auth_user: AuthUser,
Path(file_id): Path<String>,
@@ -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<GlobalState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<GlobalState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<GlobalState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -1066,3 +1072,207 @@ pub struct MoveFilePayload {
/// Target folder ID (None means root)
pub folder_id: Option<String>,
}
// ── 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<String>, Query, description = "Filter by folder ID")),
responses(
(status = 200, description = "List of files", body = Vec<FileDto>),
(status = 304, description = "Not modified"),
),
tag = "files"
)]
pub async fn list_files_query(
state: State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
query: Query<HashMap<String, String>>,
) -> 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<GlobalState>,
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<bool>, Query, description = "Return metadata JSON instead of file content"),
("original" = Option<bool>, Query, description = "Skip WebP transcoding"),
("inline" = Option<bool>, 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<GlobalState>,
auth_user: AuthUser,
path: Path<String>,
query: Query<HashMap<String, String>>,
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<GlobalState>,
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<GlobalState>,
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<GlobalState>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<GlobalState>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<GlobalState>,
auth_user: AuthUser,
path: Path<String>,
json: Json<serde_json::Value>,
) -> 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<GlobalState>,
auth_user: AuthUser,
path: Path<String>,
json: Json<serde_json::Value>,
) -> impl IntoResponse {
FileHandler::move_file_simple_impl(state, auth_user, path, json).await
}
+249 -19
View File
@@ -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<FolderService>;
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<AppState>,
auth_user: AuthUser,
Json(mut dto): Json<CreateFolderDto>,
@@ -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<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<AppState>,
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<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<AppState>,
auth_user: AuthUser,
_pagination: Query<PaginationRequestDto>,
@@ -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<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<Arc<GlobalAppState>>,
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<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
@@ -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<AppState>,
auth_user: AuthUser,
json: Json<CreateFolderDto>,
) -> 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<AppState>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<FolderDto>),
),
tag = "folders"
)]
pub async fn list_root_folders(
state: State<AppState>,
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<FolderDto>),
(status = 404, description = "Folder not found"),
),
tag = "folders"
)]
pub async fn list_folder_contents(
state: State<AppState>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<AppState>,
auth_user: AuthUser,
pagination: Query<PaginationRequestDto>,
) -> 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<AppState>,
auth_user: AuthUser,
path: Path<String>,
pagination: Query<PaginationRequestDto>,
) -> 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<Arc<GlobalAppState>>,
auth_user: AuthUser,
headers: HeaderMap,
path: Path<String>,
) -> 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<AppState>,
auth_user: AuthUser,
path: Path<String>,
json: Json<RenameFolderDto>,
) -> 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<AppState>,
auth_user: AuthUser,
path: Path<String>,
json: Json<MoveFolderDto>,
) -> 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<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> 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<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
query: Query<HashMap<String, String>>,
) -> impl IntoResponse {
FolderHandler::download_folder_zip_impl(state, auth_user, path, query).await
}
+76 -6
View File
@@ -18,16 +18,21 @@ type AppState = Arc<I18nApplicationService>;
pub struct I18nHandler;
impl I18nHandler {
/// Gets a list of available locales
pub async fn get_locales(State(service): State<AppState>) -> 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<AppState>) -> impl IntoResponse {
let locales = service.available_locales().await;
let locale_dtos: Vec<LocaleDto> = 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<AppState>,
Query(query): Query<TranslationRequestDto>,
) -> 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<AppState>,
Path(locale_code): Path<String>,
) -> 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<LocaleDto>),
),
tag = "i18n"
)]
pub async fn get_locales(state: State<AppState>) -> impl IntoResponse {
I18nHandler::get_locales_impl(state).await
}
#[utoipa::path(
get,
path = "/api/i18n/translate",
params(
("key" = String, Query, description = "Translation key"),
("locale" = Option<String>, 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<AppState>,
query: Query<TranslationRequestDto>,
) -> 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<AppState>,
path: Path<String>,
) -> impl IntoResponse {
I18nHandler::get_translations_by_locale_impl(state, path).await
}
@@ -23,6 +23,16 @@ pub struct PaginationQuery {
pub offset: Option<i64>,
}
#[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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
auth_user: AuthUser,
@@ -62,6 +92,17 @@ pub struct IncludeSharedQuery {
pub include_public: Option<bool>,
}
#[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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
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<Arc<MusicService>>,
auth_user: AuthUser,
@@ -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<i64>, Query, description = "Cursor: only return items with sort_date before this epoch value"),
("limit" = Option<i64>, 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<Arc<AppState>>,
auth_user: AuthUser,
+109 -8
View File
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<SearchParams>,
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
Json(criteria): Json<SearchCriteriaDto>,
@@ -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<Arc<AppState>>,
Query(params): Query<SuggestParams>,
) -> 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<Arc<AppState>>) -> impl IntoResponse {
pub(super) async fn clear_search_cache_impl(
State(state): State<Arc<AppState>>,
) -> 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<usize>,
}
// ── 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<String>, Query, description = "Text to search in names"),
("type" = Option<String>, Query, description = "Comma-separated MIME type filter"),
("folder_id" = Option<String>, Query, description = "Restrict search to this folder"),
("recursive" = Option<bool>, Query, description = "Include sub-folders"),
("limit" = Option<u32>, Query, description = "Max results"),
("offset" = Option<u32>, 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<Arc<AppState>>,
auth_user: AuthUser,
query: Query<SearchParams>,
) -> 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<Arc<AppState>>,
auth_user: AuthUser,
json: Json<SearchCriteriaDto>,
) -> 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<String>, Query, description = "Restrict to this folder"),
("limit" = Option<u32>, 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<Arc<AppState>>,
query: Query<SuggestParams>,
) -> 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<Arc<AppState>>) -> impl IntoResponse {
SearchHandler::clear_search_cache_impl(state).await
}
@@ -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<Arc<AppState>>,
Path(token): Path<String>,
+135 -1
View File
@@ -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",
+63 -75
View File
@@ -25,10 +25,24 @@ async fn get_openapi_spec() -> AxumJson<utoipa::openapi::OpenApi> {
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<AppState>) -> Router<Arc<AppStat
// i18n routes — no auth required (localization should be available before login)
if let Some(i18n_service) = i18n_service {
let i18n_router = Router::new()
.route("/locales", get(I18nHandler::get_locales))
.route("/translate", get(I18nHandler::translate))
.route(
"/locales/{locale_code}",
get(I18nHandler::get_translations_by_locale),
)
.route("/locales", get(get_locales))
.route("/translate", get(translate))
.route("/locales/{locale_code}", get(get_translations_by_locale))
.with_state(i18n_service);
router = router.nest("/i18n", i18n_router);
@@ -114,37 +125,33 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// 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<AppState>) -> Router<Arc<AppState>> {
// 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<AppState>) -> Router<Arc<AppState>> {
// 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<AppState>) -> Router<Arc<AppState>> {
// 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<AppState>) -> Router<Arc<AppState>> {
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()