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)
This commit is contained in:
@@ -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<BatchFavoriteItem>,
|
||||
}
|
||||
|
||||
/// Handler for favorite-related API endpoints
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/favorites",
|
||||
responses(
|
||||
(status = 200, description = "List of favorites", body = Vec<crate::application::dtos::favorites_dto::FavoriteItemDto>)
|
||||
),
|
||||
tag = "favorites"
|
||||
)]
|
||||
pub async fn get_favorites(
|
||||
State(favorites_service): State<Arc<FavoritesService>>,
|
||||
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<Arc<FavoritesService>>,
|
||||
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<Arc<FavoritesService>>,
|
||||
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<Arc<FavoritesService>>,
|
||||
auth_user: AuthUser,
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
@@ -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<crate::application::dtos::recent_dto::RecentItemDto>)
|
||||
),
|
||||
tag = "recent"
|
||||
)]
|
||||
pub async fn get_recent_items(
|
||||
State(recent_service): State<Arc<RecentService>>,
|
||||
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<Arc<RecentService>>,
|
||||
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<Arc<RecentService>>,
|
||||
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<Arc<RecentService>>,
|
||||
auth_user: AuthUser,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<Arc<ShareService>>,
|
||||
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<Arc<ShareService>>,
|
||||
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<crate::application::dtos::share_dto::ShareDto>)
|
||||
),
|
||||
tag = "shares"
|
||||
)]
|
||||
pub async fn get_user_shares(
|
||||
State(share_use_case): State<Arc<ShareService>>,
|
||||
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<Arc<ShareService>>,
|
||||
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<Arc<ShareService>>,
|
||||
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<Arc<ShareService>>,
|
||||
Path(token): Path<String>,
|
||||
@@ -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<Arc<ShareService>>,
|
||||
Path(token): Path<String>,
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
@@ -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<Arc<AppState>>,
|
||||
@@ -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<Arc<AppState>>,
|
||||
@@ -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<Arc<AppState>>,
|
||||
@@ -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<Arc<AppState>>,
|
||||
@@ -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<Arc<AppState>>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
AxumJson(json!({
|
||||
"name": "OxiCloud",
|
||||
@@ -18,6 +18,10 @@ async fn get_version() -> AxumJson<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_openapi_spec() -> AxumJson<utoipa::openapi::OpenApi> {
|
||||
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<AppState>) -> Router<Arc<AppStat
|
||||
|
||||
// Version endpoint — public, no auth required
|
||||
router = router.route("/version", get(get_version));
|
||||
router = router.route("/openapi.json", get(get_openapi_spec));
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user