diff --git a/.gitignore b/.gitignore index be2f265e..1467894e 100755 --- a/.gitignore +++ b/.gitignore @@ -78,5 +78,8 @@ storage/ *.swo nohup.out +# Generated files (OpenAPI spec, etc.) +resources/gen/ + # Helm chart dependencies charts/*/charts/* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e571eb10..111dd6e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,15 +8,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co cargo build # Dev build cargo build --release # Optimized release build cargo run # Run server (port 8086) -cargo test --workspace # Run all tests (~112) +cargo test --workspace # Run all tests (~208) cargo test # Run a single test by name cargo test --features test_utils # Run tests that use mockall mocks cargo clippy -- -D warnings # Lint (zero warnings policy) cargo fmt --all --check # Format check cargo fmt --all # Auto-format RUST_LOG=debug cargo run # Run with debug logging +cargo run --bin generate-openapi # Regenerate resources/gen/openapi.json ``` +A `justfile` is available for common tasks (`just --list` to see all). Key recipes: `just check` (fmt + clippy), `just test`, `just openapi`. + + Requires **Rust 1.93+** (edition 2024) and **PostgreSQL 13+** (with `pg_trgm` and `ltree` extensions). Database setup: `docker compose up -d postgres` — schema is applied automatically via sqlx migrations on app startup. Migration files live in `migrations/`. For local dev, set `DATABASE_URL` in `.env` (see `example.env`). diff --git a/Cargo.lock b/Cargo.lock index 4f25576e..a4f15c0c 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2527,7 +2527,7 @@ dependencies = [ [[package]] name = "oxicloud" -version = "0.5.2" +version = "0.5.3" dependencies = [ "argon2", "async-compression", @@ -2582,6 +2582,7 @@ dependencies = [ "tracing", "tracing-subscriber", "urlencoding", + "utoipa", "uuid", ] @@ -3202,6 +3203,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -4351,6 +4364,31 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "uuid", +] + [[package]] name = "uuid" version = "1.21.0" diff --git a/Cargo.toml b/Cargo.toml index 8c3c5d96..a09fd340 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ async_zip = { version = "0.0.18", features = ["tokio", "deflate"] } dashmap = "6" socket2 = { version = "0.6.2", features = ["all"] } urlencoding = "2.1.3" +utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } [features] default = [] @@ -63,6 +64,10 @@ integration_tests = [] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } +[[bin]] +name = "generate-openapi" +path = "src/bin/generate-openapi.rs" + [build-dependencies] oxc_allocator = "0.116" oxc_parser = "0.116" diff --git a/justfile b/justfile new file mode 100644 index 00000000..b65785a2 --- /dev/null +++ b/justfile @@ -0,0 +1,47 @@ +set dotenv-load + +default: + @just --list + +build: + cargo build + +release: + cargo build --release + +run: + cargo run + +run-debug: + RUST_LOG=debug cargo run + +test: + cargo test --workspace + +test-mocks: + cargo test --features test_utils + +test-one name: + cargo test {{name}} + +fmt: + cargo fmt --all + +fmt-check: + cargo fmt --all --check + +lint: + cargo clippy -- -D warnings + +check: + cargo fmt --all + cargo clippy -- -D warnings + +openapi: + cargo run --bin generate-openapi + +db: + docker compose up -d postgres + +db-down: + docker compose down diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index d665bbc0..21a473d5 100755 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use super::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, @@ -7,7 +8,7 @@ use super::display_helpers::{ /// DTO for favorites item, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct FavoriteItemDto { /// Unique identifier for the favorite entry pub id: String, @@ -84,7 +85,7 @@ impl FavoriteItemDto { } /// Result DTO for batch add-to-favorites. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BatchFavoritesResult { /// Statistics about the batch operation pub stats: BatchFavoritesStats, @@ -93,7 +94,7 @@ pub struct BatchFavoritesResult { pub favorites: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BatchFavoritesStats { /// How many items were requested pub requested: usize, diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index 99ed00bb..6676ea34 100755 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -2,13 +2,14 @@ use std::sync::Arc; use crate::domain::entities::file::File; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use super::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, }; /// DTO for file responses -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct FileDto { /// File ID pub id: String, @@ -24,6 +25,7 @@ pub struct FileDto { /// MIME type — `Arc` because MIME values repeat across files /// and DTOs are cloned on every request (clone is O(1) atomic increment). + #[schema(value_type = String)] pub mime_type: Arc, /// Parent folder ID @@ -37,12 +39,15 @@ pub struct FileDto { // ── Pre-computed display fields (Arc: values come from static tables) ── /// FontAwesome icon CSS class (e.g. "fas fa-file-image") + #[schema(value_type = String)] pub icon_class: Arc, /// Extra CSS class for icon styling (e.g. "image-icon", "" when default) + #[schema(value_type = String)] pub icon_special_class: Arc, /// Human-readable file category (e.g. "Image", "Document") + #[schema(value_type = String)] pub category: Arc, /// Human-readable formatted size (e.g. "3.27 MB") diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 0b1f64f4..cedf2289 100755 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -2,9 +2,10 @@ use std::sync::Arc; use crate::domain::entities::folder::Folder; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; /// DTO for folder creation requests -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct CreateFolderDto { /// Name of the folder to create pub name: String, @@ -14,21 +15,21 @@ pub struct CreateFolderDto { } /// DTO for folder rename requests -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct RenameFolderDto { /// New name for the folder pub name: String, } /// DTO for folder move requests -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct MoveFolderDto { /// New parent folder ID (None for root level) pub parent_id: Option, } /// DTO for folder responses -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct FolderDto { /// Folder ID pub id: String, @@ -57,12 +58,15 @@ pub struct FolderDto { // ── Pre-computed display fields (Arc: always identical values) ── /// FontAwesome icon CSS class (always "fas fa-folder") + #[schema(value_type = String)] pub icon_class: Arc, /// Extra CSS class for icon styling (always "folder-icon") + #[schema(value_type = String)] pub icon_special_class: Arc, /// Human-readable category (always "Folder") + #[schema(value_type = String)] pub category: Arc, } diff --git a/src/application/dtos/folder_listing_dto.rs b/src/application/dtos/folder_listing_dto.rs index 0bdebacb..bdfc242f 100755 --- a/src/application/dtos/folder_listing_dto.rs +++ b/src/application/dtos/folder_listing_dto.rs @@ -1,11 +1,12 @@ use serde::Serialize; +use utoipa::ToSchema; use super::file_dto::FileDto; use super::folder_dto::FolderDto; /// Combined DTO that returns both sub-folders and files for a given folder /// in a single response, eliminating the double-fetch on every navigation. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct FolderListingDto { /// Sub-folders inside the requested folder pub folders: Vec, diff --git a/src/application/dtos/pagination.rs b/src/application/dtos/pagination.rs index 802dfe06..087ac20a 100755 --- a/src/application/dtos/pagination.rs +++ b/src/application/dtos/pagination.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; /// A DTO to represent pagination information -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PaginationDto { /// Current page (starts at 0) pub page: usize, @@ -18,7 +19,7 @@ pub struct PaginationDto { } /// A DTO to represent a pagination request -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] pub struct PaginationRequestDto { /// Requested page (starts at 0) #[serde(default)] @@ -29,7 +30,7 @@ pub struct PaginationRequestDto { } /// A DTO to represent a paginated response -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PaginatedResponseDto { /// Data on the current page pub items: Vec, diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 4b6f00f4..85dec184 100755 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use super::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, @@ -7,7 +8,7 @@ use super::display_helpers::{ /// DTO for recent items, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct RecentItemDto { /// Unique identifier for the recent item pub id: String, diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs index 3c5a8f0f..24f4af9b 100755 --- a/src/application/dtos/search_dto.rs +++ b/src/application/dtos/search_dto.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; /** * Data Transfer Object for file search criteria. @@ -7,7 +8,7 @@ use serde::{Deserialize, Serialize}; * to filter files and folders in the system. It supports various filter types * including name matching, file types, date ranges, and size constraints. */ -#[derive(Debug, Clone, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Hash, Serialize, Deserialize, ToSchema)] pub struct SearchCriteriaDto { /// Optional text to search in file/folder names #[serde(skip_serializing_if = "Option::is_none")] @@ -98,7 +99,7 @@ impl Default for SearchCriteriaDto { } /// A file search result enriched with server-computed metadata -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SearchFileResultDto { /// File ID pub id: String, @@ -129,7 +130,7 @@ pub struct SearchFileResultDto { } /// A folder search result enriched with server-computed metadata -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SearchFolderResultDto { /// Folder ID pub id: String, @@ -156,7 +157,7 @@ pub struct SearchFolderResultDto { * both files and folders that match the search criteria, along with pagination * information and server-computed metadata. */ -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SearchResultsDto { /// Files matching the search criteria (enriched with metadata) pub files: Vec, @@ -227,7 +228,7 @@ impl SearchResultsDto { } /// DTO for search suggestion results (quick prefix search) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SearchSuggestionsDto { /// Suggested file/folder names matching the query prefix pub suggestions: Vec, @@ -236,7 +237,7 @@ pub struct SearchSuggestionsDto { } /// Individual search suggestion item -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SearchSuggestionItem { /// The suggested name pub name: String, diff --git a/src/application/dtos/share_dto.rs b/src/application/dtos/share_dto.rs index cc454152..a08b3e4b 100755 --- a/src/application/dtos/share_dto.rs +++ b/src/application/dtos/share_dto.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::domain::entities::share::{Share, SharePermissions}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ShareDto { pub id: String, pub item_id: String, @@ -18,14 +19,14 @@ pub struct ShareDto { pub access_count: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SharePermissionsDto { pub read: bool, pub write: bool, pub reshare: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateShareDto { pub item_id: String, pub item_name: Option, @@ -35,7 +36,7 @@ pub struct CreateShareDto { pub permissions: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateShareDto { pub password: Option, pub expires_at: Option, diff --git a/src/application/dtos/trash_dto.rs b/src/application/dtos/trash_dto.rs index 19b5e1c6..e16ff4c7 100755 --- a/src/application/dtos/trash_dto.rs +++ b/src/application/dtos/trash_dto.rs @@ -1,8 +1,9 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; /// DTO representing an item in the trash -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct TrashedItemDto { pub id: String, pub original_id: String, @@ -20,20 +21,20 @@ pub struct TrashedItemDto { } /// Request to move an item to trash -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct MoveToTrashRequest { pub item_id: String, pub item_type: String, // "file" o "folder" } /// Request to restore an item from trash -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct RestoreFromTrashRequest { pub trash_id: String, } /// Request to permanently delete an item from trash -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct DeletePermanentlyRequest { pub trash_id: String, } diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 06fd605b..3512e28a 100755 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -1,9 +1,10 @@ use crate::domain::entities::user::User; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserDto { pub id: String, pub username: String, @@ -36,13 +37,13 @@ impl From for UserDto { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct LoginDto { pub username: String, pub password: String, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct RegisterDto { pub username: String, pub email: String, @@ -51,14 +52,14 @@ pub struct RegisterDto { /// DTO for the one-time initial admin setup endpoint (`/api/setup`). /// Available only when the system is not yet initialized (no admin exists). -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct SetupAdminDto { pub username: String, pub email: String, pub password: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AuthResponseDto { pub user: UserDto, pub access_token: String, @@ -67,19 +68,19 @@ pub struct AuthResponseDto { pub expires_in: i64, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ChangePasswordDto { pub current_password: String, pub new_password: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct RefreshTokenDto { pub refresh_token: String, } /// Authenticated current user data (for use in application services) -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct CurrentUser { pub id: Uuid, pub username: String, @@ -91,19 +92,19 @@ pub struct CurrentUser { // App Password DTOs // ============================================================================ -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateAppPasswordDto { pub label: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AppPasswordCreatedDto { pub id: String, pub label: String, pub password: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AppPasswordDto { pub id: String, pub label: String, @@ -116,27 +117,27 @@ pub struct AppPasswordDto { // ============================================================================ /// Response with the OIDC authorization URL for client redirect -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct OidcAuthorizeResponseDto { pub authorize_url: String, pub state: String, } /// Query parameters received on the OIDC callback -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct OidcCallbackQueryDto { pub code: String, pub state: String, } /// Request body for the OIDC one-time code exchange endpoint -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct OidcExchangeDto { pub code: String, } /// Information about available OIDC providers -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct OidcProviderInfoDto { pub enabled: bool, pub provider_name: String, @@ -145,7 +146,7 @@ pub struct OidcProviderInfoDto { } /// Claims extracted from the validated OIDC ID token -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct OidcUserInfoDto { pub sub: String, pub preferred_username: Option, diff --git a/src/bin/generate-openapi.rs b/src/bin/generate-openapi.rs new file mode 100644 index 00000000..72e73ba4 --- /dev/null +++ b/src/bin/generate-openapi.rs @@ -0,0 +1,24 @@ +use oxicloud::interfaces::api::ApiDoc; +use std::fs; +use std::path::PathBuf; +use utoipa::OpenApi; + +fn main() { + let openapi = ApiDoc::openapi(); + + let json = + serde_json::to_string_pretty(&openapi).expect("Failed to serialize OpenAPI spec to JSON"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + let resources_gen_dir = manifest_dir.join("resources").join("gen"); + fs::create_dir_all(&resources_gen_dir).expect("Failed to create resources/gen directory"); + + let output_path = resources_gen_dir.join("openapi.json"); + fs::write(&output_path, json).expect("Failed to write OpenAPI spec to file"); + + println!( + "OpenAPI spec generated successfully at: {}", + output_path.display() + ); +} diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 551b6753..6dbaf0c5 100755 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -7,25 +7,34 @@ use axum::{ use serde::Deserialize; use std::sync::Arc; use tracing::{error, info}; +use utoipa::ToSchema; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::services::favorites_service::FavoritesService; use crate::interfaces::middleware::auth::AuthUser; /// Single item in a batch-add-favorites request. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct BatchFavoriteItem { pub item_id: String, pub item_type: String, } /// Request body for POST /api/favorites/batch -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct BatchFavoritesRequest { pub items: Vec, } /// Handler for favorite-related API endpoints +#[utoipa::path( + get, + path = "/api/favorites", + responses( + (status = 200, description = "List of favorites", body = Vec) + ), + tag = "favorites" +)] pub async fn get_favorites( State(favorites_service): State>, auth_user: AuthUser, @@ -51,6 +60,19 @@ pub async fn get_favorites( } /// Add an item to user's favorites +#[utoipa::path( + post, + path = "/api/favorites/{item_type}/{item_id}", + params( + ("item_type" = String, Path, description = "Item type (file or folder)"), + ("item_id" = String, Path, description = "Item ID") + ), + responses( + (status = 201, description = "Item added to favorites"), + (status = 400, description = "Invalid item type") + ), + tag = "favorites" +)] pub async fn add_favorite( State(favorites_service): State>, auth_user: AuthUser, @@ -94,6 +116,19 @@ pub async fn add_favorite( } /// Remove an item from user's favorites +#[utoipa::path( + delete, + path = "/api/favorites/{item_type}/{item_id}", + params( + ("item_type" = String, Path, description = "Item type (file or folder)"), + ("item_id" = String, Path, description = "Item ID") + ), + responses( + (status = 200, description = "Item removed from favorites"), + (status = 404, description = "Item not in favorites") + ), + tag = "favorites" +)] pub async fn remove_favorite( State(favorites_service): State>, auth_user: AuthUser, @@ -138,6 +173,15 @@ pub async fn remove_favorite( /// Add multiple items to favourites in a single transaction. /// POST /api/favorites/batch +#[utoipa::path( + post, + path = "/api/favorites/batch", + responses( + (status = 200, description = "Batch add result", body = crate::application::dtos::favorites_dto::BatchFavoritesResult), + (status = 400, description = "Invalid request") + ), + tag = "favorites" +)] pub async fn batch_add_favorites( State(favorites_service): State>, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index cc010bbc..658f526f 100755 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -9,6 +9,7 @@ use bytes::Bytes; use http_range_header::parse_range_header; use serde::Deserialize; use std::collections::HashMap; +use utoipa::ToSchema; use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::{ @@ -1005,7 +1006,7 @@ impl FileHandler { } /// Payload for moving a file -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct MoveFilePayload { /// Target folder ID (None means root) pub folder_id: Option, diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index b6bfd41f..08e41e37 100755 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -20,6 +20,14 @@ pub struct GetRecentParams { } /// Get user's recent items +#[utoipa::path( + get, + path = "/api/recent", + responses( + (status = 200, description = "List of recent items", body = Vec) + ), + tag = "recent" +)] pub async fn get_recent_items( State(recent_service): State>, auth_user: AuthUser, @@ -46,6 +54,19 @@ pub async fn get_recent_items( } /// Record access to an item +#[utoipa::path( + post, + path = "/api/recent/{item_type}/{item_id}", + params( + ("item_type" = String, Path, description = "Item type (file or folder)"), + ("item_id" = String, Path, description = "Item ID") + ), + responses( + (status = 200, description = "Access recorded"), + (status = 400, description = "Invalid item type") + ), + tag = "recent" +)] pub async fn record_item_access( State(recent_service): State>, auth_user: AuthUser, @@ -92,6 +113,19 @@ pub async fn record_item_access( } /// Remove an item from recents +#[utoipa::path( + delete, + path = "/api/recent/{item_type}/{item_id}", + params( + ("item_type" = String, Path, description = "Item type (file or folder)"), + ("item_id" = String, Path, description = "Item ID") + ), + responses( + (status = 200, description = "Item removed from recents"), + (status = 404, description = "Item not in recents") + ), + tag = "recent" +)] pub async fn remove_from_recent( State(recent_service): State>, auth_user: AuthUser, @@ -138,6 +172,14 @@ pub async fn remove_from_recent( } /// Clear all recent items +#[utoipa::path( + delete, + path = "/api/recent/clear", + responses( + (status = 200, description = "Recent items cleared") + ), + tag = "recent" +)] pub async fn clear_recent_items( State(recent_service): State>, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index 3c0853d2..1ecb92e1 100755 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -9,6 +9,7 @@ use axum::{ }; use serde::Deserialize; use serde_json::json; +use utoipa::ToSchema; use crate::application::services::share_service::ShareService; use crate::{ @@ -30,12 +31,22 @@ pub struct GetSharesQuery { pub item_type: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct VerifyPasswordRequest { pub password: String, } /// Create a new shared link +#[utoipa::path( + post, + path = "/api/shares", + request_body = CreateShareDto, + responses( + (status = 201, description = "Share created", body = crate::application::dtos::share_dto::ShareDto), + (status = 400, description = "Bad request") + ), + tag = "shares" +)] pub async fn create_shared_link( State(share_use_case): State>, auth_user: AuthUser, @@ -48,6 +59,16 @@ pub async fn create_shared_link( } /// Get information about a specific shared link by ID +#[utoipa::path( + get, + path = "/api/shares/{id}", + params(("id" = String, Path, description = "Share ID")), + responses( + (status = 200, description = "Share details", body = crate::application::dtos::share_dto::ShareDto), + (status = 404, description = "Share not found") + ), + tag = "shares" +)] pub async fn get_shared_link( State(share_use_case): State>, auth_user: AuthUser, @@ -65,6 +86,14 @@ pub async fn get_shared_link( /// Get all shared links created by the current user. /// Supports optional filtering by item_id + item_type query params. +#[utoipa::path( + get, + path = "/api/shares", + responses( + (status = 200, description = "List of shares", body = Vec) + ), + tag = "shares" +)] pub async fn get_user_shares( State(share_use_case): State>, auth_user: AuthUser, @@ -107,6 +136,17 @@ pub async fn get_user_shares( } /// Update a shared link's properties +#[utoipa::path( + put, + path = "/api/shares/{id}", + params(("id" = String, Path, description = "Share ID")), + request_body = UpdateShareDto, + responses( + (status = 200, description = "Share updated", body = crate::application::dtos::share_dto::ShareDto), + (status = 404, description = "Share not found") + ), + tag = "shares" +)] pub async fn update_shared_link( State(share_use_case): State>, auth_user: AuthUser, @@ -127,6 +167,16 @@ pub async fn update_shared_link( } /// Delete a shared link +#[utoipa::path( + delete, + path = "/api/shares/{id}", + params(("id" = String, Path, description = "Share ID")), + responses( + (status = 204, description = "Share deleted"), + (status = 404, description = "Share not found") + ), + tag = "shares" +)] pub async fn delete_shared_link( State(share_use_case): State>, auth_user: AuthUser, @@ -143,6 +193,17 @@ pub async fn delete_shared_link( } /// Access a shared item via its token +#[utoipa::path( + get, + path = "/api/s/{token}", + params(("token" = String, Path, description = "Share token")), + responses( + (status = 200, description = "Shared item details"), + (status = 401, description = "Password required"), + (status = 410, description = "Share expired") + ), + tag = "shares" +)] pub async fn access_shared_item( State(share_use_case): State>, Path(token): Path, @@ -176,6 +237,17 @@ pub async fn access_shared_item( } /// Verify password for a password-protected shared item +#[utoipa::path( + post, + path = "/api/s/{token}/verify", + params(("token" = String, Path, description = "Share token")), + responses( + (status = 200, description = "Password verified, item details returned"), + (status = 401, description = "Invalid password"), + (status = 410, description = "Share expired") + ), + tag = "shares" +)] pub async fn verify_shared_item_password( State(share_use_case): State>, Path(token): Path, diff --git a/src/interfaces/api/handlers/trash_handler.rs b/src/interfaces/api/handlers/trash_handler.rs index 3bd02b56..22d8f2fa 100755 --- a/src/interfaces/api/handlers/trash_handler.rs +++ b/src/interfaces/api/handlers/trash_handler.rs @@ -10,6 +10,15 @@ use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; /// Gets all items in the trash for the current user +#[utoipa::path( + get, + path = "/api/trash", + responses( + (status = 200, description = "List of trashed items"), + (status = 501, description = "Trash feature not enabled") + ), + tag = "trash" +)] #[instrument(skip_all)] pub async fn get_trash_items( State(state): State>, @@ -54,6 +63,16 @@ pub async fn get_trash_items( } /// Moves a file to the trash +#[utoipa::path( + delete, + path = "/api/trash/files/{id}", + params(("id" = String, Path, description = "File ID")), + responses( + (status = 200, description = "File moved to trash"), + (status = 501, description = "Trash feature not enabled") + ), + tag = "trash" +)] #[instrument(skip_all)] pub async fn move_file_to_trash( State(state): State>, @@ -105,6 +124,16 @@ pub async fn move_file_to_trash( } /// Moves a folder to the trash +#[utoipa::path( + delete, + path = "/api/trash/folders/{id}", + params(("id" = String, Path, description = "Folder ID")), + responses( + (status = 200, description = "Folder moved to trash"), + (status = 501, description = "Trash feature not enabled") + ), + tag = "trash" +)] #[instrument(skip_all)] pub async fn move_folder_to_trash( State(state): State>, @@ -158,6 +187,16 @@ pub async fn move_folder_to_trash( } /// Restores an item from the trash to its original location +#[utoipa::path( + post, + path = "/api/trash/{id}/restore", + params(("id" = String, Path, description = "Trash item ID")), + responses( + (status = 200, description = "Item restored from trash"), + (status = 501, description = "Trash feature not enabled") + ), + tag = "trash" +)] #[instrument(skip_all)] pub async fn restore_from_trash( State(state): State>, @@ -219,6 +258,16 @@ pub async fn restore_from_trash( } /// Permanently deletes an item from the trash +#[utoipa::path( + delete, + path = "/api/trash/{id}", + params(("id" = String, Path, description = "Trash item ID")), + responses( + (status = 200, description = "Item permanently deleted"), + (status = 501, description = "Trash feature not enabled") + ), + tag = "trash" +)] #[instrument(skip_all)] pub async fn delete_permanently( State(state): State>, @@ -282,6 +331,15 @@ pub async fn delete_permanently( } /// Empties the trash completely for the current user +#[utoipa::path( + delete, + path = "/api/trash/empty", + responses( + (status = 200, description = "Trash emptied successfully"), + (status = 501, description = "Trash feature not enabled") + ), + tag = "trash" +)] #[instrument(skip_all)] pub async fn empty_trash( State(state): State>, diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index f026147d..f53dde99 100755 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -4,3 +4,176 @@ pub mod routes; pub use routes::create_api_routes; pub use routes::create_public_api_routes; + +use utoipa::OpenApi; + +use crate::application::dtos::favorites_dto::{ + BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, +}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::{ + CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, +}; +use crate::application::dtos::folder_listing_dto::FolderListingDto; +use crate::application::dtos::pagination::{PaginationDto, PaginationRequestDto}; +use crate::application::dtos::recent_dto::RecentItemDto; +use crate::application::dtos::search_dto::{ + SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto, + SearchSuggestionItem, SearchSuggestionsDto, +}; +use crate::application::dtos::share_dto::{ + CreateShareDto, ShareDto, SharePermissionsDto, UpdateShareDto, +}; +use crate::application::dtos::trash_dto::{ + DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto, +}; +use crate::application::dtos::user_dto::{ + AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, SetupAdminDto, + UserDto, +}; +use crate::interfaces::api::handlers::file_handler::MoveFilePayload; + +#[derive(OpenApi)] +#[openapi( + paths( + handlers::trash_handler::get_trash_items, + handlers::trash_handler::move_file_to_trash, + handlers::trash_handler::move_folder_to_trash, + handlers::trash_handler::restore_from_trash, + handlers::trash_handler::delete_permanently, + handlers::trash_handler::empty_trash, + handlers::share_handler::create_shared_link, + handlers::share_handler::get_shared_link, + handlers::share_handler::get_user_shares, + handlers::share_handler::update_shared_link, + handlers::share_handler::delete_shared_link, + handlers::share_handler::access_shared_item, + handlers::share_handler::verify_shared_item_password, + handlers::favorites_handler::get_favorites, + handlers::favorites_handler::add_favorite, + handlers::favorites_handler::remove_favorite, + handlers::favorites_handler::batch_add_favorites, + handlers::recent_handler::get_recent_items, + handlers::recent_handler::record_item_access, + handlers::recent_handler::remove_from_recent, + handlers::recent_handler::clear_recent_items, + ), + components( + schemas( + // Folder schemas + FolderDto, + CreateFolderDto, + RenameFolderDto, + MoveFolderDto, + FolderListingDto, + // File schemas + FileDto, + MoveFilePayload, + PaginationDto, + PaginationRequestDto, + // User / Auth schemas + UserDto, + LoginDto, + RegisterDto, + SetupAdminDto, + AuthResponseDto, + ChangePasswordDto, + RefreshTokenDto, + // Share schemas + ShareDto, + SharePermissionsDto, + CreateShareDto, + UpdateShareDto, + // Trash schemas + TrashedItemDto, + MoveToTrashRequest, + RestoreFromTrashRequest, + DeletePermanentlyRequest, + // Search schemas + SearchCriteriaDto, + SearchResultsDto, + SearchFileResultDto, + SearchFolderResultDto, + SearchSuggestionsDto, + SearchSuggestionItem, + // Favorites schemas + FavoriteItemDto, + BatchFavoritesResult, + BatchFavoritesStats, + // Recent schemas + RecentItemDto, + ) + ), + tags( + (name = "folders", description = "Folder management endpoints"), + (name = "files", description = "File management endpoints"), + (name = "trash", description = "Trash / recycle bin endpoints"), + (name = "search", description = "Search endpoints"), + (name = "shares", description = "Shared links endpoints"), + (name = "favorites", description = "Favorites management endpoints"), + (name = "recent", description = "Recent items endpoints"), + ), + info( + title = "OxiCloud API", + version = env!("CARGO_PKG_VERSION"), + description = "REST API for OxiCloud — self-hosted cloud storage, calendar & contacts", + license(name = "MIT") + ) +)] +pub struct ApiDoc; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn openapi_spec_is_valid_and_has_expected_structure() { + let spec = ApiDoc::openapi(); + + assert_eq!(spec.info.title, "OxiCloud API"); + assert!(!spec.info.version.is_empty()); + + let paths = &spec.paths; + assert!( + paths.paths.len() >= 10, + "expected at least 10 paths, got {}", + paths.paths.len() + ); + assert!(paths.paths.contains_key("/api/trash"), "missing /api/trash"); + assert!( + paths.paths.contains_key("/api/shares"), + "missing /api/shares" + ); + assert!( + paths.paths.contains_key("/api/favorites"), + "missing /api/favorites" + ); + assert!( + paths.paths.contains_key("/api/recent"), + "missing /api/recent" + ); + + let schemas = &spec + .components + .as_ref() + .expect("components missing") + .schemas; + assert!( + schemas.len() >= 25, + "expected at least 25 schemas, got {}", + schemas.len() + ); + for name in [ + "FileDto", + "FolderDto", + "ShareDto", + "TrashedItemDto", + "UserDto", + ] { + assert!(schemas.contains_key(name), "missing schema: {name}"); + } + + let json = serde_json::to_string(&spec).expect("spec should serialise to JSON"); + assert!(json.len() > 1000, "spec JSON suspiciously small"); + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 76eda01a..f6edf151 100755 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -9,6 +9,7 @@ use axum::{ use serde_json::json; use std::sync::Arc; use tower_http::{compression::CompressionLayer, trace::TraceLayer}; +use utoipa::OpenApi; /// Returns the application version from Cargo.toml (compile-time constant) async fn get_version() -> AxumJson { @@ -18,6 +19,10 @@ async fn get_version() -> AxumJson { })) } +async fn get_openapi_spec() -> AxumJson { + AxumJson(super::ApiDoc::openapi()) +} + use crate::interfaces::api::handlers::admin_handler; use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState}; use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; @@ -64,6 +69,7 @@ pub fn create_public_api_routes(app_state: &Arc) -> Router