From bf7e030cd6d16bc5d53c87f412114ae441c7b295 Mon Sep 17 00:00:00 2001 From: iltumio Date: Sun, 29 Mar 2026 18:49:10 +0200 Subject: [PATCH 1/2] feat: add OpenAPI spec generation with utoipa and justfile - Add utoipa v5 dependency with ToSchema derives on all REST API DTOs - Annotate free-function handlers with #[utoipa::path] (trash, share, favorites, recent) - Create ApiDoc struct with OpenApi derive registering 37 schemas across 7 tags - Add generate-openapi binary outputting resources/gen/openapi.json - Serve OpenAPI spec at GET /api/openapi.json (public, no auth) - Add justfile with common dev commands (build, test, lint, check, openapi, db) --- Cargo.lock | 40 +- Cargo.toml | 5 + justfile | 47 + resources/gen/openapi.json | 2020 +++++++++++++++++ src/application/dtos/favorites_dto.rs | 7 +- src/application/dtos/file_dto.rs | 7 +- src/application/dtos/folder_dto.rs | 12 +- src/application/dtos/folder_listing_dto.rs | 3 +- src/application/dtos/pagination.rs | 7 +- src/application/dtos/recent_dto.rs | 3 +- src/application/dtos/search_dto.rs | 13 +- src/application/dtos/share_dto.rs | 9 +- src/application/dtos/trash_dto.rs | 9 +- src/application/dtos/user_dto.rs | 33 +- src/bin/generate-openapi.rs | 24 + .../api/handlers/favorites_handler.rs | 48 +- src/interfaces/api/handlers/file_handler.rs | 3 +- src/interfaces/api/handlers/recent_handler.rs | 42 + src/interfaces/api/handlers/share_handler.rs | 74 +- src/interfaces/api/handlers/trash_handler.rs | 58 + src/interfaces/api/mod.rs | 115 + src/interfaces/api/routes.rs | 7 +- 22 files changed, 2537 insertions(+), 49 deletions(-) create mode 100644 justfile create mode 100644 resources/gen/openapi.json create mode 100644 src/bin/generate-openapi.rs 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/resources/gen/openapi.json b/resources/gen/openapi.json new file mode 100644 index 00000000..91bd568b --- /dev/null +++ b/resources/gen/openapi.json @@ -0,0 +1,2020 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OxiCloud API", + "description": "REST API for OxiCloud — self-hosted cloud storage, calendar & contacts", + "license": { + "name": "MIT" + }, + "version": "0.5.3" + }, + "paths": { + "/api/favorites": { + "get": { + "tags": [ + "favorites" + ], + "summary": "Handler for favorite-related API endpoints", + "operationId": "get_favorites", + "responses": { + "200": { + "description": "List of favorites", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FavoriteItemDto" + } + } + } + } + } + } + } + }, + "/api/favorites/batch": { + "post": { + "tags": [ + "favorites" + ], + "summary": "Add multiple items to favourites in a single transaction.\nPOST /api/favorites/batch", + "operationId": "batch_add_favorites", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchFavoritesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Batch add result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchFavoritesResult" + } + } + } + }, + "400": { + "description": "Invalid request" + } + } + } + }, + "/api/favorites/{item_type}/{item_id}": { + "post": { + "tags": [ + "favorites" + ], + "summary": "Add an item to user's favorites", + "operationId": "add_favorite", + "parameters": [ + { + "name": "item_type", + "in": "path", + "description": "Item type (file or folder)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "item_id", + "in": "path", + "description": "Item ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Item added to favorites" + }, + "400": { + "description": "Invalid item type" + } + } + }, + "delete": { + "tags": [ + "favorites" + ], + "summary": "Remove an item from user's favorites", + "operationId": "remove_favorite", + "parameters": [ + { + "name": "item_type", + "in": "path", + "description": "Item type (file or folder)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "item_id", + "in": "path", + "description": "Item ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Item removed from favorites" + }, + "404": { + "description": "Item not in favorites" + } + } + } + }, + "/api/recent": { + "get": { + "tags": [ + "recent" + ], + "summary": "Get user's recent items", + "operationId": "get_recent_items", + "responses": { + "200": { + "description": "List of recent items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecentItemDto" + } + } + } + } + } + } + } + }, + "/api/recent/clear": { + "delete": { + "tags": [ + "recent" + ], + "summary": "Clear all recent items", + "operationId": "clear_recent_items", + "responses": { + "200": { + "description": "Recent items cleared" + } + } + } + }, + "/api/recent/{item_type}/{item_id}": { + "post": { + "tags": [ + "recent" + ], + "summary": "Record access to an item", + "operationId": "record_item_access", + "parameters": [ + { + "name": "item_type", + "in": "path", + "description": "Item type (file or folder)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "item_id", + "in": "path", + "description": "Item ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Access recorded" + }, + "400": { + "description": "Invalid item type" + } + } + }, + "delete": { + "tags": [ + "recent" + ], + "summary": "Remove an item from recents", + "operationId": "remove_from_recent", + "parameters": [ + { + "name": "item_type", + "in": "path", + "description": "Item type (file or folder)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "item_id", + "in": "path", + "description": "Item ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Item removed from recents" + }, + "404": { + "description": "Item not in recents" + } + } + } + }, + "/api/s/{token}": { + "get": { + "tags": [ + "shares" + ], + "summary": "Access a shared item via its token", + "operationId": "access_shared_item", + "parameters": [ + { + "name": "token", + "in": "path", + "description": "Share token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Shared item details" + }, + "401": { + "description": "Password required" + }, + "410": { + "description": "Share expired" + } + } + } + }, + "/api/s/{token}/verify": { + "post": { + "tags": [ + "shares" + ], + "summary": "Verify password for a password-protected shared item", + "operationId": "verify_shared_item_password", + "parameters": [ + { + "name": "token", + "in": "path", + "description": "Share token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password verified, item details returned" + }, + "401": { + "description": "Invalid password" + }, + "410": { + "description": "Share expired" + } + } + } + }, + "/api/shares": { + "get": { + "tags": [ + "shares" + ], + "summary": "Get all shared links created by the current user.\nSupports optional filtering by item_id + item_type query params.", + "operationId": "get_user_shares", + "responses": { + "200": { + "description": "List of shares", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShareDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "shares" + ], + "summary": "Create a new shared link", + "operationId": "create_shared_link", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateShareDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Share created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareDto" + } + } + } + }, + "400": { + "description": "Bad request" + } + } + } + }, + "/api/shares/{id}": { + "get": { + "tags": [ + "shares" + ], + "summary": "Get information about a specific shared link by ID", + "operationId": "get_shared_link", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Share ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Share details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareDto" + } + } + } + }, + "404": { + "description": "Share not found" + } + } + }, + "put": { + "tags": [ + "shares" + ], + "summary": "Update a shared link's properties", + "operationId": "update_shared_link", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Share ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateShareDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Share updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareDto" + } + } + } + }, + "404": { + "description": "Share not found" + } + } + }, + "delete": { + "tags": [ + "shares" + ], + "summary": "Delete a shared link", + "operationId": "delete_shared_link", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Share ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Share deleted" + }, + "404": { + "description": "Share not found" + } + } + } + }, + "/api/trash": { + "get": { + "tags": [ + "trash" + ], + "summary": "Gets all items in the trash for the current user", + "operationId": "get_trash_items", + "responses": { + "200": { + "description": "List of trashed items" + }, + "501": { + "description": "Trash feature not enabled" + } + } + } + }, + "/api/trash/empty": { + "delete": { + "tags": [ + "trash" + ], + "summary": "Empties the trash completely for the current user", + "operationId": "empty_trash", + "responses": { + "200": { + "description": "Trash emptied successfully" + }, + "501": { + "description": "Trash feature not enabled" + } + } + } + }, + "/api/trash/files/{id}": { + "delete": { + "tags": [ + "trash" + ], + "summary": "Moves a file to the trash", + "operationId": "move_file_to_trash", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "File ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File moved to trash" + }, + "501": { + "description": "Trash feature not enabled" + } + } + } + }, + "/api/trash/folders/{id}": { + "delete": { + "tags": [ + "trash" + ], + "summary": "Moves a folder to the trash", + "operationId": "move_folder_to_trash", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Folder ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Folder moved to trash" + }, + "501": { + "description": "Trash feature not enabled" + } + } + } + }, + "/api/trash/{id}": { + "delete": { + "tags": [ + "trash" + ], + "summary": "Permanently deletes an item from the trash", + "operationId": "delete_permanently", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Trash item ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Item permanently deleted" + }, + "501": { + "description": "Trash feature not enabled" + } + } + } + }, + "/api/trash/{id}/restore": { + "post": { + "tags": [ + "trash" + ], + "summary": "Restores an item from the trash to its original location", + "operationId": "restore_from_trash", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Trash item ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Item restored from trash" + }, + "501": { + "description": "Trash feature not enabled" + } + } + } + } + }, + "components": { + "schemas": { + "AuthResponseDto": { + "type": "object", + "required": [ + "user", + "access_token", + "refresh_token", + "token_type", + "expires_in" + ], + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "format": "int64" + }, + "refresh_token": { + "type": "string" + }, + "token_type": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "BatchFavoriteItem": { + "type": "object", + "description": "Single item in a batch-add-favorites request.", + "required": [ + "item_id", + "item_type" + ], + "properties": { + "item_id": { + "type": "string" + }, + "item_type": { + "type": "string" + } + } + }, + "BatchFavoritesRequest": { + "type": "object", + "description": "Request body for POST /api/favorites/batch", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchFavoriteItem" + } + } + } + }, + "BatchFavoritesResult": { + "type": "object", + "description": "Result DTO for batch add-to-favorites.", + "required": [ + "stats", + "favorites" + ], + "properties": { + "favorites": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FavoriteItemDto" + }, + "description": "Full list of the user's favourites (enriched), so the client can\nreplace its local cache in a single round-trip." + }, + "stats": { + "$ref": "#/components/schemas/BatchFavoritesStats", + "description": "Statistics about the batch operation" + } + } + }, + "BatchFavoritesStats": { + "type": "object", + "required": [ + "requested", + "inserted", + "already_existed" + ], + "properties": { + "already_existed": { + "type": "integer", + "format": "int64", + "description": "How many were already favourites (skipped)", + "minimum": 0 + }, + "inserted": { + "type": "integer", + "format": "int64", + "description": "How many were actually inserted (new)", + "minimum": 0 + }, + "requested": { + "type": "integer", + "description": "How many items were requested", + "minimum": 0 + } + } + }, + "ChangePasswordDto": { + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string" + }, + "new_password": { + "type": "string" + } + } + }, + "CreateFolderDto": { + "type": "object", + "description": "DTO for folder creation requests", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the folder to create" + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID (None for root level)" + } + } + }, + "CreateShareDto": { + "type": "object", + "required": [ + "item_id", + "item_type" + ], + "properties": { + "expires_at": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "item_id": { + "type": "string" + }, + "item_name": { + "type": [ + "string", + "null" + ] + }, + "item_type": { + "type": "string" + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "permissions": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SharePermissionsDto" + } + ] + } + } + }, + "DeletePermanentlyRequest": { + "type": "object", + "description": "Request to permanently delete an item from trash", + "required": [ + "trash_id" + ], + "properties": { + "trash_id": { + "type": "string" + } + } + }, + "FavoriteItemDto": { + "type": "object", + "description": "DTO for favorites item, enriched with item metadata via SQL JOIN\nso the frontend does not need N+1 requests to resolve names/sizes.", + "required": [ + "id", + "user_id", + "item_id", + "item_type", + "created_at", + "icon_class", + "icon_special_class", + "category", + "size_formatted" + ], + "properties": { + "category": { + "type": "string", + "description": "Human-readable category (e.g. \"Image\", \"Folder\")" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the item was added to favorites" + }, + "icon_class": { + "type": "string", + "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\", \"fas fa-folder\")" + }, + "icon_special_class": { + "type": "string", + "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"folder-icon\")" + }, + "id": { + "type": "string", + "description": "Unique identifier for the favorite entry" + }, + "item_id": { + "type": "string", + "description": "ID of the favorited item (file or folder)" + }, + "item_mime_type": { + "type": [ + "string", + "null" + ], + "description": "MIME type (files only)" + }, + "item_name": { + "type": [ + "string", + "null" + ], + "description": "Display name of the file or folder" + }, + "item_size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Size in bytes (files only; folders → None)" + }, + "item_type": { + "type": "string", + "description": "Type of the item ('file' or 'folder')" + }, + "modified_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Last modification timestamp of the item" + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID (folder_id for files, parent_id for folders)" + }, + "size_formatted": { + "type": "string", + "description": "Formatted file size (e.g. \"3.27 MB\"); \"--\" for folders" + }, + "user_id": { + "type": "string", + "description": "User ID who owns this favorite" + } + } + }, + "FileDto": { + "type": "object", + "description": "DTO for file responses", + "required": [ + "id", + "name", + "path", + "size", + "mime_type", + "created_at", + "modified_at", + "icon_class", + "icon_special_class", + "category", + "size_formatted" + ], + "properties": { + "category": { + "type": "string", + "description": "Human-readable file category (e.g. \"Image\", \"Document\")" + }, + "created_at": { + "type": "integer", + "format": "int64", + "description": "Creation timestamp", + "minimum": 0 + }, + "folder_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID" + }, + "icon_class": { + "type": "string", + "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\")" + }, + "icon_special_class": { + "type": "string", + "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"\" when default)" + }, + "id": { + "type": "string", + "description": "File ID" + }, + "mime_type": { + "type": "string", + "description": "MIME type — `Arc` because MIME values repeat across files\nand DTOs are cloned on every request (clone is O(1) atomic increment)." + }, + "modified_at": { + "type": "integer", + "format": "int64", + "description": "Last modification timestamp", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "File name" + }, + "owner_id": { + "type": [ + "string", + "null" + ], + "description": "Owner user ID (omitted from JSON when None)" + }, + "path": { + "type": "string", + "description": "Path to the file (relative)" + }, + "size": { + "type": "integer", + "format": "int64", + "description": "Size in bytes", + "minimum": 0 + }, + "size_formatted": { + "type": "string", + "description": "Human-readable formatted size (e.g. \"3.27 MB\")" + }, + "sort_date": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Sort date for Photos timeline — COALESCE(EXIF captured_at, created_at).\nOnly populated by the /api/photos endpoint.", + "minimum": 0 + } + } + }, + "FolderDto": { + "type": "object", + "description": "DTO for folder responses", + "required": [ + "id", + "name", + "path", + "created_at", + "modified_at", + "is_root", + "icon_class", + "icon_special_class", + "category" + ], + "properties": { + "category": { + "type": "string", + "description": "Human-readable category (always \"Folder\")" + }, + "created_at": { + "type": "integer", + "format": "int64", + "description": "Creation timestamp", + "minimum": 0 + }, + "icon_class": { + "type": "string", + "description": "FontAwesome icon CSS class (always \"fas fa-folder\")" + }, + "icon_special_class": { + "type": "string", + "description": "Extra CSS class for icon styling (always \"folder-icon\")" + }, + "id": { + "type": "string", + "description": "Folder ID" + }, + "is_root": { + "type": "boolean", + "description": "Whether this is a root folder" + }, + "modified_at": { + "type": "integer", + "format": "int64", + "description": "Last modification timestamp", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "Folder name" + }, + "owner_id": { + "type": [ + "string", + "null" + ], + "description": "Owner user ID (scopes visibility per user)" + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID" + }, + "path": { + "type": "string", + "description": "Path to the folder (relative)" + } + } + }, + "FolderListingDto": { + "type": "object", + "description": "Combined DTO that returns both sub-folders and files for a given folder\nin a single response, eliminating the double-fetch on every navigation.", + "required": [ + "folders", + "files" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDto" + }, + "description": "Files inside the requested folder" + }, + "folders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FolderDto" + }, + "description": "Sub-folders inside the requested folder" + } + } + }, + "LoginDto": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "MoveFilePayload": { + "type": "object", + "description": "Payload for moving a file", + "properties": { + "folder_id": { + "type": [ + "string", + "null" + ], + "description": "Target folder ID (None means root)" + } + } + }, + "MoveFolderDto": { + "type": "object", + "description": "DTO for folder move requests", + "properties": { + "parent_id": { + "type": [ + "string", + "null" + ], + "description": "New parent folder ID (None for root level)" + } + } + }, + "MoveToTrashRequest": { + "type": "object", + "description": "Request to move an item to trash", + "required": [ + "item_id", + "item_type" + ], + "properties": { + "item_id": { + "type": "string" + }, + "item_type": { + "type": "string" + } + } + }, + "PaginationDto": { + "type": "object", + "description": "A DTO to represent pagination information", + "required": [ + "page", + "page_size", + "total_items", + "total_pages", + "has_next", + "has_prev" + ], + "properties": { + "has_next": { + "type": "boolean", + "description": "Indicates if there is a next page" + }, + "has_prev": { + "type": "boolean", + "description": "Indicates if there is a previous page" + }, + "page": { + "type": "integer", + "description": "Current page (starts at 0)", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Page size", + "minimum": 0 + }, + "total_items": { + "type": "integer", + "description": "Total number of items", + "minimum": 0 + }, + "total_pages": { + "type": "integer", + "description": "Total number of pages", + "minimum": 0 + } + } + }, + "PaginationRequestDto": { + "type": "object", + "description": "A DTO to represent a pagination request", + "properties": { + "page": { + "type": "integer", + "description": "Requested page (starts at 0)", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Requested page size", + "minimum": 0 + } + } + }, + "RecentItemDto": { + "type": "object", + "description": "DTO for recent items, enriched with item metadata via SQL JOIN\nso the frontend does not need N+1 requests to resolve names/sizes.", + "required": [ + "id", + "user_id", + "item_id", + "item_type", + "accessed_at", + "icon_class", + "icon_special_class", + "category", + "size_formatted" + ], + "properties": { + "accessed_at": { + "type": "string", + "format": "date-time", + "description": "When the item was accessed" + }, + "category": { + "type": "string", + "description": "Human-readable category (e.g. \"Image\", \"Folder\")" + }, + "icon_class": { + "type": "string", + "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\", \"fas fa-folder\")" + }, + "icon_special_class": { + "type": "string", + "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"folder-icon\")" + }, + "id": { + "type": "string", + "description": "Unique identifier for the recent item" + }, + "item_id": { + "type": "string", + "description": "Item ID (file or folder)" + }, + "item_mime_type": { + "type": [ + "string", + "null" + ], + "description": "MIME type (files only)" + }, + "item_name": { + "type": [ + "string", + "null" + ], + "description": "Display name of the file or folder" + }, + "item_size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Size in bytes (files only; folders → None)" + }, + "item_type": { + "type": "string", + "description": "Item type ('file' or 'folder')" + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID (folder_id for files, parent_id for folders)" + }, + "size_formatted": { + "type": "string", + "description": "Formatted file size (e.g. \"3.27 MB\"); \"--\" for folders" + }, + "user_id": { + "type": "string", + "description": "Owner user ID" + } + } + }, + "RefreshTokenDto": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "RegisterDto": { + "type": "object", + "required": [ + "username", + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "RenameFolderDto": { + "type": "object", + "description": "DTO for folder rename requests", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "New name for the folder" + } + } + }, + "RestoreFromTrashRequest": { + "type": "object", + "description": "Request to restore an item from trash", + "required": [ + "trash_id" + ], + "properties": { + "trash_id": { + "type": "string" + } + } + }, + "SearchCriteriaDto": { + "type": "object", + "description": "\n * Data Transfer Object for file search criteria.\n *\n * This structure represents all possible search parameters that can be used\n * to filter files and folders in the system. It supports various filter types\n * including name matching, file types, date ranges, and size constraints.", + "properties": { + "created_after": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Optional minimum creation date (seconds since epoch)", + "minimum": 0 + }, + "created_before": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Optional maximum creation date (seconds since epoch)", + "minimum": 0 + }, + "file_types": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Optional list of file extensions to include (e.g., \"pdf\", \"jpg\")" + }, + "folder_id": { + "type": [ + "string", + "null" + ], + "description": "Optional folder ID to limit search scope" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return", + "minimum": 0 + }, + "max_size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Optional maximum file size in bytes", + "minimum": 0 + }, + "min_size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Optional minimum file size in bytes", + "minimum": 0 + }, + "modified_after": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Optional minimum modification date (seconds since epoch)", + "minimum": 0 + }, + "modified_before": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Optional maximum modification date (seconds since epoch)", + "minimum": 0 + }, + "name_contains": { + "type": [ + "string", + "null" + ], + "description": "Optional text to search in file/folder names" + }, + "offset": { + "type": "integer", + "description": "Offset for pagination", + "minimum": 0 + }, + "recursive": { + "type": "boolean", + "description": "Whether to search recursively within subfolders (default: true)" + }, + "sort_by": { + "type": "string", + "description": "Sort order for results: \"relevance\", \"name\", \"name_desc\", \"date\", \"date_desc\", \"size\", \"size_desc\"" + } + } + }, + "SearchFileResultDto": { + "type": "object", + "description": "A file search result enriched with server-computed metadata", + "required": [ + "id", + "name", + "path", + "size", + "mime_type", + "created_at", + "modified_at", + "relevance_score", + "size_formatted", + "icon_class", + "icon_special_class", + "category" + ], + "properties": { + "category": { + "type": "string", + "description": "Content category: \"document\", \"image\", \"video\", \"audio\", \"archive\", \"code\", \"other\"" + }, + "created_at": { + "type": "integer", + "format": "int64", + "description": "Creation timestamp", + "minimum": 0 + }, + "folder_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID" + }, + "icon_class": { + "type": "string", + "description": "CSS icon class for the file type (e.g., \"fas fa-file-pdf\")" + }, + "icon_special_class": { + "type": "string", + "description": "Extra CSS class for icon styling (e.g., \"pdf-icon\", \"code-icon js-icon\")" + }, + "id": { + "type": "string", + "description": "File ID" + }, + "mime_type": { + "type": "string", + "description": "MIME type" + }, + "modified_at": { + "type": "integer", + "format": "int64", + "description": "Last modification timestamp", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "File name" + }, + "path": { + "type": "string", + "description": "Path to the file (relative)" + }, + "relevance_score": { + "type": "integer", + "format": "int32", + "description": "Relevance score (0-100) computed server-side", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int64", + "description": "Size in bytes", + "minimum": 0 + }, + "size_formatted": { + "type": "string", + "description": "Human-readable file size (e.g., \"2.5 MB\")" + } + } + }, + "SearchFolderResultDto": { + "type": "object", + "description": "A folder search result enriched with server-computed metadata", + "required": [ + "id", + "name", + "path", + "created_at", + "modified_at", + "is_root", + "relevance_score" + ], + "properties": { + "created_at": { + "type": "integer", + "format": "int64", + "description": "Creation timestamp", + "minimum": 0 + }, + "id": { + "type": "string", + "description": "Folder ID" + }, + "is_root": { + "type": "boolean", + "description": "Whether it is a root folder" + }, + "modified_at": { + "type": "integer", + "format": "int64", + "description": "Last modification timestamp", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "Folder name" + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "description": "Parent folder ID" + }, + "path": { + "type": "string", + "description": "Path to the folder (relative)" + }, + "relevance_score": { + "type": "integer", + "format": "int32", + "description": "Relevance score (0-100) computed server-side", + "minimum": 0 + } + } + }, + "SearchResultsDto": { + "type": "object", + "description": "\n * Data Transfer Object for search results.\n *\n * This structure encapsulates the results of a search operation, including\n * both files and folders that match the search criteria, along with pagination\n * information and server-computed metadata.", + "required": [ + "files", + "folders", + "limit", + "offset", + "has_more", + "query_time_ms", + "sort_by" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFileResultDto" + }, + "description": "Files matching the search criteria (enriched with metadata)" + }, + "folders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFolderResultDto" + }, + "description": "Folders matching the search criteria (enriched with metadata)" + }, + "has_more": { + "type": "boolean", + "description": "Whether there are more results available" + }, + "limit": { + "type": "integer", + "description": "Limit used in the search", + "minimum": 0 + }, + "offset": { + "type": "integer", + "description": "Offset used in the search", + "minimum": 0 + }, + "query_time_ms": { + "type": "integer", + "format": "int64", + "description": "Query execution time in milliseconds (server-side)", + "minimum": 0 + }, + "sort_by": { + "type": "string", + "description": "Sort order used" + }, + "total_count": { + "type": [ + "integer", + "null" + ], + "description": "Total count of matching items (for pagination)", + "minimum": 0 + } + } + }, + "SearchSuggestionItem": { + "type": "object", + "description": "Individual search suggestion item", + "required": [ + "name", + "item_type", + "id", + "path", + "icon_class", + "icon_special_class", + "relevance_score" + ], + "properties": { + "icon_class": { + "type": "string", + "description": "CSS icon class" + }, + "icon_special_class": { + "type": "string", + "description": "Extra CSS class for icon styling" + }, + "id": { + "type": "string", + "description": "Item ID for navigation" + }, + "item_type": { + "type": "string", + "description": "Type: \"file\" or \"folder\"" + }, + "name": { + "type": "string", + "description": "The suggested name" + }, + "path": { + "type": "string", + "description": "Path for context" + }, + "relevance_score": { + "type": "integer", + "format": "int32", + "description": "Relevance score", + "minimum": 0 + } + } + }, + "SearchSuggestionsDto": { + "type": "object", + "description": "DTO for search suggestion results (quick prefix search)", + "required": [ + "suggestions", + "query_time_ms" + ], + "properties": { + "query_time_ms": { + "type": "integer", + "format": "int64", + "description": "Query execution time in milliseconds", + "minimum": 0 + }, + "suggestions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchSuggestionItem" + }, + "description": "Suggested file/folder names matching the query prefix" + } + } + }, + "SetupAdminDto": { + "type": "object", + "description": "DTO for the one-time initial admin setup endpoint (`/api/setup`).\nAvailable only when the system is not yet initialized (no admin exists).", + "required": [ + "username", + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "ShareDto": { + "type": "object", + "required": [ + "id", + "item_id", + "item_type", + "token", + "url", + "has_password", + "permissions", + "created_at", + "created_by", + "access_count" + ], + "properties": { + "access_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "created_at": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "created_by": { + "type": "string" + }, + "expires_at": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "has_password": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "item_name": { + "type": [ + "string", + "null" + ] + }, + "item_type": { + "type": "string" + }, + "permissions": { + "$ref": "#/components/schemas/SharePermissionsDto" + }, + "token": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "SharePermissionsDto": { + "type": "object", + "required": [ + "read", + "write", + "reshare" + ], + "properties": { + "read": { + "type": "boolean" + }, + "reshare": { + "type": "boolean" + }, + "write": { + "type": "boolean" + } + } + }, + "TrashedItemDto": { + "type": "object", + "description": "DTO representing an item in the trash", + "required": [ + "id", + "original_id", + "item_type", + "name", + "original_path", + "trashed_at", + "days_until_deletion", + "category", + "icon_class", + "icon_special_class" + ], + "properties": { + "category": { + "type": "string", + "description": "Human-readable category (e.g., \"Image\", \"Folder\", \"Document\")" + }, + "days_until_deletion": { + "type": "integer", + "format": "int64" + }, + "icon_class": { + "type": "string", + "description": "FontAwesome icon class for the file type" + }, + "icon_special_class": { + "type": "string", + "description": "Special CSS class for icon styling (e.g., \"image-icon\", \"pdf-icon\")" + }, + "id": { + "type": "string" + }, + "item_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "original_id": { + "type": "string" + }, + "original_path": { + "type": "string" + }, + "trashed_at": { + "type": "string", + "format": "date-time" + } + } + }, + "UpdateShareDto": { + "type": "object", + "properties": { + "expires_at": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "permissions": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SharePermissionsDto" + } + ] + } + } + }, + "UserDto": { + "type": "object", + "required": [ + "id", + "username", + "email", + "role", + "storage_quota_bytes", + "storage_used_bytes", + "created_at", + "updated_at", + "active", + "auth_provider" + ], + "properties": { + "active": { + "type": "boolean" + }, + "auth_provider": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "last_login_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "role": { + "type": "string" + }, + "storage_quota_bytes": { + "type": "integer", + "format": "int64" + }, + "storage_used_bytes": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, + "VerifyPasswordRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string" + } + } + } + } + }, + "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" + } + ] +} \ No newline at end of file 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..e6a674d2 100755 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -4,3 +4,118 @@ 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; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 76eda01a..4ecd9625 100755 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -9,8 +9,8 @@ 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 { AxumJson(json!({ "name": "OxiCloud", @@ -18,6 +18,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 +68,7 @@ pub fn create_public_api_routes(app_state: &Arc) -> Router Date: Wed, 1 Apr 2026 12:25:10 +0200 Subject: [PATCH 2/2] chore: remove generated openapi.json from tracking, add test and docs - Remove resources/gen/openapi.json from git (served dynamically at /api/openapi.json) - Add resources/gen/ to .gitignore - Add OpenAPI spec validation test (paths, schemas, serialization) - Restore removed doc-comment on get_version - Fix cargo fmt violation in mod.rs import - Update CLAUDE.md: test count (~208), generate-openapi command, justfile reference --- .gitignore | 3 + CLAUDE.md | 6 +- resources/gen/openapi.json | 2020 ---------------------------------- src/interfaces/api/mod.rs | 60 +- src/interfaces/api/routes.rs | 1 + 5 files changed, 68 insertions(+), 2022 deletions(-) delete mode 100644 resources/gen/openapi.json 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/resources/gen/openapi.json b/resources/gen/openapi.json deleted file mode 100644 index 91bd568b..00000000 --- a/resources/gen/openapi.json +++ /dev/null @@ -1,2020 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "OxiCloud API", - "description": "REST API for OxiCloud — self-hosted cloud storage, calendar & contacts", - "license": { - "name": "MIT" - }, - "version": "0.5.3" - }, - "paths": { - "/api/favorites": { - "get": { - "tags": [ - "favorites" - ], - "summary": "Handler for favorite-related API endpoints", - "operationId": "get_favorites", - "responses": { - "200": { - "description": "List of favorites", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FavoriteItemDto" - } - } - } - } - } - } - } - }, - "/api/favorites/batch": { - "post": { - "tags": [ - "favorites" - ], - "summary": "Add multiple items to favourites in a single transaction.\nPOST /api/favorites/batch", - "operationId": "batch_add_favorites", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchFavoritesRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Batch add result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchFavoritesResult" - } - } - } - }, - "400": { - "description": "Invalid request" - } - } - } - }, - "/api/favorites/{item_type}/{item_id}": { - "post": { - "tags": [ - "favorites" - ], - "summary": "Add an item to user's favorites", - "operationId": "add_favorite", - "parameters": [ - { - "name": "item_type", - "in": "path", - "description": "Item type (file or folder)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "item_id", - "in": "path", - "description": "Item ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Item added to favorites" - }, - "400": { - "description": "Invalid item type" - } - } - }, - "delete": { - "tags": [ - "favorites" - ], - "summary": "Remove an item from user's favorites", - "operationId": "remove_favorite", - "parameters": [ - { - "name": "item_type", - "in": "path", - "description": "Item type (file or folder)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "item_id", - "in": "path", - "description": "Item ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Item removed from favorites" - }, - "404": { - "description": "Item not in favorites" - } - } - } - }, - "/api/recent": { - "get": { - "tags": [ - "recent" - ], - "summary": "Get user's recent items", - "operationId": "get_recent_items", - "responses": { - "200": { - "description": "List of recent items", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecentItemDto" - } - } - } - } - } - } - } - }, - "/api/recent/clear": { - "delete": { - "tags": [ - "recent" - ], - "summary": "Clear all recent items", - "operationId": "clear_recent_items", - "responses": { - "200": { - "description": "Recent items cleared" - } - } - } - }, - "/api/recent/{item_type}/{item_id}": { - "post": { - "tags": [ - "recent" - ], - "summary": "Record access to an item", - "operationId": "record_item_access", - "parameters": [ - { - "name": "item_type", - "in": "path", - "description": "Item type (file or folder)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "item_id", - "in": "path", - "description": "Item ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Access recorded" - }, - "400": { - "description": "Invalid item type" - } - } - }, - "delete": { - "tags": [ - "recent" - ], - "summary": "Remove an item from recents", - "operationId": "remove_from_recent", - "parameters": [ - { - "name": "item_type", - "in": "path", - "description": "Item type (file or folder)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "item_id", - "in": "path", - "description": "Item ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Item removed from recents" - }, - "404": { - "description": "Item not in recents" - } - } - } - }, - "/api/s/{token}": { - "get": { - "tags": [ - "shares" - ], - "summary": "Access a shared item via its token", - "operationId": "access_shared_item", - "parameters": [ - { - "name": "token", - "in": "path", - "description": "Share token", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Shared item details" - }, - "401": { - "description": "Password required" - }, - "410": { - "description": "Share expired" - } - } - } - }, - "/api/s/{token}/verify": { - "post": { - "tags": [ - "shares" - ], - "summary": "Verify password for a password-protected shared item", - "operationId": "verify_shared_item_password", - "parameters": [ - { - "name": "token", - "in": "path", - "description": "Share token", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyPasswordRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Password verified, item details returned" - }, - "401": { - "description": "Invalid password" - }, - "410": { - "description": "Share expired" - } - } - } - }, - "/api/shares": { - "get": { - "tags": [ - "shares" - ], - "summary": "Get all shared links created by the current user.\nSupports optional filtering by item_id + item_type query params.", - "operationId": "get_user_shares", - "responses": { - "200": { - "description": "List of shares", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShareDto" - } - } - } - } - } - } - }, - "post": { - "tags": [ - "shares" - ], - "summary": "Create a new shared link", - "operationId": "create_shared_link", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShareDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Share created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShareDto" - } - } - } - }, - "400": { - "description": "Bad request" - } - } - } - }, - "/api/shares/{id}": { - "get": { - "tags": [ - "shares" - ], - "summary": "Get information about a specific shared link by ID", - "operationId": "get_shared_link", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Share ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Share details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShareDto" - } - } - } - }, - "404": { - "description": "Share not found" - } - } - }, - "put": { - "tags": [ - "shares" - ], - "summary": "Update a shared link's properties", - "operationId": "update_shared_link", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Share ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateShareDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Share updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShareDto" - } - } - } - }, - "404": { - "description": "Share not found" - } - } - }, - "delete": { - "tags": [ - "shares" - ], - "summary": "Delete a shared link", - "operationId": "delete_shared_link", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Share ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Share deleted" - }, - "404": { - "description": "Share not found" - } - } - } - }, - "/api/trash": { - "get": { - "tags": [ - "trash" - ], - "summary": "Gets all items in the trash for the current user", - "operationId": "get_trash_items", - "responses": { - "200": { - "description": "List of trashed items" - }, - "501": { - "description": "Trash feature not enabled" - } - } - } - }, - "/api/trash/empty": { - "delete": { - "tags": [ - "trash" - ], - "summary": "Empties the trash completely for the current user", - "operationId": "empty_trash", - "responses": { - "200": { - "description": "Trash emptied successfully" - }, - "501": { - "description": "Trash feature not enabled" - } - } - } - }, - "/api/trash/files/{id}": { - "delete": { - "tags": [ - "trash" - ], - "summary": "Moves a file to the trash", - "operationId": "move_file_to_trash", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "File ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File moved to trash" - }, - "501": { - "description": "Trash feature not enabled" - } - } - } - }, - "/api/trash/folders/{id}": { - "delete": { - "tags": [ - "trash" - ], - "summary": "Moves a folder to the trash", - "operationId": "move_folder_to_trash", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Folder ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder moved to trash" - }, - "501": { - "description": "Trash feature not enabled" - } - } - } - }, - "/api/trash/{id}": { - "delete": { - "tags": [ - "trash" - ], - "summary": "Permanently deletes an item from the trash", - "operationId": "delete_permanently", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Trash item ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Item permanently deleted" - }, - "501": { - "description": "Trash feature not enabled" - } - } - } - }, - "/api/trash/{id}/restore": { - "post": { - "tags": [ - "trash" - ], - "summary": "Restores an item from the trash to its original location", - "operationId": "restore_from_trash", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Trash item ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Item restored from trash" - }, - "501": { - "description": "Trash feature not enabled" - } - } - } - } - }, - "components": { - "schemas": { - "AuthResponseDto": { - "type": "object", - "required": [ - "user", - "access_token", - "refresh_token", - "token_type", - "expires_in" - ], - "properties": { - "access_token": { - "type": "string" - }, - "expires_in": { - "type": "integer", - "format": "int64" - }, - "refresh_token": { - "type": "string" - }, - "token_type": { - "type": "string" - }, - "user": { - "$ref": "#/components/schemas/UserDto" - } - } - }, - "BatchFavoriteItem": { - "type": "object", - "description": "Single item in a batch-add-favorites request.", - "required": [ - "item_id", - "item_type" - ], - "properties": { - "item_id": { - "type": "string" - }, - "item_type": { - "type": "string" - } - } - }, - "BatchFavoritesRequest": { - "type": "object", - "description": "Request body for POST /api/favorites/batch", - "required": [ - "items" - ], - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BatchFavoriteItem" - } - } - } - }, - "BatchFavoritesResult": { - "type": "object", - "description": "Result DTO for batch add-to-favorites.", - "required": [ - "stats", - "favorites" - ], - "properties": { - "favorites": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FavoriteItemDto" - }, - "description": "Full list of the user's favourites (enriched), so the client can\nreplace its local cache in a single round-trip." - }, - "stats": { - "$ref": "#/components/schemas/BatchFavoritesStats", - "description": "Statistics about the batch operation" - } - } - }, - "BatchFavoritesStats": { - "type": "object", - "required": [ - "requested", - "inserted", - "already_existed" - ], - "properties": { - "already_existed": { - "type": "integer", - "format": "int64", - "description": "How many were already favourites (skipped)", - "minimum": 0 - }, - "inserted": { - "type": "integer", - "format": "int64", - "description": "How many were actually inserted (new)", - "minimum": 0 - }, - "requested": { - "type": "integer", - "description": "How many items were requested", - "minimum": 0 - } - } - }, - "ChangePasswordDto": { - "type": "object", - "required": [ - "current_password", - "new_password" - ], - "properties": { - "current_password": { - "type": "string" - }, - "new_password": { - "type": "string" - } - } - }, - "CreateFolderDto": { - "type": "object", - "description": "DTO for folder creation requests", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Name of the folder to create" - }, - "parent_id": { - "type": [ - "string", - "null" - ], - "description": "Parent folder ID (None for root level)" - } - } - }, - "CreateShareDto": { - "type": "object", - "required": [ - "item_id", - "item_type" - ], - "properties": { - "expires_at": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 - }, - "item_id": { - "type": "string" - }, - "item_name": { - "type": [ - "string", - "null" - ] - }, - "item_type": { - "type": "string" - }, - "password": { - "type": [ - "string", - "null" - ] - }, - "permissions": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/SharePermissionsDto" - } - ] - } - } - }, - "DeletePermanentlyRequest": { - "type": "object", - "description": "Request to permanently delete an item from trash", - "required": [ - "trash_id" - ], - "properties": { - "trash_id": { - "type": "string" - } - } - }, - "FavoriteItemDto": { - "type": "object", - "description": "DTO for favorites item, enriched with item metadata via SQL JOIN\nso the frontend does not need N+1 requests to resolve names/sizes.", - "required": [ - "id", - "user_id", - "item_id", - "item_type", - "created_at", - "icon_class", - "icon_special_class", - "category", - "size_formatted" - ], - "properties": { - "category": { - "type": "string", - "description": "Human-readable category (e.g. \"Image\", \"Folder\")" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the item was added to favorites" - }, - "icon_class": { - "type": "string", - "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\", \"fas fa-folder\")" - }, - "icon_special_class": { - "type": "string", - "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"folder-icon\")" - }, - "id": { - "type": "string", - "description": "Unique identifier for the favorite entry" - }, - "item_id": { - "type": "string", - "description": "ID of the favorited item (file or folder)" - }, - "item_mime_type": { - "type": [ - "string", - "null" - ], - "description": "MIME type (files only)" - }, - "item_name": { - "type": [ - "string", - "null" - ], - "description": "Display name of the file or folder" - }, - "item_size": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Size in bytes (files only; folders → None)" - }, - "item_type": { - "type": "string", - "description": "Type of the item ('file' or 'folder')" - }, - "modified_at": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Last modification timestamp of the item" - }, - "parent_id": { - "type": [ - "string", - "null" - ], - "description": "Parent folder ID (folder_id for files, parent_id for folders)" - }, - "size_formatted": { - "type": "string", - "description": "Formatted file size (e.g. \"3.27 MB\"); \"--\" for folders" - }, - "user_id": { - "type": "string", - "description": "User ID who owns this favorite" - } - } - }, - "FileDto": { - "type": "object", - "description": "DTO for file responses", - "required": [ - "id", - "name", - "path", - "size", - "mime_type", - "created_at", - "modified_at", - "icon_class", - "icon_special_class", - "category", - "size_formatted" - ], - "properties": { - "category": { - "type": "string", - "description": "Human-readable file category (e.g. \"Image\", \"Document\")" - }, - "created_at": { - "type": "integer", - "format": "int64", - "description": "Creation timestamp", - "minimum": 0 - }, - "folder_id": { - "type": [ - "string", - "null" - ], - "description": "Parent folder ID" - }, - "icon_class": { - "type": "string", - "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\")" - }, - "icon_special_class": { - "type": "string", - "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"\" when default)" - }, - "id": { - "type": "string", - "description": "File ID" - }, - "mime_type": { - "type": "string", - "description": "MIME type — `Arc` because MIME values repeat across files\nand DTOs are cloned on every request (clone is O(1) atomic increment)." - }, - "modified_at": { - "type": "integer", - "format": "int64", - "description": "Last modification timestamp", - "minimum": 0 - }, - "name": { - "type": "string", - "description": "File name" - }, - "owner_id": { - "type": [ - "string", - "null" - ], - "description": "Owner user ID (omitted from JSON when None)" - }, - "path": { - "type": "string", - "description": "Path to the file (relative)" - }, - "size": { - "type": "integer", - "format": "int64", - "description": "Size in bytes", - "minimum": 0 - }, - "size_formatted": { - "type": "string", - "description": "Human-readable formatted size (e.g. \"3.27 MB\")" - }, - "sort_date": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Sort date for Photos timeline — COALESCE(EXIF captured_at, created_at).\nOnly populated by the /api/photos endpoint.", - "minimum": 0 - } - } - }, - "FolderDto": { - "type": "object", - "description": "DTO for folder responses", - "required": [ - "id", - "name", - "path", - "created_at", - "modified_at", - "is_root", - "icon_class", - "icon_special_class", - "category" - ], - "properties": { - "category": { - "type": "string", - "description": "Human-readable category (always \"Folder\")" - }, - "created_at": { - "type": "integer", - "format": "int64", - "description": "Creation timestamp", - "minimum": 0 - }, - "icon_class": { - "type": "string", - "description": "FontAwesome icon CSS class (always \"fas fa-folder\")" - }, - "icon_special_class": { - "type": "string", - "description": "Extra CSS class for icon styling (always \"folder-icon\")" - }, - "id": { - "type": "string", - "description": "Folder ID" - }, - "is_root": { - "type": "boolean", - "description": "Whether this is a root folder" - }, - "modified_at": { - "type": "integer", - "format": "int64", - "description": "Last modification timestamp", - "minimum": 0 - }, - "name": { - "type": "string", - "description": "Folder name" - }, - "owner_id": { - "type": [ - "string", - "null" - ], - "description": "Owner user ID (scopes visibility per user)" - }, - "parent_id": { - "type": [ - "string", - "null" - ], - "description": "Parent folder ID" - }, - "path": { - "type": "string", - "description": "Path to the folder (relative)" - } - } - }, - "FolderListingDto": { - "type": "object", - "description": "Combined DTO that returns both sub-folders and files for a given folder\nin a single response, eliminating the double-fetch on every navigation.", - "required": [ - "folders", - "files" - ], - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDto" - }, - "description": "Files inside the requested folder" - }, - "folders": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FolderDto" - }, - "description": "Sub-folders inside the requested folder" - } - } - }, - "LoginDto": { - "type": "object", - "required": [ - "username", - "password" - ], - "properties": { - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "MoveFilePayload": { - "type": "object", - "description": "Payload for moving a file", - "properties": { - "folder_id": { - "type": [ - "string", - "null" - ], - "description": "Target folder ID (None means root)" - } - } - }, - "MoveFolderDto": { - "type": "object", - "description": "DTO for folder move requests", - "properties": { - "parent_id": { - "type": [ - "string", - "null" - ], - "description": "New parent folder ID (None for root level)" - } - } - }, - "MoveToTrashRequest": { - "type": "object", - "description": "Request to move an item to trash", - "required": [ - "item_id", - "item_type" - ], - "properties": { - "item_id": { - "type": "string" - }, - "item_type": { - "type": "string" - } - } - }, - "PaginationDto": { - "type": "object", - "description": "A DTO to represent pagination information", - "required": [ - "page", - "page_size", - "total_items", - "total_pages", - "has_next", - "has_prev" - ], - "properties": { - "has_next": { - "type": "boolean", - "description": "Indicates if there is a next page" - }, - "has_prev": { - "type": "boolean", - "description": "Indicates if there is a previous page" - }, - "page": { - "type": "integer", - "description": "Current page (starts at 0)", - "minimum": 0 - }, - "page_size": { - "type": "integer", - "description": "Page size", - "minimum": 0 - }, - "total_items": { - "type": "integer", - "description": "Total number of items", - "minimum": 0 - }, - "total_pages": { - "type": "integer", - "description": "Total number of pages", - "minimum": 0 - } - } - }, - "PaginationRequestDto": { - "type": "object", - "description": "A DTO to represent a pagination request", - "properties": { - "page": { - "type": "integer", - "description": "Requested page (starts at 0)", - "minimum": 0 - }, - "page_size": { - "type": "integer", - "description": "Requested page size", - "minimum": 0 - } - } - }, - "RecentItemDto": { - "type": "object", - "description": "DTO for recent items, enriched with item metadata via SQL JOIN\nso the frontend does not need N+1 requests to resolve names/sizes.", - "required": [ - "id", - "user_id", - "item_id", - "item_type", - "accessed_at", - "icon_class", - "icon_special_class", - "category", - "size_formatted" - ], - "properties": { - "accessed_at": { - "type": "string", - "format": "date-time", - "description": "When the item was accessed" - }, - "category": { - "type": "string", - "description": "Human-readable category (e.g. \"Image\", \"Folder\")" - }, - "icon_class": { - "type": "string", - "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\", \"fas fa-folder\")" - }, - "icon_special_class": { - "type": "string", - "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"folder-icon\")" - }, - "id": { - "type": "string", - "description": "Unique identifier for the recent item" - }, - "item_id": { - "type": "string", - "description": "Item ID (file or folder)" - }, - "item_mime_type": { - "type": [ - "string", - "null" - ], - "description": "MIME type (files only)" - }, - "item_name": { - "type": [ - "string", - "null" - ], - "description": "Display name of the file or folder" - }, - "item_size": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Size in bytes (files only; folders → None)" - }, - "item_type": { - "type": "string", - "description": "Item type ('file' or 'folder')" - }, - "parent_id": { - "type": [ - "string", - "null" - ], - "description": "Parent folder ID (folder_id for files, parent_id for folders)" - }, - "size_formatted": { - "type": "string", - "description": "Formatted file size (e.g. \"3.27 MB\"); \"--\" for folders" - }, - "user_id": { - "type": "string", - "description": "Owner user ID" - } - } - }, - "RefreshTokenDto": { - "type": "object", - "required": [ - "refresh_token" - ], - "properties": { - "refresh_token": { - "type": "string" - } - } - }, - "RegisterDto": { - "type": "object", - "required": [ - "username", - "email", - "password" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "RenameFolderDto": { - "type": "object", - "description": "DTO for folder rename requests", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "New name for the folder" - } - } - }, - "RestoreFromTrashRequest": { - "type": "object", - "description": "Request to restore an item from trash", - "required": [ - "trash_id" - ], - "properties": { - "trash_id": { - "type": "string" - } - } - }, - "SearchCriteriaDto": { - "type": "object", - "description": "\n * Data Transfer Object for file search criteria.\n *\n * This structure represents all possible search parameters that can be used\n * to filter files and folders in the system. It supports various filter types\n * including name matching, file types, date ranges, and size constraints.", - "properties": { - "created_after": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Optional minimum creation date (seconds since epoch)", - "minimum": 0 - }, - "created_before": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Optional maximum creation date (seconds since epoch)", - "minimum": 0 - }, - "file_types": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - }, - "description": "Optional list of file extensions to include (e.g., \"pdf\", \"jpg\")" - }, - "folder_id": { - "type": [ - "string", - "null" - ], - "description": "Optional folder ID to limit search scope" - }, - "limit": { - "type": "integer", - "description": "Maximum number of results to return", - "minimum": 0 - }, - "max_size": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Optional maximum file size in bytes", - "minimum": 0 - }, - "min_size": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Optional minimum file size in bytes", - "minimum": 0 - }, - "modified_after": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Optional minimum modification date (seconds since epoch)", - "minimum": 0 - }, - "modified_before": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Optional maximum modification date (seconds since epoch)", - "minimum": 0 - }, - "name_contains": { - "type": [ - "string", - "null" - ], - "description": "Optional text to search in file/folder names" - }, - "offset": { - "type": "integer", - "description": "Offset for pagination", - "minimum": 0 - }, - "recursive": { - "type": "boolean", - "description": "Whether to search recursively within subfolders (default: true)" - }, - "sort_by": { - "type": "string", - "description": "Sort order for results: \"relevance\", \"name\", \"name_desc\", \"date\", \"date_desc\", \"size\", \"size_desc\"" - } - } - }, - "SearchFileResultDto": { - "type": "object", - "description": "A file search result enriched with server-computed metadata", - "required": [ - "id", - "name", - "path", - "size", - "mime_type", - "created_at", - "modified_at", - "relevance_score", - "size_formatted", - "icon_class", - "icon_special_class", - "category" - ], - "properties": { - "category": { - "type": "string", - "description": "Content category: \"document\", \"image\", \"video\", \"audio\", \"archive\", \"code\", \"other\"" - }, - "created_at": { - "type": "integer", - "format": "int64", - "description": "Creation timestamp", - "minimum": 0 - }, - "folder_id": { - "type": [ - "string", - "null" - ], - "description": "Parent folder ID" - }, - "icon_class": { - "type": "string", - "description": "CSS icon class for the file type (e.g., \"fas fa-file-pdf\")" - }, - "icon_special_class": { - "type": "string", - "description": "Extra CSS class for icon styling (e.g., \"pdf-icon\", \"code-icon js-icon\")" - }, - "id": { - "type": "string", - "description": "File ID" - }, - "mime_type": { - "type": "string", - "description": "MIME type" - }, - "modified_at": { - "type": "integer", - "format": "int64", - "description": "Last modification timestamp", - "minimum": 0 - }, - "name": { - "type": "string", - "description": "File name" - }, - "path": { - "type": "string", - "description": "Path to the file (relative)" - }, - "relevance_score": { - "type": "integer", - "format": "int32", - "description": "Relevance score (0-100) computed server-side", - "minimum": 0 - }, - "size": { - "type": "integer", - "format": "int64", - "description": "Size in bytes", - "minimum": 0 - }, - "size_formatted": { - "type": "string", - "description": "Human-readable file size (e.g., \"2.5 MB\")" - } - } - }, - "SearchFolderResultDto": { - "type": "object", - "description": "A folder search result enriched with server-computed metadata", - "required": [ - "id", - "name", - "path", - "created_at", - "modified_at", - "is_root", - "relevance_score" - ], - "properties": { - "created_at": { - "type": "integer", - "format": "int64", - "description": "Creation timestamp", - "minimum": 0 - }, - "id": { - "type": "string", - "description": "Folder ID" - }, - "is_root": { - "type": "boolean", - "description": "Whether it is a root folder" - }, - "modified_at": { - "type": "integer", - "format": "int64", - "description": "Last modification timestamp", - "minimum": 0 - }, - "name": { - "type": "string", - "description": "Folder name" - }, - "parent_id": { - "type": [ - "string", - "null" - ], - "description": "Parent folder ID" - }, - "path": { - "type": "string", - "description": "Path to the folder (relative)" - }, - "relevance_score": { - "type": "integer", - "format": "int32", - "description": "Relevance score (0-100) computed server-side", - "minimum": 0 - } - } - }, - "SearchResultsDto": { - "type": "object", - "description": "\n * Data Transfer Object for search results.\n *\n * This structure encapsulates the results of a search operation, including\n * both files and folders that match the search criteria, along with pagination\n * information and server-computed metadata.", - "required": [ - "files", - "folders", - "limit", - "offset", - "has_more", - "query_time_ms", - "sort_by" - ], - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchFileResultDto" - }, - "description": "Files matching the search criteria (enriched with metadata)" - }, - "folders": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchFolderResultDto" - }, - "description": "Folders matching the search criteria (enriched with metadata)" - }, - "has_more": { - "type": "boolean", - "description": "Whether there are more results available" - }, - "limit": { - "type": "integer", - "description": "Limit used in the search", - "minimum": 0 - }, - "offset": { - "type": "integer", - "description": "Offset used in the search", - "minimum": 0 - }, - "query_time_ms": { - "type": "integer", - "format": "int64", - "description": "Query execution time in milliseconds (server-side)", - "minimum": 0 - }, - "sort_by": { - "type": "string", - "description": "Sort order used" - }, - "total_count": { - "type": [ - "integer", - "null" - ], - "description": "Total count of matching items (for pagination)", - "minimum": 0 - } - } - }, - "SearchSuggestionItem": { - "type": "object", - "description": "Individual search suggestion item", - "required": [ - "name", - "item_type", - "id", - "path", - "icon_class", - "icon_special_class", - "relevance_score" - ], - "properties": { - "icon_class": { - "type": "string", - "description": "CSS icon class" - }, - "icon_special_class": { - "type": "string", - "description": "Extra CSS class for icon styling" - }, - "id": { - "type": "string", - "description": "Item ID for navigation" - }, - "item_type": { - "type": "string", - "description": "Type: \"file\" or \"folder\"" - }, - "name": { - "type": "string", - "description": "The suggested name" - }, - "path": { - "type": "string", - "description": "Path for context" - }, - "relevance_score": { - "type": "integer", - "format": "int32", - "description": "Relevance score", - "minimum": 0 - } - } - }, - "SearchSuggestionsDto": { - "type": "object", - "description": "DTO for search suggestion results (quick prefix search)", - "required": [ - "suggestions", - "query_time_ms" - ], - "properties": { - "query_time_ms": { - "type": "integer", - "format": "int64", - "description": "Query execution time in milliseconds", - "minimum": 0 - }, - "suggestions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchSuggestionItem" - }, - "description": "Suggested file/folder names matching the query prefix" - } - } - }, - "SetupAdminDto": { - "type": "object", - "description": "DTO for the one-time initial admin setup endpoint (`/api/setup`).\nAvailable only when the system is not yet initialized (no admin exists).", - "required": [ - "username", - "email", - "password" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "ShareDto": { - "type": "object", - "required": [ - "id", - "item_id", - "item_type", - "token", - "url", - "has_password", - "permissions", - "created_at", - "created_by", - "access_count" - ], - "properties": { - "access_count": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "created_at": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "created_by": { - "type": "string" - }, - "expires_at": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 - }, - "has_password": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "item_name": { - "type": [ - "string", - "null" - ] - }, - "item_type": { - "type": "string" - }, - "permissions": { - "$ref": "#/components/schemas/SharePermissionsDto" - }, - "token": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "SharePermissionsDto": { - "type": "object", - "required": [ - "read", - "write", - "reshare" - ], - "properties": { - "read": { - "type": "boolean" - }, - "reshare": { - "type": "boolean" - }, - "write": { - "type": "boolean" - } - } - }, - "TrashedItemDto": { - "type": "object", - "description": "DTO representing an item in the trash", - "required": [ - "id", - "original_id", - "item_type", - "name", - "original_path", - "trashed_at", - "days_until_deletion", - "category", - "icon_class", - "icon_special_class" - ], - "properties": { - "category": { - "type": "string", - "description": "Human-readable category (e.g., \"Image\", \"Folder\", \"Document\")" - }, - "days_until_deletion": { - "type": "integer", - "format": "int64" - }, - "icon_class": { - "type": "string", - "description": "FontAwesome icon class for the file type" - }, - "icon_special_class": { - "type": "string", - "description": "Special CSS class for icon styling (e.g., \"image-icon\", \"pdf-icon\")" - }, - "id": { - "type": "string" - }, - "item_type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "original_id": { - "type": "string" - }, - "original_path": { - "type": "string" - }, - "trashed_at": { - "type": "string", - "format": "date-time" - } - } - }, - "UpdateShareDto": { - "type": "object", - "properties": { - "expires_at": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 - }, - "password": { - "type": [ - "string", - "null" - ] - }, - "permissions": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/SharePermissionsDto" - } - ] - } - } - }, - "UserDto": { - "type": "object", - "required": [ - "id", - "username", - "email", - "role", - "storage_quota_bytes", - "storage_used_bytes", - "created_at", - "updated_at", - "active", - "auth_provider" - ], - "properties": { - "active": { - "type": "boolean" - }, - "auth_provider": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "email": { - "type": "string" - }, - "id": { - "type": "string" - }, - "last_login_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "role": { - "type": "string" - }, - "storage_quota_bytes": { - "type": "integer", - "format": "int64" - }, - "storage_used_bytes": { - "type": "integer", - "format": "int64" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "username": { - "type": "string" - } - } - }, - "VerifyPasswordRequest": { - "type": "object", - "required": [ - "password" - ], - "properties": { - "password": { - "type": "string" - } - } - } - } - }, - "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" - } - ] -} \ No newline at end of file diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index e6a674d2..f53dde99 100755 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -11,7 +11,9 @@ 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_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; @@ -119,3 +121,59 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; ) )] 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 4ecd9625..f6edf151 100755 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -11,6 +11,7 @@ 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 { AxumJson(json!({ "name": "OxiCloud",