diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ebb9de0..5f3d51a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -axum = { version = "0.7", features = ["macros", "ws"] } +axum = { version = "0.8", features = ["macros", "ws"] } tokio = { version = "1", features = ["full"] } tower = { version = "0.4", features = ["util", "timeout"] } tower-http = { version = "0.5", features = ["fs", "trace", "cors", "compression-full"] } @@ -24,3 +24,5 @@ mime_guess = "2.0" shared = { path = "../shared" } thiserror = "2.0.18" dotenvy = "0.15.7" +utoipa = { version = "5.4.0", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 44c81ab..8956a1f 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -8,13 +8,16 @@ use axum::{ use rust_embed::RustEmbed; use serde::Deserialize; use shared::TorrentActionRequest; +use utoipa::ToSchema; #[derive(RustEmbed)] #[folder = "../frontend/dist"] pub struct Asset; -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct AddTorrentRequest { + /// Magnet link or Torrent file URL + #[schema(example = "magnet:?xt=urn:btih:...")] uri: String, } @@ -46,6 +49,16 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse { } } +/// Add a new torrent via magnet link or URL +#[utoipa::path( + post, + path = "/api/torrents/add", + request_body = AddTorrentRequest, + responses( + (status = 200, description = "Torrent added successfully"), + (status = 500, description = "Internal server error or rTorrent fault") + ) +)] pub async fn add_torrent_handler( State(state): State, Json(payload): Json, @@ -193,6 +206,18 @@ async fn delete_torrent_with_data( } } +/// Perform an action on a torrent (start, stop, delete) +#[utoipa::path( + post, + path = "/api/torrents/action", + request_body = TorrentActionRequest, + responses( + (status = 200, description = "Action executed successfully"), + (status = 400, description = "Invalid action or request"), + (status = 403, description = "Forbidden: Security risk detected"), + (status = 500, description = "Internal server error") + ) +)] pub async fn handle_torrent_action( State(state): State, Json(payload): Json, diff --git a/backend/src/main.rs b/backend/src/main.rs index 98301e4..e64e3bd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -22,6 +22,8 @@ use tower_http::{ cors::CorsLayer, trace::TraceLayer, }; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; #[derive(Clone)] pub struct AppState { @@ -47,6 +49,27 @@ struct Args { port: u16, } +#[derive(OpenApi)] +#[openapi( + paths( + handlers::add_torrent_handler, + handlers::handle_torrent_action + ), + components( + schemas( + handlers::AddTorrentRequest, + shared::TorrentActionRequest, + shared::Torrent, + shared::TorrentStatus, + shared::Theme + ) + ), + tags( + (name = "vibetorrent", description = "VibeTorrent API") + ) +)] +struct ApiDoc; + #[tokio::main] async fn main() { // Load .env file @@ -122,8 +145,10 @@ async fn main() { match diff::diff_torrents(&previous_torrents, &new_torrents) { diff::DiffResult::FullUpdate => { - let _ = - event_bus_tx.send(AppEvent::FullList(new_torrents.clone(), now)); + let _ = event_bus_tx.send(AppEvent::FullList { + torrents: new_torrents.clone(), + timestamp: now, + }); } diff::DiffResult::Partial(updates) => { for update in updates { @@ -144,6 +169,7 @@ async fn main() { }); let app = Router::new() + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) .route("/api/events", get(sse::sse_handler)) .route("/api/torrents/add", post(handlers::add_torrent_handler)) .route( diff --git a/frontend/src/store.rs b/frontend/src/store.rs index ce4e3cd..5f5f9d8 100644 --- a/frontend/src/store.rs +++ b/frontend/src/store.rs @@ -1,8 +1,7 @@ -use leptos::*; -use shared::{Torrent, AppEvent}; use futures::StreamExt; use gloo_net::eventsource::futures::EventSource; - +use leptos::*; +use shared::{AppEvent, Torrent}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FilterStatus { @@ -41,7 +40,11 @@ pub fn provide_torrent_store() { let filter = create_rw_signal(FilterStatus::All); let search_query = create_rw_signal(String::new()); - let store = TorrentStore { torrents, filter, search_query }; + let store = TorrentStore { + torrents, + filter, + search_query, + }; provide_context(store); // Initialize SSE connection @@ -52,28 +55,47 @@ pub fn provide_torrent_store() { while let Some(Ok((_, msg))) = stream.next().await { if let Some(data_str) = msg.data().as_string() { - if let Ok(event) = serde_json::from_str::(&data_str) { + if let Ok(event) = serde_json::from_str::(&data_str) { match event { - AppEvent::FullList(list, _) => { + AppEvent::FullList { torrents: list, .. } => { torrents.set(list); } AppEvent::Update(update) => { torrents.update(|list| { - if let Some(t) = list.iter_mut().find(|t| t.hash == update.hash) { - if let Some(name) = update.name { t.name = name; } - if let Some(size) = update.size { t.size = size; } - if let Some(down_rate) = update.down_rate { t.down_rate = down_rate; } - if let Some(up_rate) = update.up_rate { t.up_rate = up_rate; } - if let Some(percent_complete) = update.percent_complete { t.percent_complete = percent_complete; } - if let Some(completed) = update.completed { t.completed = completed; } - if let Some(eta) = update.eta { t.eta = eta; } - if let Some(status) = update.status { t.status = status; } - if let Some(error_message) = update.error_message { t.error_message = error_message; } + if let Some(t) = list.iter_mut().find(|t| t.hash == update.hash) + { + if let Some(name) = update.name { + t.name = name; + } + if let Some(size) = update.size { + t.size = size; + } + if let Some(down_rate) = update.down_rate { + t.down_rate = down_rate; + } + if let Some(up_rate) = update.up_rate { + t.up_rate = up_rate; + } + if let Some(percent_complete) = update.percent_complete { + t.percent_complete = percent_complete; + } + if let Some(completed) = update.completed { + t.completed = completed; + } + if let Some(eta) = update.eta { + t.eta = eta; + } + if let Some(status) = update.status { + t.status = status; + } + if let Some(error_message) = update.error_message { + t.error_message = error_message; + } } }); } } - } + } } } }); diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 87ab7e9..f36fa6d 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -5,3 +5,4 @@ edition = "2024" [dependencies] serde = { version = "1.0.228", features = ["derive"] } +utoipa = { version = "5.4.0", features = ["axum_extras"] } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 3663792..587244f 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct Torrent { pub hash: String, pub name: String, @@ -15,7 +16,7 @@ pub struct Torrent { pub added_date: i64, } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] pub enum TorrentStatus { Downloading, Seeding, @@ -25,14 +26,17 @@ pub enum TorrentStatus { Queued, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[serde(tag = "type", content = "data")] pub enum AppEvent { - FullList(Vec, u64), // u64 is likely free_space_bytes + FullList { + torrents: Vec, + timestamp: u64, + }, Update(TorrentUpdate), } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct TorrentUpdate { pub hash: String, pub name: Option, @@ -46,14 +50,17 @@ pub struct TorrentUpdate { pub error_message: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct TorrentActionRequest { + /// The hash of the torrent + #[schema(example = "5D4C9065...")] pub hash: String, - pub action: String, // "start", "stop", "delete" + /// The action to perform: "start", "stop", "delete", "delete_with_data" + #[schema(example = "start")] + pub action: String, } -// Added Theme here to separate it from backend logic but allow frontend usage via shared -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] pub enum Theme { Midnight, Light,