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 crate::domain::services::i18n_service::Locale;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// DTO for locale information /// DTO for locale information
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LocaleDto { pub struct LocaleDto {
/// Locale code (e.g., "en", "es") /// Locale code (e.g., "en", "es")
pub code: String, pub code: String,
@@ -29,7 +30,7 @@ impl From<Locale> for LocaleDto {
} }
/// DTO for translation request /// DTO for translation request
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct TranslationRequestDto { pub struct TranslationRequestDto {
/// The translation key /// The translation key
pub key: String, pub key: String,
@@ -39,7 +40,7 @@ pub struct TranslationRequestDto {
} }
/// DTO for translation response /// DTO for translation response
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct TranslationResponseDto { pub struct TranslationResponseDto {
/// The translation key /// The translation key
pub key: String, pub key: String,
@@ -52,7 +53,7 @@ pub struct TranslationResponseDto {
} }
/// DTO for translation error /// DTO for translation error
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct TranslationErrorDto { pub struct TranslationErrorDto {
/// The translation key that was not found /// The translation key that was not found
pub key: String, pub key: String,
+8 -7
View File
@@ -1,8 +1,9 @@
use crate::domain::entities::playlist::{AudioFileMetadata, Playlist, PlaylistItem}; use crate::domain::entities::playlist::{AudioFileMetadata, Playlist, PlaylistItem};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct PlaylistDto { pub struct PlaylistDto {
pub id: String, pub id: String,
pub name: 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 struct PlaylistItemDto {
pub id: String, pub id: String,
pub playlist_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 struct CreatePlaylistDto {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub is_public: Option<bool>, pub is_public: Option<bool>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdatePlaylistDto { pub struct UpdatePlaylistDto {
pub name: Option<String>, pub name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
@@ -127,17 +128,17 @@ pub struct UpdatePlaylistDto {
pub cover_file_id: Option<String>, pub cover_file_id: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AddTracksDto { pub struct AddTracksDto {
pub file_ids: Vec<String>, pub file_ids: Vec<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ReorderTracksDto { pub struct ReorderTracksDto {
pub item_ids: Vec<String>, pub item_ids: Vec<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SharePlaylistDto { pub struct SharePlaylistDto {
pub user_id: String, pub user_id: String,
pub can_write: Option<bool>, pub can_write: Option<bool>,
+10 -9
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
// ============================================================================ // ============================================================================
// OIDC Settings DTOs (Admin Panel) // OIDC Settings DTOs (Admin Panel)
@@ -24,7 +25,7 @@ pub struct OidcSettingsDto {
} }
/// Request body for saving OIDC settings from the admin panel /// Request body for saving OIDC settings from the admin panel
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SaveOidcSettingsDto { pub struct SaveOidcSettingsDto {
pub enabled: bool, pub enabled: bool,
pub issuer_url: String, pub issuer_url: String,
@@ -62,26 +63,26 @@ pub struct OidcTestResultDto {
// ============================================================================ // ============================================================================
/// Request body for updating a user's role /// Request body for updating a user's role
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateUserRoleDto { pub struct UpdateUserRoleDto {
pub role: String, pub role: String,
} }
/// Request body for updating a user's active status /// Request body for updating a user's active status
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateUserActiveDto { pub struct UpdateUserActiveDto {
pub active: bool, pub active: bool,
} }
/// Request body for updating a user's storage quota /// Request body for updating a user's storage quota
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateUserQuotaDto { pub struct UpdateUserQuotaDto {
/// Quota in bytes. Use 0 for unlimited. /// Quota in bytes. Use 0 for unlimited.
pub quota_bytes: i64, pub quota_bytes: i64,
} }
/// Request body for admin-created users /// Request body for admin-created users
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct AdminCreateUserDto { pub struct AdminCreateUserDto {
pub username: String, pub username: String,
pub password: String, pub password: String,
@@ -96,7 +97,7 @@ pub struct AdminCreateUserDto {
} }
/// Request body for admin password reset /// Request body for admin password reset
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AdminResetPasswordDto { pub struct AdminResetPasswordDto {
pub new_password: String, pub new_password: String,
} }
@@ -156,7 +157,7 @@ pub struct StorageSettingsDto {
} }
/// Request body for saving storage settings from the admin panel /// Request body for saving storage settings from the admin panel
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SaveStorageSettingsDto { pub struct SaveStorageSettingsDto {
pub backend: String, pub backend: String,
pub s3_endpoint_url: Option<String>, pub s3_endpoint_url: Option<String>,
@@ -210,14 +211,14 @@ pub struct MigrationStateDto {
} }
/// Request body for `POST /api/admin/storage/migration/start`. /// Request body for `POST /api/admin/storage/migration/start`.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct StartMigrationDto { pub struct StartMigrationDto {
/// How many blobs to copy in parallel (default: 4). /// How many blobs to copy in parallel (default: 4).
pub concurrency: Option<usize>, pub concurrency: Option<usize>,
} }
/// Request body (empty) for `POST /api/admin/storage/migration/verify`. /// Request body (empty) for `POST /api/admin/storage/migration/verify`.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct VerifyMigrationDto { pub struct VerifyMigrationDto {
/// Number of random blobs to sample-check (default: 100). /// Number of random blobs to sample-check (default: 100).
pub sample_size: Option<usize>, pub sample_size: Option<usize>,
@@ -8,6 +8,7 @@ use crate::common::errors::DomainError;
use bytes::Bytes; use bytes::Bytes;
use serde::Serialize; use serde::Serialize;
use std::path::PathBuf; use std::path::PathBuf;
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
/// Default chunk size (5 MB) — optimised for parallel transfers. /// 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; pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024;
/// Response returned when a new upload session is created. /// Response returned when a new upload session is created.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CreateUploadResponseDto { pub struct CreateUploadResponseDto {
pub upload_id: String, pub upload_id: String,
pub chunk_size: usize, pub chunk_size: usize,
@@ -26,7 +27,7 @@ pub struct CreateUploadResponseDto {
} }
/// Response returned after a single chunk is uploaded. /// Response returned after a single chunk is uploaded.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ChunkUploadResponseDto { pub struct ChunkUploadResponseDto {
pub chunk_index: usize, pub chunk_index: usize,
pub bytes_received: u64, pub bytes_received: u64,
@@ -35,7 +36,7 @@ pub struct ChunkUploadResponseDto {
} }
/// Response for querying upload session status. /// Response for querying upload session status.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct UploadStatusResponseDto { pub struct UploadStatusResponseDto {
pub upload_id: String, pub upload_id: String,
pub filename: 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 /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -120,7 +130,17 @@ async fn get_oidc_settings(
} }
/// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(dto): Json<SaveOidcSettingsDto>, Json(dto): Json<SaveOidcSettingsDto>,
@@ -170,7 +190,17 @@ async fn test_oidc_connection(
// ───────────────────────────────────────────────────── // ─────────────────────────────────────────────────────
/// GET /api/admin/settings/storage — get storage backend settings /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -190,7 +220,17 @@ async fn get_storage_settings(
} }
/// PUT /api/admin/settings/storage — save storage backend 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(dto): Json<SaveStorageSettingsDto>, Json(dto): Json<SaveStorageSettingsDto>,
@@ -240,7 +280,17 @@ async fn test_storage_connection(
// ───────────────────────────────────────────────────── // ─────────────────────────────────────────────────────
/// GET /api/admin/storage/migration — current migration progress /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -250,7 +300,18 @@ async fn get_migration_status(
} }
/// POST /api/admin/storage/migration/start — begin background migration /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(dto): Json<StartMigrationDto>, Json(dto): Json<StartMigrationDto>,
@@ -316,7 +377,18 @@ async fn start_migration(
} }
/// POST /api/admin/storage/migration/pause — pause running 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -335,7 +407,18 @@ async fn pause_migration(
} }
/// POST /api/admin/storage/migration/resume — resume paused 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -355,7 +438,18 @@ async fn resume_migration(
} }
/// POST /api/admin/storage/migration/complete — finalize 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -383,7 +477,18 @@ async fn complete_migration(
} }
/// POST /api/admin/storage/migration/verify — run integrity check /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(dto): Json<VerifyMigrationDto>, 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. /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -496,7 +611,17 @@ fn build_backend_from_config(
} }
/// GET /api/admin/settings/general — system overview (backward compat) /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -527,7 +652,17 @@ async fn get_general_settings(
// ============================================================================ // ============================================================================
/// GET /api/admin/dashboard — full dashboard statistics /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -603,7 +738,21 @@ async fn get_dashboard_stats(
// ============================================================================ // ============================================================================
/// GET /api/admin/users?limit=50&offset=0 — list all users /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>, Query(query): Query<ListUsersQueryDto>,
@@ -639,7 +788,19 @@ async fn list_users(
} }
/// GET /api/admin/users/:id — get single user /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
@@ -663,7 +824,19 @@ async fn get_user(
} }
/// DELETE /api/admin/users/:id — delete a 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
@@ -700,7 +873,19 @@ async fn delete_user(
} }
/// PUT /api/admin/users/:id/role — change user role /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
@@ -738,7 +923,19 @@ async fn update_user_role(
} }
/// PUT /api/admin/users/:id/active — activate/deactivate user /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
@@ -781,7 +978,18 @@ async fn update_user_active(
} }
/// PUT /api/admin/users/:id/quota — update user storage quota /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
@@ -815,7 +1023,18 @@ async fn update_user_quota(
// ============================================================================ // ============================================================================
/// POST /api/admin/users — create a new user (admin only) /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>, 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) /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
@@ -882,7 +1113,17 @@ async fn reset_user_password(
// ============================================================================ // ============================================================================
/// GET /api/admin/settings/registration — check if public registration is enabled /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -901,7 +1142,18 @@ async fn get_registration_setting(
} }
/// PUT /api/admin/settings/registration — enable/disable public registration /// 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>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(body): Json<serde_json::Value>, Json(body): Json<serde_json::Value>,
+117 -6
View File
@@ -5,6 +5,7 @@ use axum::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use utoipa::ToSchema;
use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::folder_dto::FolderDto;
@@ -25,7 +26,7 @@ pub struct BatchHandlerState {
} }
/// DTO for batch file operation requests /// DTO for batch file operation requests
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct BatchFileOperationRequest { pub struct BatchFileOperationRequest {
/// IDs of the files to process /// IDs of the files to process
pub file_ids: Vec<String>, pub file_ids: Vec<String>,
@@ -35,7 +36,7 @@ pub struct BatchFileOperationRequest {
} }
/// DTO for batch folder operation requests /// DTO for batch folder operation requests
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct BatchFolderOperationRequest { pub struct BatchFolderOperationRequest {
/// IDs of the folders to process /// IDs of the folders to process
pub folder_ids: Vec<String>, pub folder_ids: Vec<String>,
@@ -48,14 +49,14 @@ pub struct BatchFolderOperationRequest {
} }
/// DTO for batch folder creation requests /// DTO for batch folder creation requests
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct BatchCreateFoldersRequest { pub struct BatchCreateFoldersRequest {
/// Details of the folders to create /// Details of the folders to create
pub folders: Vec<CreateFolderDetail>, pub folders: Vec<CreateFolderDetail>,
} }
/// Detail for folder creation /// Detail for folder creation
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct CreateFolderDetail { pub struct CreateFolderDetail {
/// Folder name /// Folder name
pub name: String, pub name: String,
@@ -132,6 +133,17 @@ where
} }
/// Handler for moving multiple files in batch /// 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( pub async fn move_files_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -188,6 +200,17 @@ pub async fn move_files_batch(
} }
/// Handler for copying multiple files in 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( pub async fn copy_files_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -244,6 +267,17 @@ pub async fn copy_files_batch(
} }
/// Handler for deleting multiple files in 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( pub async fn delete_files_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -308,6 +342,17 @@ pub async fn delete_files_batch(
} }
/// Handler for deleting multiple folders in 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( pub async fn delete_folders_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -372,6 +417,17 @@ pub async fn delete_folders_batch(
} }
/// Handler for creating multiple folders in 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( pub async fn create_folders_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -435,6 +491,17 @@ pub async fn create_folders_batch(
} }
/// Handler for getting multiple files in 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( pub async fn get_files_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -491,6 +558,17 @@ pub async fn get_files_batch(
} }
/// Handler for getting multiple folders in 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( pub async fn get_folders_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -547,7 +625,7 @@ pub async fn get_folders_batch(
} }
/// DTO for batch trash operation requests /// DTO for batch trash operation requests
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct BatchTrashRequest { pub struct BatchTrashRequest {
/// IDs of the files to move to trash /// IDs of the files to move to trash
#[serde(default)] #[serde(default)]
@@ -558,7 +636,7 @@ pub struct BatchTrashRequest {
} }
/// DTO for batch download requests /// DTO for batch download requests
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct BatchDownloadRequest { pub struct BatchDownloadRequest {
/// IDs of the files to include in the ZIP /// IDs of the files to include in the ZIP
#[serde(default)] #[serde(default)]
@@ -569,6 +647,17 @@ pub struct BatchDownloadRequest {
} }
/// Handler for moving multiple files and folders to trash in batch /// 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( pub async fn trash_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -681,6 +770,17 @@ pub async fn trash_batch(
} }
/// Handler for moving multiple folders in 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( pub async fn move_folders_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, 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, /// The ZIP is written to a temporary file and streamed to the client,
/// so RAM usage is O(buffer_size) regardless of archive size. /// 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( pub async fn download_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -9,13 +9,14 @@
use axum::{ use axum::{
Json, Json,
extract::{Path, Query, State}, extract::{Path, Query, Request, State},
http::{HeaderMap, StatusCode, header}, http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use bytes::Bytes; use bytes::Bytes;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use utoipa::ToSchema;
use crate::application::ports::chunked_upload_ports::ChunkedUploadPort; use crate::application::ports::chunked_upload_ports::ChunkedUploadPort;
use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE; 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; use crate::interfaces::middleware::auth::AuthUser;
/// Request body for creating an upload session /// Request body for creating an upload session
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct CreateUploadRequest { pub struct CreateUploadRequest {
pub filename: String, pub filename: String,
pub folder_id: Option<String>, pub folder_id: Option<String>,
@@ -43,7 +44,7 @@ pub struct ChunkUploadParams {
} }
/// Final response after completing upload /// Final response after completing upload
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct CompleteUploadResponse { pub struct CompleteUploadResponse {
pub file_id: String, pub file_id: String,
pub filename: String, pub filename: String,
@@ -52,9 +53,21 @@ pub struct CompleteUploadResponse {
} }
/// Chunked Upload Handler /// 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; pub struct ChunkedUploadHandler;
impl 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 /// POST /api/uploads - Create a new upload session
/// ///
/// Request body: /// Request body:
@@ -77,7 +90,7 @@ impl ChunkedUploadHandler {
/// "expires_at": 86400 /// "expires_at": 86400
/// } /// }
/// ``` /// ```
pub async fn create_upload( pub(super) async fn create_upload_impl(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Json(request): Json<CreateUploadRequest>, Json(request): Json<CreateUploadRequest>,
@@ -171,7 +184,7 @@ impl ChunkedUploadHandler {
/// - checksum: Optional MD5 checksum for verification /// - checksum: Optional MD5 checksum for verification
/// ///
/// Body: Raw bytes of the chunk /// Body: Raw bytes of the chunk
pub async fn upload_chunk( pub(super) async fn upload_chunk_impl(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(upload_id): Path<String>, Path(upload_id): Path<String>,
@@ -220,7 +233,7 @@ impl ChunkedUploadHandler {
/// HEAD /api/uploads/:upload_id - Get upload status /// HEAD /api/uploads/:upload_id - Get upload status
/// ///
/// Returns upload progress and pending chunks /// 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>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(upload_id): Path<String>, Path(upload_id): Path<String>,
@@ -251,7 +264,7 @@ impl ChunkedUploadHandler {
/// POST /api/uploads/:upload_id/complete - Finalize upload /// POST /api/uploads/:upload_id/complete - Finalize upload
/// ///
/// Assembles all chunks into the final file and creates the file record /// 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>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(upload_id): Path<String>, Path(upload_id): Path<String>,
@@ -324,7 +337,7 @@ impl ChunkedUploadHandler {
/// DELETE /api/uploads/:upload_id - Cancel upload /// DELETE /api/uploads/:upload_id - Cancel upload
/// ///
/// Cancels an in-progress upload and cleans up temp files /// 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>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(upload_id): Path<String>, 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 serde::Serialize;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use utoipa::ToSchema;
use crate::application::ports::dedup_ports::DedupResultDto; use crate::application::ports::dedup_ports::DedupResultDto;
use crate::common::di::AppState; use crate::common::di::AppState;
@@ -16,7 +17,7 @@ use std::sync::Arc;
type GlobalState = Arc<AppState>; type GlobalState = Arc<AppState>;
/// Response for hash check endpoint /// Response for hash check endpoint
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct HashCheckResponse { pub struct HashCheckResponse {
/// Whether a blob with this hash already exists /// Whether a blob with this hash already exists
pub exists: bool, pub exists: bool,
@@ -31,7 +32,7 @@ pub struct HashCheckResponse {
} }
/// Response for upload with dedup endpoint /// Response for upload with dedup endpoint
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct DedupUploadResponse { pub struct DedupUploadResponse {
/// Whether this was a new file or an existing one /// Whether this was a new file or an existing one
pub is_new: bool, pub is_new: bool,
@@ -46,7 +47,7 @@ pub struct DedupUploadResponse {
} }
/// Response for dedup stats endpoint /// Response for dedup stats endpoint
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct StatsResponse { pub struct StatsResponse {
/// Total number of unique blobs stored /// Total number of unique blobs stored
pub unique_blobs: u64, pub unique_blobs: u64,
@@ -64,8 +65,10 @@ pub struct StatsResponse {
pub savings_percentage: f64, 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: /// Provides endpoints for:
/// - Checking if content already exists (by hash) /// - Checking if content already exists (by hash)
/// - Uploading files with automatic deduplication /// - Uploading files with automatic deduplication
@@ -73,13 +76,19 @@ pub struct StatsResponse {
pub struct DedupHandler; pub struct DedupHandler;
impl 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. /// Check if the authenticated user already has a file with the given hash.
/// ///
/// User-scoped: only reveals whether **this user** owns a file that /// User-scoped: only reveals whether **this user** owns a file that
/// references the blob — never exposes global existence or ref_count. /// references the blob — never exposes global existence or ref_count.
/// ///
/// GET /api/dedup/check/{hash} /// GET /api/dedup/check/{hash}
pub async fn check_hash( pub(super) async fn check_hash_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(hash): Path<String>, Path(hash): Path<String>,
@@ -142,7 +151,7 @@ impl DedupHandler {
/// the pre-computed hash so the file is never re-read for hashing. /// the pre-computed hash so the file is never re-read for hashing.
/// ///
/// POST /api/dedup/upload /// POST /api/dedup/upload
pub async fn upload_with_dedup( pub(super) async fn upload_with_dedup_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
_auth_user: AuthUser, _auth_user: AuthUser,
mut multipart: Multipart, mut multipart: Multipart,
@@ -300,7 +309,7 @@ impl DedupHandler {
/// - Total references /// - Total references
/// - Bytes saved /// - Bytes saved
/// - Deduplication ratio /// - Deduplication ratio
pub async fn get_stats( pub(super) async fn get_stats_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -349,7 +358,7 @@ impl DedupHandler {
/// Returns the raw content of a blob **only if** the authenticated user /// Returns the raw content of a blob **only if** the authenticated user
/// owns at least one file that references it. Returns 404 otherwise /// owns at least one file that references it. Returns 404 otherwise
/// (does not reveal whether the blob exists globally). /// (does not reveal whether the blob exists globally).
pub async fn get_blob( pub(super) async fn get_blob_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(hash): Path<String>, Path(hash): Path<String>,
@@ -423,7 +432,7 @@ impl DedupHandler {
/// ///
/// Verifies integrity and returns current statistics. /// Verifies integrity and returns current statistics.
/// Useful for health checks and auditing. /// Useful for health checks and auditing.
pub async fn recalculate_stats( pub(super) async fn recalculate_stats_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
) -> impl IntoResponse { ) -> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+223 -13
View File
@@ -11,6 +11,7 @@ use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::OptimizedFileContent;
use crate::application::ports::file_ports::{ use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
@@ -39,6 +40,13 @@ type GlobalState = Arc<AppState>;
pub struct FileHandler; pub struct FileHandler;
impl 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 // UPLOAD
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -312,7 +320,7 @@ impl FileHandler {
/// The DB path is only taken on a **cache miss for images** where the /// 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 /// thumbnail hasn't been generated yet (first access after upload if
/// background generation hasn't finished). /// background generation hasn't finished).
pub async fn get_thumbnail( pub(super) async fn get_thumbnail_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
headers: HeaderMap, headers: HeaderMap,
@@ -462,7 +470,7 @@ impl FileHandler {
/// subsequent `GET …/thumbnail/{size}` requests are served instantly. /// subsequent `GET …/thumbnail/{size}` requests are served instantly.
/// ///
/// **Max body: 512 KB** — thumbnails are small. /// **Max body: 512 KB** — thumbnails are small.
pub async fn upload_thumbnail( pub(super) async fn upload_thumbnail_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
Path((id, size)): Path<(String, String)>, Path((id, size)): Path<(String, String)>,
@@ -527,7 +535,7 @@ impl FileHandler {
/// streaming) is fully handled by `FileRetrievalUseCase::get_file_optimized`. /// streaming) is fully handled by `FileRetrievalUseCase::get_file_optimized`.
/// This handler only deals with HTTP concerns: ETag, Range, Content-Disposition, /// This handler only deals with HTTP concerns: ETag, Range, Content-Disposition,
/// and optional compression. /// and optional compression.
pub async fn download_file( pub(super) async fn download_file_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -694,10 +702,8 @@ impl FileHandler {
// LIST // LIST
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/// Lists files, extracting `folder_id` from query parameters. /// Lists files in a folder, extracting `folder_id` from query parameters.
/// pub(super) async fn list_files_query_impl(
/// Axum-compatible handler wrapper around [`Self::list_files`].
pub async fn list_files_query(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
headers: HeaderMap, headers: HeaderMap,
@@ -745,7 +751,7 @@ impl FileHandler {
/// Delegates to [`Self::upload_file_inner`] and, on success, spawns /// Delegates to [`Self::upload_file_inner`] and, on success, spawns
/// a background task to generate all thumbnail sizes before serialising /// a background task to generate all thumbnail sizes before serialising
/// the `FileDto` once. /// the `FileDto` once.
pub async fn upload_file_with_thumbnails( pub(super) async fn upload_file_with_thumbnails_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
multipart: Multipart, multipart: Multipart,
@@ -813,7 +819,7 @@ impl FileHandler {
/// Returns EXIF/media metadata for a file. /// Returns EXIF/media metadata for a file.
/// ///
/// Used by the Photos lightbox and for testing EXIF extraction. /// 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>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(file_id): Path<String>, Path(file_id): Path<String>,
@@ -859,7 +865,7 @@ impl FileHandler {
/// ///
/// When auth is available, uses trash-first deletion; otherwise falls back /// When auth is available, uses trash-first deletion; otherwise falls back
/// to permanent delete so the endpoint works with or without auth. /// 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>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -889,7 +895,7 @@ impl FileHandler {
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/// Renames a file (ownership-verified) /// Renames a file (ownership-verified)
pub async fn rename_file( pub(super) async fn rename_file_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -936,8 +942,8 @@ impl FileHandler {
} }
} }
/// Moves a file to a different folder (simplified payload, ownership-verified) /// Moves a file to a different folder (ownership-verified)
pub async fn move_file_simple( pub(super) async fn move_file_simple_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -1066,3 +1072,207 @@ pub struct MoveFilePayload {
/// Target folder ID (None means root) /// Target folder ID (None means root)
pub folder_id: Option<String>, 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 std::sync::Arc;
use tokio_util::io::ReaderStream; 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::folder_listing_dto::FolderListingDto;
use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::dtos::pagination::PaginationRequestDto;
use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::file_ports::FileRetrievalUseCase;
@@ -27,10 +29,17 @@ type AppState = Arc<FolderService>;
pub struct FolderHandler; pub struct FolderHandler;
impl 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. /// Creates a new folder.
/// When parent_id is not provided, the folder is created inside the /// When parent_id is not provided, the folder is created inside the
/// authenticated user's home folder rather than at the storage root. /// 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>, State(service): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
Json(mut dto): Json<CreateFolderDto>, Json(mut dto): Json<CreateFolderDto>,
@@ -93,7 +102,7 @@ impl FolderHandler {
/// Gets a folder by ID. /// Gets a folder by ID.
/// Validates that the authenticated user owns the folder. /// Validates that the authenticated user owns the folder.
pub async fn get_folder( pub(super) async fn get_folder_impl(
State(service): State<AppState>, State(service): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -120,7 +129,7 @@ impl FolderHandler {
/// Lists root folders for the authenticated user. /// Lists root folders for the authenticated user.
/// Only returns folders owned by this user — no information disclosure. /// 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>, State(service): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
) -> axum::response::Response { ) -> axum::response::Response {
@@ -129,7 +138,7 @@ impl FolderHandler {
/// Lists contents of a specific folder by its ID. /// Lists contents of a specific folder by its ID.
/// Scoped to the authenticated user's folders. /// 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>, State(service): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -137,8 +146,9 @@ impl FolderHandler {
Self::list_folders_scoped(service, Some(&id), &auth_user).await Self::list_folders_scoped(service, Some(&id), &auth_user).await
} }
/// Lists root folders with pagination support. /// Lists root folders with pagination.
pub async fn list_root_folders_paginated( /// 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>, State(service): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
_pagination: Query<PaginationRequestDto>, _pagination: Query<PaginationRequestDto>,
@@ -146,9 +156,8 @@ impl FolderHandler {
Self::list_folders_scoped(service, None, &auth_user).await Self::list_folders_scoped(service, None, &auth_user).await
} }
/// Lists contents of a specific folder with pagination. /// Lists sub-folders inside a folder with pagination.
/// Scoped to the authenticated user — only returns folders owned by this user. pub(super) async fn list_folder_contents_paginated_impl(
pub async fn list_folder_contents_paginated(
State(service): State<AppState>, State(service): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -204,7 +213,7 @@ impl FolderHandler {
/// ///
/// Both queries run concurrently via `tokio::join!`. /// Both queries run concurrently via `tokio::join!`.
/// Supports `If-None-Match` / ETag for conditional responses (304). /// 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>>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
headers: HeaderMap, headers: HeaderMap,
@@ -246,8 +255,8 @@ impl FolderHandler {
} }
} }
/// Renames a folder (ownership enforced by service layer) /// Renames a folder (ownership enforced).
pub async fn rename_folder( pub(super) async fn rename_folder_impl(
State(service): State<AppState>, State(service): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -259,8 +268,8 @@ impl FolderHandler {
} }
} }
/// Moves a folder to a new parent (ownership enforced by service layer) /// Moves a folder to a new parent (ownership enforced).
pub async fn move_folder( pub(super) async fn move_folder_impl(
State(service): State<AppState>, State(service): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -284,8 +293,8 @@ impl FolderHandler {
} }
} }
/// Deletes a folder with trash functionality (ownership enforced by service layer) /// Deletes a folder (moves to trash if enabled, otherwise permanent).
pub async fn delete_folder_with_trash( pub(super) async fn delete_folder_with_trash_impl(
State(state): State<Arc<GlobalAppState>>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
@@ -322,8 +331,8 @@ impl FolderHandler {
} }
} }
/// Downloads a folder as a ZIP file (ownership enforced) /// Downloads a folder and all its contents as a ZIP archive.
pub async fn download_folder_zip( pub(super) async fn download_folder_zip_impl(
State(state): State<Arc<GlobalAppState>>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, 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; pub struct I18nHandler;
impl I18nHandler { impl I18nHandler {
/// Gets a list of available locales // ── Why no #[utoipa::path] here? ─────────────────────────────────────────────
pub async fn get_locales(State(service): State<AppState>) -> impl IntoResponse { // 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 locales = service.available_locales().await;
let locale_dtos: Vec<LocaleDto> = locales.into_iter().map(LocaleDto::from).collect(); let locale_dtos: Vec<LocaleDto> = locales.into_iter().map(LocaleDto::from).collect();
(StatusCode::OK, Json(locale_dtos)).into_response() (StatusCode::OK, Json(locale_dtos)).into_response()
} }
/// Translates a key to the requested locale /// Translates a single key to the requested locale.
pub async fn translate( pub(super) async fn translate_impl(
State(service): State<AppState>, State(service): State<AppState>,
Query(query): Query<TranslationRequestDto>, Query(query): Query<TranslationRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -79,8 +84,8 @@ impl I18nHandler {
} }
} }
/// Gets all translations for a locale (Axum-compatible: extracts locale from path) /// Returns all translations for a locale as a flat key→value object.
pub async fn get_translations_by_locale( pub(super) async fn get_translations_by_locale_impl(
State(service): State<AppState>, State(service): State<AppState>,
Path(locale_code): Path<String>, Path(locale_code): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -116,3 +121,68 @@ impl I18nHandler {
.into_response() .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>, 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( pub async fn create_playlist(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn get_playlist(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn list_playlists(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -62,6 +92,17 @@ pub struct IncludeSharedQuery {
pub include_public: Option<bool>, 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( pub async fn update_playlist(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn delete_playlist(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn add_tracks(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn remove_track(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn reorder_tracks(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn list_playlist_tracks(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn share_playlist(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn remove_share(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn get_playlist_shares(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, 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( pub async fn get_audio_metadata(
State(music_service): State<Arc<MusicService>>, State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -26,6 +26,20 @@ pub struct PhotosQueryParams {
/// ///
/// Supports cursor-based pagination via the `before` parameter. /// Supports cursor-based pagination via the `before` parameter.
/// The `X-Next-Cursor` response header contains the cursor for the next page. /// 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( pub async fn list_photos(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
+109 -8
View File
@@ -6,7 +6,9 @@ use axum::{
use serde_json::json; use serde_json::json;
use tracing::{error, info}; 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::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
@@ -22,8 +24,13 @@ use std::sync::Arc;
pub struct SearchHandler; pub struct SearchHandler;
impl SearchHandler { impl SearchHandler {
/// GET /search — simple query-parameter-based search. // ── Why no #[utoipa::path] here? ─────────────────────────────────────────────
pub async fn search_files_get( // 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>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Query(params): Query<SearchParams>, Query(params): Query<SearchParams>,
@@ -81,8 +88,8 @@ impl SearchHandler {
} }
} }
/// POST /search/advanced — full criteria in the request body. /// Advanced search with full criteria in the request body.
pub async fn search_files_post( pub(super) async fn search_files_post_impl(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Json(criteria): Json<SearchCriteriaDto>, Json(criteria): Json<SearchCriteriaDto>,
@@ -122,8 +129,8 @@ impl SearchHandler {
} }
} }
/// GET /search/suggest — lightweight autocomplete suggestions. /// Autocomplete suggestions for search.
pub async fn suggest_files( pub(super) async fn suggest_files_impl(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(params): Query<SuggestParams>, Query(params): Query<SuggestParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -167,7 +174,9 @@ impl SearchHandler {
} }
/// DELETE /search/cache — clears the search results cache. /// 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"); info!("API: Clearing search cache");
let search_service = match &state.applications.search_service { let search_service = match &state.applications.search_service {
@@ -259,3 +268,95 @@ pub struct SuggestParams {
/// Maximum number of suggestions (default 10, max 20) /// Maximum number of suggestions (default 10, max 20)
pub limit: Option<usize>, 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), /// Validates the share token, checks it refers to a file (not folder),
/// then streams the file content to the caller. /// 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( pub async fn download_shared_file(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(token): Path<String>, Path(token): Path<String>,
+135 -1
View File
@@ -15,6 +15,9 @@ use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
}; };
use crate::application::dtos::folder_listing_dto::FolderListingDto; 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::pagination::{PaginationDto, PaginationRequestDto};
use crate::application::dtos::recent_dto::RecentItemDto; use crate::application::dtos::recent_dto::RecentItemDto;
use crate::application::dtos::search_dto::{ use crate::application::dtos::search_dto::{
@@ -31,17 +34,72 @@ use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, SetupAdminDto, AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, SetupAdminDto,
UserDto, 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; use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( 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::get_trash_items,
handlers::trash_handler::move_file_to_trash, handlers::trash_handler::move_file_to_trash,
handlers::trash_handler::move_folder_to_trash, handlers::trash_handler::move_folder_to_trash,
handlers::trash_handler::restore_from_trash, handlers::trash_handler::restore_from_trash,
handlers::trash_handler::delete_permanently, handlers::trash_handler::delete_permanently,
handlers::trash_handler::empty_trash, handlers::trash_handler::empty_trash,
// Share handlers (free functions)
handlers::share_handler::create_shared_link, handlers::share_handler::create_shared_link,
handlers::share_handler::get_shared_link, handlers::share_handler::get_shared_link,
handlers::share_handler::get_user_shares, 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::delete_shared_link,
handlers::share_handler::access_shared_item, handlers::share_handler::access_shared_item,
handlers::share_handler::verify_shared_item_password, 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::get_favorites,
handlers::favorites_handler::add_favorite, handlers::favorites_handler::add_favorite,
handlers::favorites_handler::remove_favorite, handlers::favorites_handler::remove_favorite,
handlers::favorites_handler::batch_add_favorites, handlers::favorites_handler::batch_add_favorites,
// Recent handlers (free functions)
handlers::recent_handler::get_recent_items, handlers::recent_handler::get_recent_items,
handlers::recent_handler::record_item_access, handlers::recent_handler::record_item_access,
handlers::recent_handler::remove_from_recent, handlers::recent_handler::remove_from_recent,
handlers::recent_handler::clear_recent_items, 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( components(
schemas( schemas(
@@ -102,16 +214,38 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
BatchFavoritesStats, BatchFavoritesStats,
// Recent schemas // Recent schemas
RecentItemDto, RecentItemDto,
// i18n schemas
LocaleDto,
TranslationRequestDto,
TranslationResponseDto,
TranslationErrorDto,
// Chunked upload schemas
CreateUploadRequest,
CompleteUploadResponse,
CreateUploadResponseDto,
ChunkUploadResponseDto,
UploadStatusResponseDto,
// Dedup schemas
HashCheckResponse,
DedupUploadResponse,
StatsResponse,
) )
), ),
tags( tags(
(name = "folders", description = "Folder management endpoints"),
(name = "files", description = "File management endpoints"), (name = "files", description = "File management endpoints"),
(name = "folders", description = "Folder management endpoints"),
(name = "trash", description = "Trash / recycle bin endpoints"), (name = "trash", description = "Trash / recycle bin endpoints"),
(name = "search", description = "Search endpoints"), (name = "search", description = "Search endpoints"),
(name = "shares", description = "Shared links endpoints"), (name = "shares", description = "Shared links endpoints"),
(name = "favorites", description = "Favorites management endpoints"), (name = "favorites", description = "Favorites management endpoints"),
(name = "recent", description = "Recent items 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( info(
title = "OxiCloud API", 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::admin_handler;
use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState}; use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState};
use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; use crate::interfaces::api::handlers::chunked_upload_handler::{
use crate::interfaces::api::handlers::file_handler::FileHandler; cancel_upload, complete_upload, create_upload, get_upload_status, upload_chunk,
use crate::interfaces::api::handlers::folder_handler::FolderHandler; };
use crate::interfaces::api::handlers::i18n_handler::I18nHandler; 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; use crate::interfaces::api::handlers::trash_handler;
/// Creates public API routes that should NOT require authentication. /// 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) // i18n routes — no auth required (localization should be available before login)
if let Some(i18n_service) = i18n_service { if let Some(i18n_service) = i18n_service {
let i18n_router = Router::new() let i18n_router = Router::new()
.route("/locales", get(I18nHandler::get_locales)) .route("/locales", get(get_locales))
.route("/translate", get(I18nHandler::translate)) .route("/translate", get(translate))
.route( .route("/locales/{locale_code}", get(get_translations_by_locale))
"/locales/{locale_code}",
get(I18nHandler::get_translations_by_locale),
)
.with_state(i18n_service); .with_state(i18n_service);
router = router.nest("/i18n", i18n_router); 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 // Create the basic folders router with service operations
let folders_basic_router = Router::new() let folders_basic_router = Router::new()
.route("/", post(FolderHandler::create_folder)) .route("/", post(create_folder))
.route("/", get(FolderHandler::list_root_folders)) .route("/", get(list_root_folders))
.route( .route("/paginated", get(list_root_folders_paginated))
"/paginated", .route("/{id}", get(get_folder))
get(FolderHandler::list_root_folders_paginated), .route("/{id}/contents", get(list_folder_contents))
)
.route("/{id}", get(FolderHandler::get_folder))
.route("/{id}/contents", get(FolderHandler::list_folder_contents))
.route( .route(
"/{id}/contents/paginated", "/{id}/contents/paginated",
get(FolderHandler::list_folder_contents_paginated), get(list_folder_contents_paginated),
) )
.route("/{id}/rename", put(FolderHandler::rename_folder)) .route("/{id}/rename", put(rename_folder))
.route("/{id}/move", put(FolderHandler::move_folder)) .route("/{id}/move", put(move_folder))
.with_state(folder_service.clone()); .with_state(folder_service.clone());
// Special route for ZIP download that requires AppState instead of just FolderService // Special route for ZIP download that requires AppState instead of just FolderService
let folder_zip_router = Router::new() 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()); .with_state(app_state.clone());
// Combined listing endpoint: returns both sub-folders AND files in one // Combined listing endpoint: returns both sub-folders AND files in one
// response. Needs full AppState because it calls both FolderService // response. Needs full AppState because it calls both FolderService
// and FileRetrievalService concurrently. // and FileRetrievalService concurrently.
let folder_listing_router = Router::new() 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()); .with_state(app_state.clone());
// Create folder operations that use trash (requires full AppState) // Create folder operations that use trash (requires full AppState)
let folders_ops_router = let folders_ops_router = Router::new().route("/{id}", delete(delete_folder_with_trash));
Router::new().route("/{id}", delete(FolderHandler::delete_folder_with_trash));
// Merge the routers // Merge the routers
let folders_router = folders_basic_router 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 // Create file routes for basic operations and trash-enabled delete
let basic_file_router = Router::new() let basic_file_router = Router::new()
.route("/", get(FileHandler::list_files_query)) .route("/", get(list_files_query))
.route("/upload", post(FileHandler::upload_file_with_thumbnails)) .route("/upload", post(upload_file_with_thumbnails))
.route("/{id}", get(FileHandler::download_file)) .route("/{id}", get(download_file))
.route( .route(
"/{id}/thumbnail/{size}", "/{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({ .layer(DefaultBodyLimit::max({
// Use architecture-appropriate body limit: 10 GB on 64-bit, 1 GB on 32-bit // Use architecture-appropriate body limit: 10 GB on 64-bit, 1 GB on 32-bit
#[cfg(target_pointer_width = "64")] #[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 // File operations with trash support
let file_operations_router = Router::new() let file_operations_router = Router::new()
.route("/{id}", delete(FileHandler::delete_file)) .route("/{id}", delete(delete_file))
.route("/{id}/move", put(FileHandler::move_file_simple)) .route("/{id}/move", put(move_file_simple))
.route("/{id}/rename", put(FileHandler::rename_file)); .route("/{id}/rename", put(rename_file));
// Merge the routers // Merge the routers
let files_router = basic_file_router.merge(file_operations_router); 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 // Create search routes if the service is available
let search_router = if search_service.is_some() { let search_router = if search_service.is_some() {
use crate::interfaces::api::handlers::search_handler::SearchHandler;
Router::new() Router::new()
// Simple search with query parameters // Simple search with query parameters
.route("/", get(SearchHandler::search_files_get)) .route("/", get(search_files_get))
// Lightweight autocomplete suggestions // Lightweight autocomplete suggestions
.route("/suggest", get(SearchHandler::suggest_files)) .route("/suggest", get(suggest_files))
// Advanced search with full criteria object // Advanced search with full criteria object
.route("/advanced", post(SearchHandler::search_files_post)) .route("/advanced", post(search_files_post))
// Clear search cache // Clear search cache
.route("/cache", delete(SearchHandler::clear_search_cache)) .route("/cache", delete(clear_search_cache))
.with_state(app_state.clone()) .with_state(app_state.clone())
} else { } else {
Router::new() Router::new()
@@ -275,49 +280,32 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
Router::new() 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() let chunked_upload_router = Router::new()
.route("/", post(ChunkedUploadHandler::create_upload)) .route("/", post(create_upload))
.route( .route("/{upload_id}", axum::routing::patch(upload_chunk))
"/{upload_id}", .route("/{upload_id}", axum::routing::head(get_upload_status))
axum::routing::patch(ChunkedUploadHandler::upload_chunk), .route("/{upload_id}/complete", post(complete_upload))
) .route("/{upload_id}", delete(cancel_upload))
.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))
.with_state(app_state.clone()); .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() let dedup_router = Router::new()
.route( .route("/check/{hash}", get(check_hash))
"/check/{hash}", .route("/upload", post(upload_with_dedup))
get(super::handlers::dedup_handler::DedupHandler::check_hash), .route("/stats", get(get_stats))
) .route("/blob/{hash}", get(get_blob))
.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),
)
// NOTE: remove_reference is intentionally NOT exposed as a public // NOTE: remove_reference is intentionally NOT exposed as a public
// endpoint — ref_count management is an internal concern handled // endpoint — ref_count management is an internal concern handled
// automatically when files are deleted via the file API. // automatically when files are deleted via the file API.
.route( .route("/recalculate", post(recalculate_stats))
"/recalculate",
post(super::handlers::dedup_handler::DedupHandler::recalculate_stats),
)
.with_state(app_state.clone()); .with_state(app_state.clone());
let mut router = Router::new() let mut router = Router::new()