From 625077f341925ceb14400d7738459b2d47a9c92a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:17:55 +0300 Subject: [PATCH] pinakes-server: add utoipa annotations to all routes; fix tests Signed-off-by: NotAShelf Change-Id: I28cf5b7b7cff8e90e123d624d97cf9656a6a6964 --- crates/pinakes-server/Cargo.toml | 3 + crates/pinakes-server/src/api_doc.rs | 486 ++++++++++++++++++ crates/pinakes-server/src/app.rs | 19 +- crates/pinakes-server/src/dto/analytics.rs | 5 +- crates/pinakes-server/src/dto/audit.rs | 2 +- crates/pinakes-server/src/dto/batch.rs | 10 +- crates/pinakes-server/src/dto/collections.rs | 6 +- crates/pinakes-server/src/dto/config.rs | 14 +- crates/pinakes-server/src/dto/enrichment.rs | 3 +- crates/pinakes-server/src/dto/media.rs | 61 ++- crates/pinakes-server/src/dto/playlists.rs | 10 +- crates/pinakes-server/src/dto/plugins.rs | 15 +- crates/pinakes-server/src/dto/scan.rs | 9 +- crates/pinakes-server/src/dto/search.rs | 8 +- crates/pinakes-server/src/dto/sharing.rs | 20 +- crates/pinakes-server/src/dto/social.rs | 14 +- crates/pinakes-server/src/dto/statistics.rs | 8 +- crates/pinakes-server/src/dto/sync.rs | 34 +- crates/pinakes-server/src/dto/tags.rs | 6 +- crates/pinakes-server/src/dto/transcode.rs | 4 +- crates/pinakes-server/src/dto/users.rs | 19 +- crates/pinakes-server/src/error.rs | 19 + crates/pinakes-server/src/lib.rs | 1 + crates/pinakes-server/src/routes/analytics.rs | 74 +++ crates/pinakes-server/src/routes/audit.rs | 15 + crates/pinakes-server/src/routes/auth.rs | 123 ++++- crates/pinakes-server/src/routes/backup.rs | 12 + crates/pinakes-server/src/routes/books.rs | 135 ++++- .../pinakes-server/src/routes/collections.rs | 97 ++++ crates/pinakes-server/src/routes/config.rs | 76 +++ crates/pinakes-server/src/routes/database.rs | 36 ++ .../pinakes-server/src/routes/duplicates.rs | 11 + .../pinakes-server/src/routes/enrichment.rs | 41 ++ crates/pinakes-server/src/routes/export.rs | 28 +- crates/pinakes-server/src/routes/health.rs | 45 +- crates/pinakes-server/src/routes/integrity.rs | 69 ++- crates/pinakes-server/src/routes/jobs.rs | 37 ++ crates/pinakes-server/src/routes/media.rs | 409 +++++++++++++++ crates/pinakes-server/src/routes/notes.rs | 96 +++- crates/pinakes-server/src/routes/photos.rs | 39 +- crates/pinakes-server/src/routes/playlists.rs | 135 +++++ crates/pinakes-server/src/routes/plugins.rs | 117 +++++ .../src/routes/saved_searches.rs | 41 +- crates/pinakes-server/src/routes/scan.rs | 23 + .../src/routes/scheduled_tasks.rs | 37 ++ crates/pinakes-server/src/routes/search.rs | 31 ++ crates/pinakes-server/src/routes/shares.rs | 156 ++++++ crates/pinakes-server/src/routes/social.rs | 114 ++++ .../pinakes-server/src/routes/statistics.rs | 11 + crates/pinakes-server/src/routes/streaming.rs | 75 +++ crates/pinakes-server/src/routes/subtitles.rs | 281 ++++++++-- crates/pinakes-server/src/routes/sync.rs | 217 ++++++++ crates/pinakes-server/src/routes/tags.rs | 97 ++++ crates/pinakes-server/src/routes/transcode.rs | 48 ++ crates/pinakes-server/src/routes/upload.rs | 49 ++ crates/pinakes-server/src/routes/users.rs | 114 ++++ crates/pinakes-server/src/routes/webhooks.rs | 24 +- crates/pinakes-server/tests/books.rs | 18 +- crates/pinakes-server/tests/common/mod.rs | 1 + crates/pinakes-server/tests/notes.rs | 27 +- 60 files changed, 3493 insertions(+), 242 deletions(-) create mode 100644 crates/pinakes-server/src/api_doc.rs diff --git a/crates/pinakes-server/Cargo.toml b/crates/pinakes-server/Cargo.toml index d6f42a2..d96001c 100644 --- a/crates/pinakes-server/Cargo.toml +++ b/crates/pinakes-server/Cargo.toml @@ -32,6 +32,9 @@ rand = { workspace = true } percent-encoding = { workspace = true } http = { workspace = true } rustc-hash = { workspace = true } +utoipa = { workspace = true } +utoipa-axum = { workspace = true } +utoipa-swagger-ui = { workspace = true } [lints] workspace = true diff --git a/crates/pinakes-server/src/api_doc.rs b/crates/pinakes-server/src/api_doc.rs new file mode 100644 index 0000000..c4a8e4d --- /dev/null +++ b/crates/pinakes-server/src/api_doc.rs @@ -0,0 +1,486 @@ +use utoipa::OpenApi; + +/// Central OpenAPI document registry. +/// Handler functions and schemas are added here as route modules are annotated. +#[derive(OpenApi)] +#[openapi( + info( + title = "Pinakes API", + version = env!("CARGO_PKG_VERSION"), + description = "Media cataloging and library management API" + ), + paths( + // analytics + crate::routes::analytics::get_most_viewed, + crate::routes::analytics::get_recently_viewed, + crate::routes::analytics::record_event, + crate::routes::analytics::get_watch_progress, + crate::routes::analytics::update_watch_progress, + // audit + crate::routes::audit::list_audit, + // auth + crate::routes::auth::login, + crate::routes::auth::logout, + crate::routes::auth::me, + crate::routes::auth::refresh, + crate::routes::auth::revoke_all_sessions, + crate::routes::auth::list_active_sessions, + // backup + crate::routes::backup::create_backup, + // books + crate::routes::books::get_book_metadata, + crate::routes::books::list_books, + crate::routes::books::list_series, + crate::routes::books::get_series_books, + crate::routes::books::list_authors, + crate::routes::books::get_author_books, + crate::routes::books::get_reading_progress, + crate::routes::books::update_reading_progress, + crate::routes::books::get_reading_list, + // collections + crate::routes::collections::create_collection, + crate::routes::collections::list_collections, + crate::routes::collections::get_collection, + crate::routes::collections::delete_collection, + crate::routes::collections::add_member, + crate::routes::collections::remove_member, + crate::routes::collections::get_members, + // config + crate::routes::config::get_config, + crate::routes::config::get_ui_config, + crate::routes::config::update_ui_config, + crate::routes::config::update_scanning_config, + crate::routes::config::add_root, + crate::routes::config::remove_root, + // database + crate::routes::database::database_stats, + crate::routes::database::vacuum_database, + crate::routes::database::clear_database, + // duplicates + crate::routes::duplicates::list_duplicates, + // enrichment + crate::routes::enrichment::trigger_enrichment, + crate::routes::enrichment::get_external_metadata, + crate::routes::enrichment::batch_enrich, + // export + crate::routes::export::trigger_export, + crate::routes::export::trigger_export_with_options, + // health + crate::routes::health::health, + crate::routes::health::liveness, + crate::routes::health::readiness, + crate::routes::health::health_detailed, + // integrity + crate::routes::integrity::trigger_orphan_detection, + crate::routes::integrity::trigger_verify_integrity, + crate::routes::integrity::trigger_cleanup_thumbnails, + crate::routes::integrity::generate_all_thumbnails, + crate::routes::integrity::resolve_orphans, + // jobs + crate::routes::jobs::list_jobs, + crate::routes::jobs::get_job, + crate::routes::jobs::cancel_job, + // media + crate::routes::media::import_media, + crate::routes::media::list_media, + crate::routes::media::get_media, + crate::routes::media::update_media, + crate::routes::media::delete_media, + crate::routes::media::open_media, + crate::routes::media::import_with_options, + crate::routes::media::batch_import, + crate::routes::media::import_directory_endpoint, + crate::routes::media::preview_directory, + crate::routes::media::set_custom_field, + crate::routes::media::delete_custom_field, + crate::routes::media::batch_tag, + crate::routes::media::delete_all_media, + crate::routes::media::batch_delete, + crate::routes::media::batch_add_to_collection, + crate::routes::media::batch_update, + crate::routes::media::get_thumbnail, + crate::routes::media::get_media_count, + crate::routes::media::rename_media, + crate::routes::media::move_media_endpoint, + crate::routes::media::batch_move_media, + crate::routes::media::soft_delete_media, + crate::routes::media::restore_media, + crate::routes::media::list_trash, + crate::routes::media::trash_info, + crate::routes::media::empty_trash, + crate::routes::media::permanent_delete_media, + crate::routes::media::stream_media, + // notes + crate::routes::notes::get_backlinks, + crate::routes::notes::get_outgoing_links, + crate::routes::notes::get_graph, + crate::routes::notes::reindex_links, + crate::routes::notes::resolve_links, + crate::routes::notes::get_unresolved_count, + // photos + crate::routes::photos::get_timeline, + crate::routes::photos::get_map_photos, + // playlists + crate::routes::playlists::create_playlist, + crate::routes::playlists::list_playlists, + crate::routes::playlists::get_playlist, + crate::routes::playlists::update_playlist, + crate::routes::playlists::delete_playlist, + crate::routes::playlists::add_item, + crate::routes::playlists::remove_item, + crate::routes::playlists::list_items, + crate::routes::playlists::reorder_item, + crate::routes::playlists::shuffle_playlist, + // plugins + crate::routes::plugins::list_plugins, + crate::routes::plugins::get_plugin, + crate::routes::plugins::install_plugin, + crate::routes::plugins::uninstall_plugin, + crate::routes::plugins::toggle_plugin, + crate::routes::plugins::list_plugin_ui_pages, + crate::routes::plugins::list_plugin_ui_widgets, + crate::routes::plugins::emit_plugin_event, + crate::routes::plugins::list_plugin_ui_theme_extensions, + crate::routes::plugins::reload_plugin, + // saved_searches + crate::routes::saved_searches::create_saved_search, + crate::routes::saved_searches::list_saved_searches, + crate::routes::saved_searches::delete_saved_search, + // scan + crate::routes::scan::trigger_scan, + crate::routes::scan::scan_status, + // scheduled_tasks + crate::routes::scheduled_tasks::list_scheduled_tasks, + crate::routes::scheduled_tasks::toggle_scheduled_task, + crate::routes::scheduled_tasks::run_scheduled_task_now, + // search + crate::routes::search::search, + crate::routes::search::search_post, + // shares + crate::routes::shares::create_share, + crate::routes::shares::list_outgoing, + crate::routes::shares::list_incoming, + crate::routes::shares::get_share, + crate::routes::shares::update_share, + crate::routes::shares::delete_share, + crate::routes::shares::batch_delete, + crate::routes::shares::access_shared, + crate::routes::shares::get_activity, + crate::routes::shares::get_notifications, + crate::routes::shares::mark_notification_read, + crate::routes::shares::mark_all_read, + // social + crate::routes::social::rate_media, + crate::routes::social::get_media_ratings, + crate::routes::social::add_comment, + crate::routes::social::get_media_comments, + crate::routes::social::add_favorite, + crate::routes::social::remove_favorite, + crate::routes::social::list_favorites, + crate::routes::social::create_share_link, + crate::routes::social::access_shared_media, + // statistics + crate::routes::statistics::library_statistics, + // streaming + crate::routes::streaming::hls_master_playlist, + crate::routes::streaming::hls_variant_playlist, + crate::routes::streaming::hls_segment, + crate::routes::streaming::dash_manifest, + crate::routes::streaming::dash_segment, + // subtitles + crate::routes::subtitles::list_subtitles, + crate::routes::subtitles::add_subtitle, + crate::routes::subtitles::delete_subtitle, + crate::routes::subtitles::get_subtitle_content, + crate::routes::subtitles::update_offset, + // sync + crate::routes::sync::register_device, + crate::routes::sync::list_devices, + crate::routes::sync::get_device, + crate::routes::sync::update_device, + crate::routes::sync::delete_device, + crate::routes::sync::regenerate_token, + crate::routes::sync::get_changes, + crate::routes::sync::report_changes, + crate::routes::sync::acknowledge_changes, + crate::routes::sync::list_conflicts, + crate::routes::sync::resolve_conflict, + crate::routes::sync::create_upload, + crate::routes::sync::upload_chunk, + crate::routes::sync::get_upload_status, + crate::routes::sync::complete_upload, + crate::routes::sync::cancel_upload, + crate::routes::sync::download_file, + // tags + crate::routes::tags::create_tag, + crate::routes::tags::list_tags, + crate::routes::tags::get_tag, + crate::routes::tags::delete_tag, + crate::routes::tags::tag_media, + crate::routes::tags::untag_media, + crate::routes::tags::get_media_tags, + // transcode + crate::routes::transcode::start_transcode, + crate::routes::transcode::get_session, + crate::routes::transcode::list_sessions, + crate::routes::transcode::cancel_session, + // upload + crate::routes::upload::upload_file, + crate::routes::upload::download_file, + crate::routes::upload::move_to_managed, + crate::routes::upload::managed_stats, + // users + crate::routes::users::list_users, + crate::routes::users::create_user, + crate::routes::users::get_user, + crate::routes::users::update_user, + crate::routes::users::delete_user, + crate::routes::users::get_user_libraries, + crate::routes::users::grant_library_access, + crate::routes::users::revoke_library_access, + // webhooks + crate::routes::webhooks::list_webhooks, + crate::routes::webhooks::test_webhook, + ), + components( + schemas( + // analytics DTOs + crate::dto::UsageEventResponse, + crate::dto::RecordUsageEventRequest, + // audit DTOs + crate::dto::AuditEntryResponse, + // auth local types + crate::routes::auth::SessionListResponse, + crate::routes::auth::SessionInfo, + // batch DTOs + crate::dto::BatchTagRequest, + crate::dto::BatchCollectionRequest, + crate::dto::BatchDeleteRequest, + crate::dto::BatchUpdateRequest, + crate::dto::BatchOperationResponse, + // books local types + crate::routes::books::BookMetadataResponse, + crate::routes::books::AuthorResponse, + crate::routes::books::ReadingProgressResponse, + crate::routes::books::UpdateProgressRequest, + crate::routes::books::SeriesSummary, + crate::routes::books::AuthorSummary, + // collections DTOs + crate::dto::CollectionResponse, + crate::dto::CreateCollectionRequest, + crate::dto::AddMemberRequest, + // config DTOs + crate::dto::ConfigResponse, + crate::dto::ScanningConfigResponse, + crate::dto::ServerConfigResponse, + crate::dto::UpdateScanningRequest, + crate::dto::RootDirRequest, + crate::dto::UiConfigResponse, + crate::dto::UpdateUiConfigRequest, + // database DTOs + crate::dto::DatabaseStatsResponse, + // duplicate DTOs + crate::dto::DuplicateGroupResponse, + // enrichment DTOs + crate::dto::ExternalMetadataResponse, + // export local types + crate::routes::export::ExportRequest, + // health local types + crate::routes::health::HealthResponse, + crate::routes::health::DatabaseHealth, + crate::routes::health::FilesystemHealth, + crate::routes::health::CacheHealth, + crate::routes::health::DetailedHealthResponse, + crate::routes::health::JobsHealth, + // integrity local types + crate::routes::integrity::OrphanResolveRequest, + crate::routes::integrity::VerifyIntegrityRequest, + crate::routes::integrity::GenerateThumbnailsRequest, + // media DTOs + crate::dto::MediaResponse, + crate::dto::CustomFieldResponse, + crate::dto::ImportRequest, + crate::dto::ImportWithOptionsRequest, + crate::dto::DirectoryImportRequest, + crate::dto::DirectoryPreviewResponse, + crate::dto::UpdateMediaRequest, + crate::dto::MoveMediaRequest, + crate::dto::RenameMediaRequest, + crate::dto::BatchMoveRequest, + crate::dto::BatchImportRequest, + crate::dto::SetCustomFieldRequest, + crate::dto::MediaCountResponse, + crate::dto::TrashInfoResponse, + crate::dto::ImportResponse, + crate::dto::TrashResponse, + crate::dto::EmptyTrashResponse, + crate::dto::BatchImportResponse, + crate::dto::BatchImportItemResult, + crate::dto::DirectoryPreviewFile, + crate::dto::UpdateMediaFullRequest, + crate::dto::OpenRequest, + crate::dto::WatchProgressRequest, + crate::dto::WatchProgressResponse, + // notes local types + crate::routes::notes::BacklinksResponse, + crate::routes::notes::BacklinkItem, + crate::routes::notes::OutgoingLinksResponse, + crate::routes::notes::OutgoingLinkItem, + crate::routes::notes::GraphResponse, + crate::routes::notes::GraphNodeResponse, + crate::routes::notes::GraphEdgeResponse, + crate::routes::notes::ReindexResponse, + crate::routes::notes::ResolveLinksResponse, + crate::routes::notes::UnresolvedLinksResponse, + // photos local types + crate::routes::photos::TimelineGroup, + crate::routes::photos::MapMarker, + // playlists DTOs + crate::dto::PlaylistResponse, + crate::dto::CreatePlaylistRequest, + crate::dto::UpdatePlaylistRequest, + crate::dto::PlaylistItemRequest, + crate::dto::ReorderPlaylistRequest, + // plugins DTOs + crate::dto::PluginResponse, + crate::dto::InstallPluginRequest, + crate::dto::TogglePluginRequest, + crate::dto::PluginUiPageEntry, + crate::dto::PluginUiWidgetEntry, + crate::dto::PluginEventRequest, + // saved_searches local types + crate::routes::saved_searches::CreateSavedSearchRequest, + crate::routes::saved_searches::SavedSearchResponse, + // scan DTOs + crate::dto::ScanRequest, + crate::dto::ScanResponse, + crate::dto::ScanJobResponse, + crate::dto::ScanStatusResponse, + // search DTOs + crate::dto::SearchParams, + crate::dto::SearchResponse, + crate::dto::SearchRequestBody, + crate::dto::PaginationParams, + // sharing DTOs + crate::dto::CreateShareRequest, + crate::dto::UpdateShareRequest, + crate::dto::ShareResponse, + crate::dto::SharePermissionsRequest, + crate::dto::BatchDeleteSharesRequest, + crate::dto::AccessSharedRequest, + crate::dto::SharedContentResponse, + crate::dto::ShareActivityResponse, + crate::dto::ShareNotificationResponse, + // social DTOs + crate::dto::RatingResponse, + crate::dto::CreateRatingRequest, + crate::dto::CommentResponse, + crate::dto::CreateCommentRequest, + crate::dto::FavoriteRequest, + crate::dto::CreateShareLinkRequest, + crate::dto::ShareLinkResponse, + // statistics DTOs + crate::dto::LibraryStatisticsResponse, + crate::dto::TypeCountResponse, + crate::dto::ScheduledTaskResponse, + // subtitles DTOs + crate::dto::SubtitleResponse, + crate::dto::AddSubtitleRequest, + crate::dto::UpdateSubtitleOffsetRequest, + crate::dto::SubtitleListResponse, + crate::dto::SubtitleTrackInfoResponse, + // sync DTOs + crate::dto::RegisterDeviceRequest, + crate::dto::DeviceResponse, + crate::dto::DeviceRegistrationResponse, + crate::dto::UpdateDeviceRequest, + crate::dto::GetChangesParams, + crate::dto::SyncChangeResponse, + crate::dto::ChangesResponse, + crate::dto::ReportChangesRequest, + crate::dto::ReportChangesResponse, + crate::dto::AcknowledgeChangesRequest, + crate::dto::ConflictResponse, + crate::dto::ResolveConflictRequest, + crate::dto::CreateUploadSessionRequest, + crate::dto::UploadSessionResponse, + crate::dto::ChunkUploadedResponse, + crate::dto::MostViewedResponse, + // tags DTOs + crate::dto::TagResponse, + crate::dto::CreateTagRequest, + crate::dto::TagMediaRequest, + // transcode DTOs + crate::dto::TranscodeSessionResponse, + crate::dto::CreateTranscodeRequest, + // upload DTOs + crate::dto::UploadResponse, + crate::dto::ManagedStorageStatsResponse, + // users DTOs + crate::dto::UserResponse, + crate::dto::UserLibraryResponse, + crate::dto::GrantLibraryAccessRequest, + crate::dto::RevokeLibraryAccessRequest, + // webhooks local types + crate::routes::webhooks::WebhookInfo, + ) + ), + tags( + (name = "analytics", description = "Usage analytics and viewing history"), + (name = "audit", description = "Audit log entries"), + (name = "auth", description = "Authentication and session management"), + (name = "backup", description = "Database backup"), + (name = "books", description = "Book metadata, series, authors, and reading progress"), + (name = "collections", description = "Media collections"), + (name = "config", description = "Server configuration"), + (name = "database", description = "Database administration"), + (name = "duplicates", description = "Duplicate media detection"), + (name = "enrichment", description = "External metadata enrichment"), + (name = "export", description = "Media library export"), + (name = "health", description = "Server health checks"), + (name = "integrity", description = "Library integrity checks and repairs"), + (name = "jobs", description = "Background job management"), + (name = "media", description = "Media item management"), + (name = "notes", description = "Markdown notes link graph"), + (name = "photos", description = "Photo timeline and map view"), + (name = "playlists", description = "Media playlists"), + (name = "plugins", description = "Plugin management"), + (name = "saved_searches", description = "Saved search queries"), + (name = "scan", description = "Directory scanning"), + (name = "scheduled_tasks", description = "Scheduled background tasks"), + (name = "search", description = "Full-text media search"), + (name = "shares", description = "Media sharing and notifications"), + (name = "social", description = "Ratings, comments, favorites, and share links"), + (name = "statistics", description = "Library statistics"), + (name = "streaming", description = "HLS and DASH adaptive streaming"), + (name = "subtitles", description = "Media subtitle management"), + (name = "sync", description = "Multi-device library synchronization"), + (name = "tags", description = "Media tag management"), + (name = "transcode", description = "Video transcoding sessions"), + (name = "upload", description = "File upload and managed storage"), + (name = "users", description = "User and library access management"), + (name = "webhooks", description = "Webhook configuration"), + ), + security( + ("bearer_auth" = []) + ), + modifiers(&SecurityAddon) +)] +pub struct ApiDoc; + +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "bearer_auth", + utoipa::openapi::security::SecurityScheme::Http( + utoipa::openapi::security::Http::new( + utoipa::openapi::security::HttpAuthScheme::Bearer, + ), + ), + ); + } + } +} diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs index f32431c..ed859d0 100644 --- a/crates/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -14,8 +14,10 @@ use tower_http::{ set_header::SetResponseHeaderLayer, trace::TraceLayer, }; +use utoipa::OpenApi as _; +use utoipa_swagger_ui::SwaggerUi; -use crate::{auth, routes, state::AppState}; +use crate::{api_doc::ApiDoc, auth, routes, state::AppState}; /// Create the router with optional TLS configuration for HSTS headers pub fn create_router( @@ -51,6 +53,11 @@ pub fn create_router_with_tls( rate_limits: &pinakes_core::config::RateLimitConfig, tls_config: Option<&pinakes_core::config::TlsConfig>, ) -> Router { + let swagger_ui_enabled = state + .config + .try_read() + .map_or(false, |cfg| cfg.server.swagger_ui); + let global_governor = build_governor( rate_limits.global_per_second, rate_limits.global_burst_size, @@ -605,7 +612,7 @@ pub fn create_router_with_tls( HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"), )); - let router = Router::new() + let base_router = Router::new() .nest("/api/v1", full_api) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(GovernorLayer::new(global_governor)) @@ -613,6 +620,14 @@ pub fn create_router_with_tls( .layer(cors) .layer(security_headers); + let router = if swagger_ui_enabled { + base_router.merge( + SwaggerUi::new("/api/docs").url("/api/openapi.json", ApiDoc::openapi()), + ) + } else { + base_router + }; + // Add HSTS header when TLS is enabled if let Some(tls) = tls_config { if tls.enabled && tls.hsts_enabled { diff --git a/crates/pinakes-server/src/dto/analytics.rs b/crates/pinakes-server/src/dto/analytics.rs index c68289d..456d8fe 100644 --- a/crates/pinakes-server/src/dto/analytics.rs +++ b/crates/pinakes-server/src/dto/analytics.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UsageEventResponse { pub id: String, pub media_id: Option, @@ -25,10 +25,11 @@ impl From for UsageEventResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RecordUsageEventRequest { pub media_id: Option, pub event_type: String, pub duration_secs: Option, + #[schema(value_type = Option)] pub context: Option, } diff --git a/crates/pinakes-server/src/dto/audit.rs b/crates/pinakes-server/src/dto/audit.rs index 7a71fd0..d0df363 100644 --- a/crates/pinakes-server/src/dto/audit.rs +++ b/crates/pinakes-server/src/dto/audit.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct AuditEntryResponse { pub id: String, pub media_id: Option, diff --git a/crates/pinakes-server/src/dto/batch.rs b/crates/pinakes-server/src/dto/batch.rs index 0389762..a56596c 100644 --- a/crates/pinakes-server/src/dto/batch.rs +++ b/crates/pinakes-server/src/dto/batch.rs @@ -1,24 +1,24 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchTagRequest { pub media_ids: Vec, pub tag_ids: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchCollectionRequest { pub media_ids: Vec, pub collection_id: Uuid, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchDeleteRequest { pub media_ids: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchUpdateRequest { pub media_ids: Vec, pub title: Option, @@ -29,7 +29,7 @@ pub struct BatchUpdateRequest { pub description: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BatchOperationResponse { pub processed: usize, pub errors: Vec, diff --git a/crates/pinakes-server/src/dto/collections.rs b/crates/pinakes-server/src/dto/collections.rs index 04adcdb..14b8a86 100644 --- a/crates/pinakes-server/src/dto/collections.rs +++ b/crates/pinakes-server/src/dto/collections.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CollectionResponse { pub id: String, pub name: String, @@ -13,7 +13,7 @@ pub struct CollectionResponse { pub updated_at: DateTime, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateCollectionRequest { pub name: String, pub kind: String, @@ -21,7 +21,7 @@ pub struct CreateCollectionRequest { pub filter_query: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct AddMemberRequest { pub media_id: Uuid, pub position: Option, diff --git a/crates/pinakes-server/src/dto/config.rs b/crates/pinakes-server/src/dto/config.rs index 024c683..6252c53 100644 --- a/crates/pinakes-server/src/dto/config.rs +++ b/crates/pinakes-server/src/dto/config.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ConfigResponse { pub backend: String, pub database_path: Option, @@ -12,33 +12,33 @@ pub struct ConfigResponse { pub config_writable: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScanningConfigResponse { pub watch: bool, pub poll_interval_secs: u64, pub ignore_patterns: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ServerConfigResponse { pub host: String, pub port: u16, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateScanningRequest { pub watch: Option, pub poll_interval_secs: Option, pub ignore_patterns: Option>, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RootDirRequest { pub path: String, } // UI Config -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct UiConfigResponse { pub theme: String, pub default_view: String, @@ -49,7 +49,7 @@ pub struct UiConfigResponse { pub sidebar_collapsed: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateUiConfigRequest { pub theme: Option, pub default_view: Option, diff --git a/crates/pinakes-server/src/dto/enrichment.rs b/crates/pinakes-server/src/dto/enrichment.rs index c1ec7b0..4e144de 100644 --- a/crates/pinakes-server/src/dto/enrichment.rs +++ b/crates/pinakes-server/src/dto/enrichment.rs @@ -1,12 +1,13 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ExternalMetadataResponse { pub id: String, pub media_id: String, pub source: String, pub external_id: Option, + #[schema(value_type = Object)] pub metadata: serde_json::Value, pub confidence: f64, pub last_updated: DateTime, diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index ffed427..8e269ab 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -34,7 +34,7 @@ pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String { full_path.to_string_lossy().into_owned() } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MediaResponse { pub id: String, pub path: String, @@ -50,6 +50,7 @@ pub struct MediaResponse { pub duration_secs: Option, pub description: Option, pub has_thumbnail: bool, + #[schema(value_type = Object)] pub custom_fields: FxHashMap, // Photo-specific metadata @@ -67,24 +68,25 @@ pub struct MediaResponse { pub links_extracted_at: Option>, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CustomFieldResponse { pub field_type: String, pub value: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ImportRequest { + #[schema(value_type = String)] pub path: PathBuf, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ImportResponse { pub media_id: String, pub was_duplicate: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateMediaRequest { pub title: Option, pub artist: Option, @@ -95,56 +97,60 @@ pub struct UpdateMediaRequest { } // File Management -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RenameMediaRequest { pub new_name: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct MoveMediaRequest { + #[schema(value_type = String)] pub destination: PathBuf, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchMoveRequest { pub media_ids: Vec, + #[schema(value_type = String)] pub destination: PathBuf, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TrashResponse { pub items: Vec, pub total_count: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TrashInfoResponse { pub count: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct EmptyTrashResponse { pub deleted_count: u64, } // Enhanced Import -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ImportWithOptionsRequest { + #[schema(value_type = String)] pub path: PathBuf, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchImportRequest { + #[schema(value_type = Vec)] pub paths: Vec, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BatchImportResponse { pub results: Vec, pub total: usize, @@ -153,7 +159,7 @@ pub struct BatchImportResponse { pub errors: usize, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BatchImportItemResult { pub path: String, pub media_id: Option, @@ -161,22 +167,23 @@ pub struct BatchImportItemResult { pub error: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct DirectoryImportRequest { + #[schema(value_type = String)] pub path: PathBuf, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DirectoryPreviewResponse { pub files: Vec, pub total_count: usize, pub total_size: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DirectoryPreviewFile { pub path: String, pub file_name: String, @@ -185,7 +192,7 @@ pub struct DirectoryPreviewFile { } // Custom Fields -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct SetCustomFieldRequest { pub name: String, pub field_type: String, @@ -193,7 +200,7 @@ pub struct SetCustomFieldRequest { } // Media update extended -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateMediaFullRequest { pub title: Option, pub artist: Option, @@ -204,26 +211,26 @@ pub struct UpdateMediaFullRequest { } // Search with sort -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MediaCountResponse { pub count: u64, } // Duplicates -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DuplicateGroupResponse { pub content_hash: String, pub items: Vec, } // Open -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct OpenRequest { pub media_id: Uuid, } // Upload -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UploadResponse { pub media_id: String, pub content_hash: String, @@ -242,7 +249,7 @@ impl From for UploadResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ManagedStorageStatsResponse { pub total_blobs: u64, pub total_size_bytes: u64, @@ -368,12 +375,12 @@ mod tests { } // Watch progress -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct WatchProgressRequest { pub progress_secs: f64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct WatchProgressResponse { pub progress_secs: f64, } diff --git a/crates/pinakes-server/src/dto/playlists.rs b/crates/pinakes-server/src/dto/playlists.rs index af387b1..c054df5 100644 --- a/crates/pinakes-server/src/dto/playlists.rs +++ b/crates/pinakes-server/src/dto/playlists.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct PlaylistResponse { pub id: String, pub owner_id: String, @@ -31,7 +31,7 @@ impl From for PlaylistResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreatePlaylistRequest { pub name: String, pub description: Option, @@ -40,20 +40,20 @@ pub struct CreatePlaylistRequest { pub filter_query: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdatePlaylistRequest { pub name: Option, pub description: Option, pub is_public: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct PlaylistItemRequest { pub media_id: Uuid, pub position: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ReorderPlaylistRequest { pub media_id: Uuid, pub new_position: i32, diff --git a/crates/pinakes-server/src/dto/plugins.rs b/crates/pinakes-server/src/dto/plugins.rs index a80a1f2..661111a 100644 --- a/crates/pinakes-server/src/dto/plugins.rs +++ b/crates/pinakes-server/src/dto/plugins.rs @@ -1,7 +1,7 @@ use pinakes_plugin_api::{UiPage, UiWidget}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct PluginResponse { pub id: String, pub name: String, @@ -12,22 +12,23 @@ pub struct PluginResponse { pub enabled: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct InstallPluginRequest { pub source: String, // URL or file path } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct TogglePluginRequest { pub enabled: bool, } /// A single plugin UI page entry in the list response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct PluginUiPageEntry { /// Plugin ID that provides this page pub plugin_id: String, /// Full page definition + #[schema(value_type = Object)] pub page: UiPage, /// Endpoint paths this plugin is allowed to fetch (empty means no /// restriction) @@ -35,19 +36,21 @@ pub struct PluginUiPageEntry { } /// A single plugin UI widget entry in the list response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct PluginUiWidgetEntry { /// Plugin ID that provides this widget pub plugin_id: String, /// Full widget definition + #[schema(value_type = Object)] pub widget: UiWidget, } /// Request body for emitting a plugin event -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct PluginEventRequest { pub event: String, #[serde(default)] + #[schema(value_type = Object)] pub payload: serde_json::Value, } diff --git a/crates/pinakes-server/src/dto/scan.rs b/crates/pinakes-server/src/dto/scan.rs index 86c4805..c546e0b 100644 --- a/crates/pinakes-server/src/dto/scan.rs +++ b/crates/pinakes-server/src/dto/scan.rs @@ -2,24 +2,25 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ScanRequest { + #[schema(value_type = Option)] pub path: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScanResponse { pub files_found: usize, pub files_processed: usize, pub errors: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScanJobResponse { pub job_id: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScanStatusResponse { pub scanning: bool, pub files_found: usize, diff --git a/crates/pinakes-server/src/dto/search.rs b/crates/pinakes-server/src/dto/search.rs index dfe2576..9421bef 100644 --- a/crates/pinakes-server/src/dto/search.rs +++ b/crates/pinakes-server/src/dto/search.rs @@ -9,7 +9,7 @@ pub const MAX_OFFSET: u64 = 10_000_000; /// Maximum page size accepted from most listing endpoints. pub const MAX_LIMIT: u64 = 1000; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct SearchParams { pub q: String, pub sort: Option, @@ -28,14 +28,14 @@ impl SearchParams { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SearchResponse { pub items: Vec, pub total_count: u64, } // Search (POST body) -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct SearchRequestBody { pub q: String, pub sort: Option, @@ -55,7 +55,7 @@ impl SearchRequestBody { } // Pagination -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct PaginationParams { pub offset: Option, pub limit: Option, diff --git a/crates/pinakes-server/src/dto/sharing.rs b/crates/pinakes-server/src/dto/sharing.rs index 60f60ab..4757e26 100644 --- a/crates/pinakes-server/src/dto/sharing.rs +++ b/crates/pinakes-server/src/dto/sharing.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateShareRequest { pub target_type: String, pub target_id: String, @@ -16,7 +16,7 @@ pub struct CreateShareRequest { pub inherit_to_children: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct SharePermissionsRequest { pub can_view: Option, pub can_download: Option, @@ -26,7 +26,7 @@ pub struct SharePermissionsRequest { pub can_add: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ShareResponse { pub id: String, pub target_type: String, @@ -46,7 +46,7 @@ pub struct ShareResponse { pub updated_at: DateTime, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SharePermissionsResponse { pub can_view: bool, pub can_download: bool, @@ -125,7 +125,7 @@ impl From for ShareResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateShareRequest { pub permissions: Option, pub note: Option, @@ -133,7 +133,7 @@ pub struct UpdateShareRequest { pub inherit_to_children: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ShareActivityResponse { pub id: String, pub share_id: String, @@ -158,7 +158,7 @@ impl From for ShareActivityResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ShareNotificationResponse { pub id: String, pub share_id: String, @@ -181,12 +181,12 @@ impl From } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchDeleteSharesRequest { pub share_ids: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct AccessSharedRequest { pub password: Option, } @@ -194,7 +194,7 @@ pub struct AccessSharedRequest { /// Response for accessing shared content. /// Single-media shares return the media object directly (backwards compatible). /// Collection/Tag/SavedSearch shares return a list of items. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] #[serde(untagged)] pub enum SharedContentResponse { Single(super::MediaResponse), diff --git a/crates/pinakes-server/src/dto/social.rs b/crates/pinakes-server/src/dto/social.rs index 43f192f..d52d85d 100644 --- a/crates/pinakes-server/src/dto/social.rs +++ b/crates/pinakes-server/src/dto/social.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct RatingResponse { pub id: String, pub user_id: String, @@ -25,13 +25,13 @@ impl From for RatingResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateRatingRequest { pub stars: u8, pub review_text: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CommentResponse { pub id: String, pub user_id: String, @@ -54,25 +54,25 @@ impl From for CommentResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateCommentRequest { pub text: String, pub parent_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct FavoriteRequest { pub media_id: Uuid, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateShareLinkRequest { pub media_id: Uuid, pub password: Option, pub expires_in_hours: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ShareLinkResponse { pub id: String, pub media_id: String, diff --git a/crates/pinakes-server/src/dto/statistics.rs b/crates/pinakes-server/src/dto/statistics.rs index b5d573e..a430409 100644 --- a/crates/pinakes-server/src/dto/statistics.rs +++ b/crates/pinakes-server/src/dto/statistics.rs @@ -1,7 +1,7 @@ use serde::Serialize; // Library Statistics -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct LibraryStatisticsResponse { pub total_media: u64, pub total_size_bytes: u64, @@ -17,7 +17,7 @@ pub struct LibraryStatisticsResponse { pub total_duplicates: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TypeCountResponse { pub name: String, pub count: u64, @@ -61,7 +61,7 @@ impl From } // Database management -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DatabaseStatsResponse { pub media_count: u64, pub tag_count: u64, @@ -72,7 +72,7 @@ pub struct DatabaseStatsResponse { } // Scheduled Tasks -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScheduledTaskResponse { pub id: String, pub name: String, diff --git a/crates/pinakes-server/src/dto/sync.rs b/crates/pinakes-server/src/dto/sync.rs index 95993ed..34b2056 100644 --- a/crates/pinakes-server/src/dto/sync.rs +++ b/crates/pinakes-server/src/dto/sync.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use super::media::MediaResponse; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RegisterDeviceRequest { pub name: String, pub device_type: String, @@ -11,7 +11,7 @@ pub struct RegisterDeviceRequest { pub os_info: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DeviceResponse { pub id: String, pub name: String, @@ -42,25 +42,25 @@ impl From for DeviceResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DeviceRegistrationResponse { pub device: DeviceResponse, pub device_token: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateDeviceRequest { pub name: Option, pub enabled: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct GetChangesParams { pub cursor: Option, pub limit: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SyncChangeResponse { pub id: String, pub sequence: i64, @@ -87,14 +87,14 @@ impl From for SyncChangeResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ChangesResponse { pub changes: Vec, pub cursor: i64, pub has_more: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ClientChangeReport { pub path: String, pub change_type: String, @@ -103,19 +103,19 @@ pub struct ClientChangeReport { pub local_mtime: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ReportChangesRequest { pub changes: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ReportChangesResponse { pub accepted: Vec, pub conflicts: Vec, pub upload_required: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ConflictResponse { pub id: String, pub path: String, @@ -136,12 +136,12 @@ impl From for ConflictResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ResolveConflictRequest { pub resolution: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateUploadSessionRequest { pub target_path: String, pub expected_hash: String, @@ -149,7 +149,7 @@ pub struct CreateUploadSessionRequest { pub chunk_size: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UploadSessionResponse { pub id: String, pub target_path: String, @@ -178,19 +178,19 @@ impl From for UploadSessionResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ChunkUploadedResponse { pub chunk_index: u64, pub received: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct AcknowledgeChangesRequest { pub cursor: i64, } // Most viewed (uses MediaResponse) -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MostViewedResponse { pub media: MediaResponse, pub view_count: u64, diff --git a/crates/pinakes-server/src/dto/tags.rs b/crates/pinakes-server/src/dto/tags.rs index 20f1bec..032d437 100644 --- a/crates/pinakes-server/src/dto/tags.rs +++ b/crates/pinakes-server/src/dto/tags.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TagResponse { pub id: String, pub name: String, @@ -10,13 +10,13 @@ pub struct TagResponse { pub created_at: DateTime, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateTagRequest { pub name: String, pub parent_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct TagMediaRequest { pub tag_id: Uuid, } diff --git a/crates/pinakes-server/src/dto/transcode.rs b/crates/pinakes-server/src/dto/transcode.rs index 4a828a2..6b3debf 100644 --- a/crates/pinakes-server/src/dto/transcode.rs +++ b/crates/pinakes-server/src/dto/transcode.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TranscodeSessionResponse { pub id: String, pub media_id: String, @@ -28,7 +28,7 @@ impl From } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateTranscodeRequest { pub profile: String, } diff --git a/crates/pinakes-server/src/dto/users.rs b/crates/pinakes-server/src/dto/users.rs index f657dab..d0567c8 100644 --- a/crates/pinakes-server/src/dto/users.rs +++ b/crates/pinakes-server/src/dto/users.rs @@ -2,27 +2,27 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; // Auth -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct LoginRequest { pub username: String, pub password: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct LoginResponse { pub token: String, pub username: String, pub role: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserInfoResponse { pub username: String, pub role: String, } // Users -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserResponse { pub id: String, pub username: String, @@ -32,14 +32,14 @@ pub struct UserResponse { pub updated_at: DateTime, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserProfileResponse { pub avatar_path: Option, pub bio: Option, pub preferences: UserPreferencesResponse, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserPreferencesResponse { pub theme: Option, pub language: Option, @@ -47,7 +47,7 @@ pub struct UserPreferencesResponse { pub auto_play: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserLibraryResponse { pub user_id: String, pub root_path: String, @@ -55,13 +55,14 @@ pub struct UserLibraryResponse { pub granted_at: DateTime, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct GrantLibraryAccessRequest { pub root_path: String, + #[schema(value_type = String)] pub permission: pinakes_core::users::LibraryPermission, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RevokeLibraryAccessRequest { pub root_path: String, } diff --git a/crates/pinakes-server/src/error.rs b/crates/pinakes-server/src/error.rs index 8a4f345..c18592d 100644 --- a/crates/pinakes-server/src/error.rs +++ b/crates/pinakes-server/src/error.rs @@ -44,6 +44,25 @@ impl IntoResponse for ApiError { PinakesError::InvalidOperation(msg) => { (StatusCode::BAD_REQUEST, msg.clone()) }, + PinakesError::InvalidLanguageCode(code) => { + ( + StatusCode::BAD_REQUEST, + format!("invalid language code: {code}"), + ) + }, + PinakesError::SubtitleTrackNotFound { index } => { + ( + StatusCode::NOT_FOUND, + format!("subtitle track {index} not found in media"), + ) + }, + PinakesError::ExternalTool { tool, .. } => { + tracing::error!(tool = %tool, error = %self.0, "external tool failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("external tool `{tool}` failed"), + ) + }, PinakesError::Authentication(msg) => { (StatusCode::UNAUTHORIZED, msg.clone()) }, diff --git a/crates/pinakes-server/src/lib.rs b/crates/pinakes-server/src/lib.rs index 6f386df..4299f9a 100644 --- a/crates/pinakes-server/src/lib.rs +++ b/crates/pinakes-server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod api_doc; pub mod app; pub mod auth; pub mod dto; diff --git a/crates/pinakes-server/src/routes/analytics.rs b/crates/pinakes-server/src/routes/analytics.rs index 1698061..fda8fd9 100644 --- a/crates/pinakes-server/src/routes/analytics.rs +++ b/crates/pinakes-server/src/routes/analytics.rs @@ -24,6 +24,21 @@ use crate::{ const MAX_LIMIT: u64 = 100; +#[utoipa::path( + get, + path = "/api/v1/analytics/most-viewed", + tag = "analytics", + params( + ("limit" = Option, Query, description = "Maximum number of results"), + ("offset" = Option, Query, description = "Pagination offset"), + ), + responses( + (status = 200, description = "Most viewed media", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_most_viewed( State(state): State, Query(params): Query, @@ -44,6 +59,21 @@ pub async fn get_most_viewed( )) } +#[utoipa::path( + get, + path = "/api/v1/analytics/recently-viewed", + tag = "analytics", + params( + ("limit" = Option, Query, description = "Maximum number of results"), + ("offset" = Option, Query, description = "Pagination offset"), + ), + responses( + (status = 200, description = "Recently viewed media", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_recently_viewed( State(state): State, Extension(username): Extension, @@ -61,6 +91,18 @@ pub async fn get_recently_viewed( )) } +#[utoipa::path( + post, + path = "/api/v1/analytics/events", + tag = "analytics", + request_body = RecordUsageEventRequest, + responses( + (status = 200, description = "Event recorded"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn record_event( State(state): State, Extension(username): Extension, @@ -84,6 +126,21 @@ pub async fn record_event( Ok(Json(serde_json::json!({"recorded": true}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/progress", + tag = "analytics", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ), + responses( + (status = 200, description = "Watch progress", body = WatchProgressResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_watch_progress( State(state): State, Extension(username): Extension, @@ -100,6 +157,23 @@ pub async fn get_watch_progress( })) } +#[utoipa::path( + put, + path = "/api/v1/media/{id}/progress", + tag = "analytics", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ), + request_body = WatchProgressRequest, + responses( + (status = 200, description = "Progress updated"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_watch_progress( State(state): State, Extension(username): Extension, diff --git a/crates/pinakes-server/src/routes/audit.rs b/crates/pinakes-server/src/routes/audit.rs index 7a32067..80ccd10 100644 --- a/crates/pinakes-server/src/routes/audit.rs +++ b/crates/pinakes-server/src/routes/audit.rs @@ -9,6 +9,21 @@ use crate::{ state::AppState, }; +#[utoipa::path( + get, + path = "/api/v1/audit", + tag = "audit", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Page size"), + ), + responses( + (status = 200, description = "Audit log entries", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_audit( State(state): State, Query(params): Query, diff --git a/crates/pinakes-server/src/routes/auth.rs b/crates/pinakes-server/src/routes/auth.rs index 3b4672e..a4561f5 100644 --- a/crates/pinakes-server/src/routes/auth.rs +++ b/crates/pinakes-server/src/routes/auth.rs @@ -17,6 +17,19 @@ const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,\ p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk"; +#[utoipa::path( + post, + path = "/api/v1/auth/login", + tag = "auth", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = LoginResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid credentials"), + (status = 500, description = "Internal server error"), + ), + security() +)] pub async fn login( State(state): State, Json(req): Json, @@ -82,6 +95,7 @@ pub async fn login( let user = user.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; // Generate session token using unbiased uniform distribution + #[expect(clippy::expect_used)] let token: String = { use rand::seq::IndexedRandom; const CHARSET: &[u8] = @@ -134,39 +148,64 @@ pub async fn login( })) } +#[utoipa::path( + post, + path = "/api/v1/auth/logout", + tag = "auth", + responses( + (status = 200, description = "Logged out"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn logout( State(state): State, headers: HeaderMap, ) -> StatusCode { - if let Some(token) = extract_bearer_token(&headers) { - // Get username before deleting session - let username = match state.storage.get_session(token).await { - Ok(Some(session)) => Some(session.username), - _ => None, - }; + let Some(token) = extract_bearer_token(&headers) else { + return StatusCode::UNAUTHORIZED; + }; - // Delete session from database - if let Err(e) = state.storage.delete_session(token).await { - tracing::error!(error = %e, "failed to delete session from database"); - return StatusCode::INTERNAL_SERVER_ERROR; - } + // Get username before deleting session + let username = match state.storage.get_session(token).await { + Ok(Some(session)) => Some(session.username), + _ => None, + }; - // Record logout in audit log - if let Some(user) = username - && let Err(e) = pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::Logout, - Some(format!("username: {user}")), - ) - .await - { - tracing::warn!(error = %e, "failed to record logout audit"); - } + // Delete session from database + if let Err(e) = state.storage.delete_session(token).await { + tracing::error!(error = %e, "failed to delete session from database"); + return StatusCode::INTERNAL_SERVER_ERROR; } + + // Record logout in audit log + if let Some(user) = username + && let Err(e) = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Logout, + Some(format!("username: {user}")), + ) + .await + { + tracing::warn!(error = %e, "failed to record logout audit"); + } + StatusCode::OK } +#[utoipa::path( + get, + path = "/api/v1/auth/me", + tag = "auth", + responses( + (status = 200, description = "Current user info", body = UserInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn me( State(state): State, headers: HeaderMap, @@ -204,6 +243,17 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { /// Refresh the current session, extending its expiry by the configured /// duration. +#[utoipa::path( + post, + path = "/api/v1/auth/refresh", + tag = "auth", + responses( + (status = 200, description = "Session refreshed"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn refresh( State(state): State, headers: HeaderMap, @@ -232,6 +282,17 @@ pub async fn refresh( } /// Revoke all sessions for the current user +#[utoipa::path( + post, + path = "/api/v1/auth/revoke-all", + tag = "auth", + responses( + (status = 200, description = "All sessions revoked"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn revoke_all_sessions( State(state): State, headers: HeaderMap, @@ -280,12 +341,12 @@ pub async fn revoke_all_sessions( } /// List all active sessions (admin only) -#[derive(serde::Serialize)] +#[derive(serde::Serialize, utoipa::ToSchema)] pub struct SessionListResponse { pub sessions: Vec, } -#[derive(serde::Serialize)] +#[derive(serde::Serialize, utoipa::ToSchema)] pub struct SessionInfo { pub username: String, pub role: String, @@ -294,6 +355,18 @@ pub struct SessionInfo { pub expires_at: String, } +#[utoipa::path( + get, + path = "/api/v1/auth/sessions", + tag = "auth", + responses( + (status = 200, description = "Active sessions", body = SessionListResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_active_sessions( State(state): State, ) -> Result, StatusCode> { diff --git a/crates/pinakes-server/src/routes/backup.rs b/crates/pinakes-server/src/routes/backup.rs index 4af2b18..d80b31f 100644 --- a/crates/pinakes-server/src/routes/backup.rs +++ b/crates/pinakes-server/src/routes/backup.rs @@ -11,6 +11,18 @@ use crate::{error::ApiError, state::AppState}; /// /// For `SQLite`: creates a backup via VACUUM INTO and returns the file. /// For `PostgreSQL`: returns unsupported error (use `pg_dump` instead). +#[utoipa::path( + post, + path = "/api/v1/admin/backup", + tag = "backup", + responses( + (status = 200, description = "Backup file download"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_backup( State(state): State, ) -> Result { diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index 9c83b64..9993492 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -29,7 +29,7 @@ use crate::{ }; /// Book metadata response DTO -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct BookMetadataResponse { pub media_id: Uuid, pub isbn: Option, @@ -42,6 +42,7 @@ pub struct BookMetadataResponse { pub series_index: Option, pub format: Option, pub authors: Vec, + #[schema(value_type = Object)] pub identifiers: FxHashMap>, } @@ -69,7 +70,7 @@ impl From for BookMetadataResponse { } /// Author response DTO -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct AuthorResponse { pub name: String, pub role: String, @@ -89,7 +90,7 @@ impl From for AuthorResponse { } /// Reading progress response DTO -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ReadingProgressResponse { pub media_id: Uuid, pub user_id: Uuid, @@ -113,7 +114,7 @@ impl From for ReadingProgressResponse { } /// Update reading progress request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateProgressRequest { pub current_page: i32, } @@ -141,20 +142,32 @@ const fn default_limit() -> u64 { } /// Series summary DTO -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SeriesSummary { pub name: String, pub book_count: u64, } /// Author summary DTO -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct AuthorSummary { pub name: String, pub book_count: u64, } /// Get book metadata by media ID +#[utoipa::path( + get, + path = "/api/v1/books/{id}/metadata", + tag = "books", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Book metadata", body = BookMetadataResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_book_metadata( State(state): State, Path(media_id): Path, @@ -173,6 +186,26 @@ pub async fn get_book_metadata( } /// List all books with optional search filters +#[utoipa::path( + get, + path = "/api/v1/books", + tag = "books", + params( + ("isbn" = Option, Query, description = "Filter by ISBN"), + ("author" = Option, Query, description = "Filter by author"), + ("series" = Option, Query, description = "Filter by series"), + ("publisher" = Option, Query, description = "Filter by publisher"), + ("language" = Option, Query, description = "Filter by language"), + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "List of books", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_books( State(state): State, Query(query): Query, @@ -204,6 +237,16 @@ pub async fn list_books( } /// List all series with book counts +#[utoipa::path( + get, + path = "/api/v1/books/series", + tag = "books", + responses( + (status = 200, description = "List of series with counts", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_series( State(state): State, ) -> Result { @@ -222,6 +265,17 @@ pub async fn list_series( } /// Get books in a specific series +#[utoipa::path( + get, + path = "/api/v1/books/series/{name}", + tag = "books", + params(("name" = String, Path, description = "Series name")), + responses( + (status = 200, description = "Books in series", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_series_books( State(state): State, Path(series_name): Path, @@ -236,6 +290,20 @@ pub async fn get_series_books( } /// List all authors with book counts +#[utoipa::path( + get, + path = "/api/v1/books/authors", + tag = "books", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Authors with book counts", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_authors( State(state): State, Query(pagination): Query, @@ -255,6 +323,21 @@ pub async fn list_authors( } /// Get books by a specific author +#[utoipa::path( + get, + path = "/api/v1/books/authors/{name}/books", + tag = "books", + params( + ("name" = String, Path, description = "Author name"), + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Books by author", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_author_books( State(state): State, Path(author_name): Path, @@ -274,6 +357,18 @@ pub async fn get_author_books( } /// Get reading progress for a book +#[utoipa::path( + get, + path = "/api/v1/books/{id}/progress", + tag = "books", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Reading progress", body = ReadingProgressResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_reading_progress( State(state): State, Extension(username): Extension, @@ -294,6 +389,19 @@ pub async fn get_reading_progress( } /// Update reading progress for a book +#[utoipa::path( + put, + path = "/api/v1/books/{id}/progress", + tag = "books", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = UpdateProgressRequest, + responses( + (status = 204, description = "Progress updated"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_reading_progress( State(state): State, Extension(username): Extension, @@ -306,6 +414,10 @@ pub async fn update_reading_progress( let user_id = resolve_user_id(&state.storage, &username).await?; let media_id = MediaId(media_id); + // Verify the media item exists before writing progress; a FK violation from + // the storage layer would otherwise surface as a 500 rather than 404. + state.storage.get_media(media_id).await?; + state .storage .update_reading_progress(user_id.0, media_id, req.current_page) @@ -315,6 +427,17 @@ pub async fn update_reading_progress( } /// Get user's reading list +#[utoipa::path( + get, + path = "/api/v1/books/reading-list", + tag = "books", + params(("status" = Option, Query, description = "Filter by reading status. Valid values: to_read, reading, completed, abandoned")), + responses( + (status = 200, description = "Reading list", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_reading_list( State(state): State, Extension(username): Extension, diff --git a/crates/pinakes-server/src/routes/collections.rs b/crates/pinakes-server/src/routes/collections.rs index c746fa8..a1df04d 100644 --- a/crates/pinakes-server/src/routes/collections.rs +++ b/crates/pinakes-server/src/routes/collections.rs @@ -16,6 +16,20 @@ use crate::{ state::AppState, }; +#[utoipa::path( + post, + path = "/api/v1/collections", + tag = "collections", + request_body = CreateCollectionRequest, + responses( + (status = 200, description = "Collection created", body = CollectionResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_collection( State(state): State, Json(req): Json, @@ -60,6 +74,17 @@ pub async fn create_collection( Ok(Json(CollectionResponse::from(col))) } +#[utoipa::path( + get, + path = "/api/v1/collections", + tag = "collections", + responses( + (status = 200, description = "List of collections", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_collections( State(state): State, ) -> Result>, ApiError> { @@ -69,6 +94,19 @@ pub async fn list_collections( )) } +#[utoipa::path( + get, + path = "/api/v1/collections/{id}", + tag = "collections", + params(("id" = Uuid, Path, description = "Collection ID")), + responses( + (status = 200, description = "Collection", body = CollectionResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_collection( State(state): State, Path(id): Path, @@ -77,6 +115,20 @@ pub async fn get_collection( Ok(Json(CollectionResponse::from(col))) } +#[utoipa::path( + delete, + path = "/api/v1/collections/{id}", + tag = "collections", + params(("id" = Uuid, Path, description = "Collection ID")), + responses( + (status = 200, description = "Collection deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_collection( State(state): State, Path(id): Path, @@ -91,6 +143,21 @@ pub async fn delete_collection( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/collections/{id}/members", + tag = "collections", + params(("id" = Uuid, Path, description = "Collection ID")), + request_body = AddMemberRequest, + responses( + (status = 200, description = "Member added"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_member( State(state): State, Path(collection_id): Path, @@ -106,6 +173,23 @@ pub async fn add_member( Ok(Json(serde_json::json!({"added": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/collections/{id}/members/{media_id}", + tag = "collections", + params( + ("id" = Uuid, Path, description = "Collection ID"), + ("media_id" = Uuid, Path, description = "Media item ID"), + ), + responses( + (status = 200, description = "Member removed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn remove_member( State(state): State, Path((collection_id, media_id)): Path<(Uuid, Uuid)>, @@ -119,6 +203,19 @@ pub async fn remove_member( Ok(Json(serde_json::json!({"removed": true}))) } +#[utoipa::path( + get, + path = "/api/v1/collections/{id}/members", + tag = "collections", + params(("id" = Uuid, Path, description = "Collection ID")), + responses( + (status = 200, description = "Collection members", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_members( State(state): State, Path(collection_id): Path, diff --git a/crates/pinakes-server/src/routes/config.rs b/crates/pinakes-server/src/routes/config.rs index 2311178..7a76f83 100644 --- a/crates/pinakes-server/src/routes/config.rs +++ b/crates/pinakes-server/src/routes/config.rs @@ -14,6 +14,18 @@ use crate::{ state::AppState, }; +#[utoipa::path( + get, + path = "/api/v1/config", + tag = "config", + responses( + (status = 200, description = "Current server configuration", body = ConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_config( State(state): State, ) -> Result, ApiError> { @@ -63,6 +75,17 @@ pub async fn get_config( })) } +#[utoipa::path( + get, + path = "/api/v1/config/ui", + tag = "config", + responses( + (status = 200, description = "UI configuration", body = UiConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_ui_config( State(state): State, ) -> Result, ApiError> { @@ -70,6 +93,19 @@ pub async fn get_ui_config( Ok(Json(UiConfigResponse::from(&config.ui))) } +#[utoipa::path( + patch, + path = "/api/v1/config/ui", + tag = "config", + request_body = UpdateUiConfigRequest, + responses( + (status = 200, description = "Updated UI configuration", body = UiConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_ui_config( State(state): State, Json(req): Json, @@ -104,6 +140,19 @@ pub async fn update_ui_config( Ok(Json(UiConfigResponse::from(&config.ui))) } +#[utoipa::path( + patch, + path = "/api/v1/config/scanning", + tag = "config", + request_body = UpdateScanningRequest, + responses( + (status = 200, description = "Updated configuration", body = ConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_scanning_config( State(state): State, Json(req): Json, @@ -169,6 +218,20 @@ pub async fn update_scanning_config( })) } +#[utoipa::path( + post, + path = "/api/v1/config/roots", + tag = "config", + request_body = RootDirRequest, + responses( + (status = 200, description = "Updated configuration", body = ConfigResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_root( State(state): State, Json(req): Json, @@ -196,6 +259,19 @@ pub async fn add_root( get_config(State(state)).await } +#[utoipa::path( + delete, + path = "/api/v1/config/roots", + tag = "config", + request_body = RootDirRequest, + responses( + (status = 200, description = "Updated configuration", body = ConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn remove_root( State(state): State, Json(req): Json, diff --git a/crates/pinakes-server/src/routes/database.rs b/crates/pinakes-server/src/routes/database.rs index 4c71cde..e88fcb8 100644 --- a/crates/pinakes-server/src/routes/database.rs +++ b/crates/pinakes-server/src/routes/database.rs @@ -2,6 +2,18 @@ use axum::{Json, extract::State}; use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState}; +#[utoipa::path( + get, + path = "/api/v1/admin/database/stats", + tag = "database", + responses( + (status = 200, description = "Database statistics", body = DatabaseStatsResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn database_stats( State(state): State, ) -> Result, ApiError> { @@ -16,6 +28,18 @@ pub async fn database_stats( })) } +#[utoipa::path( + post, + path = "/api/v1/admin/database/vacuum", + tag = "database", + responses( + (status = 200, description = "Database vacuumed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn vacuum_database( State(state): State, ) -> Result, ApiError> { @@ -23,6 +47,18 @@ pub async fn vacuum_database( Ok(Json(serde_json::json!({"status": "ok"}))) } +#[utoipa::path( + post, + path = "/api/v1/admin/database/clear", + tag = "database", + responses( + (status = 200, description = "Database cleared"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn clear_database( State(state): State, ) -> Result, ApiError> { diff --git a/crates/pinakes-server/src/routes/duplicates.rs b/crates/pinakes-server/src/routes/duplicates.rs index 075b3cc..6150979 100644 --- a/crates/pinakes-server/src/routes/duplicates.rs +++ b/crates/pinakes-server/src/routes/duplicates.rs @@ -6,6 +6,17 @@ use crate::{ state::AppState, }; +#[utoipa::path( + get, + path = "/api/v1/media/duplicates", + tag = "duplicates", + responses( + (status = 200, description = "Duplicate groups", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_duplicates( State(state): State, ) -> Result>, ApiError> { diff --git a/crates/pinakes-server/src/routes/enrichment.rs b/crates/pinakes-server/src/routes/enrichment.rs index 5b93b3f..1060cc3 100644 --- a/crates/pinakes-server/src/routes/enrichment.rs +++ b/crates/pinakes-server/src/routes/enrichment.rs @@ -11,6 +11,20 @@ use crate::{ state::AppState, }; +#[utoipa::path( + post, + path = "/api/v1/media/{id}/enrich", + tag = "enrichment", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Enrichment job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_enrichment( State(state): State, Path(id): Path, @@ -25,6 +39,19 @@ pub async fn trigger_enrichment( Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/metadata/external", + tag = "enrichment", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "External metadata", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_external_metadata( State(state): State, Path(id): Path, @@ -38,6 +65,20 @@ pub async fn get_external_metadata( )) } +#[utoipa::path( + post, + path = "/api/v1/media/enrich/batch", + tag = "enrichment", + request_body = BatchDeleteRequest, + responses( + (status = 200, description = "Enrichment job submitted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_enrich( State(state): State, Json(req): Json, // Reuse: has media_ids field diff --git a/crates/pinakes-server/src/routes/export.rs b/crates/pinakes-server/src/routes/export.rs index 7b98b04..8251272 100644 --- a/crates/pinakes-server/src/routes/export.rs +++ b/crates/pinakes-server/src/routes/export.rs @@ -5,12 +5,25 @@ use serde::Deserialize; use crate::{error::ApiError, state::AppState}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ExportRequest { pub format: String, + #[schema(value_type = String)] pub destination: PathBuf, } +#[utoipa::path( + post, + path = "/api/v1/export", + tag = "export", + responses( + (status = 200, description = "Export job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_export( State(state): State, ) -> Result, ApiError> { @@ -25,6 +38,19 @@ pub async fn trigger_export( Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } +#[utoipa::path( + post, + path = "/api/v1/export/options", + tag = "export", + request_body = ExportRequest, + responses( + (status = 200, description = "Export job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_export_with_options( State(state): State, Json(req): Json, diff --git a/crates/pinakes-server/src/routes/health.rs b/crates/pinakes-server/src/routes/health.rs index cffabb3..7d30c27 100644 --- a/crates/pinakes-server/src/routes/health.rs +++ b/crates/pinakes-server/src/routes/health.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::state::AppState; /// Basic health check response -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct HealthResponse { pub status: String, pub version: String, @@ -18,7 +18,7 @@ pub struct HealthResponse { pub cache: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct DatabaseHealth { pub status: String, pub latency_ms: u64, @@ -26,14 +26,14 @@ pub struct DatabaseHealth { pub media_count: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct FilesystemHealth { pub status: String, pub roots_configured: usize, pub roots_accessible: usize, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct CacheHealth { pub hit_rate: f64, pub total_entries: u64, @@ -43,6 +43,14 @@ pub struct CacheHealth { } /// Comprehensive health check - includes database, filesystem, and cache status +#[utoipa::path( + get, + path = "/api/v1/health", + tag = "health", + responses( + (status = 200, description = "Health status", body = HealthResponse), + ) +)] pub async fn health(State(state): State) -> Json { let mut response = HealthResponse { status: "ok".to_string(), @@ -106,6 +114,14 @@ pub async fn health(State(state): State) -> Json { /// Liveness probe - just checks if the server is running /// Returns 200 OK if the server process is alive +#[utoipa::path( + get, + path = "/api/v1/health/live", + tag = "health", + responses( + (status = 200, description = "Server is alive"), + ) +)] pub async fn liveness() -> impl IntoResponse { ( StatusCode::OK, @@ -117,6 +133,15 @@ pub async fn liveness() -> impl IntoResponse { /// Readiness probe - checks if the server can serve requests /// Returns 200 OK if database is accessible +#[utoipa::path( + get, + path = "/api/v1/health/ready", + tag = "health", + responses( + (status = 200, description = "Server is ready"), + (status = 503, description = "Server not ready"), + ) +)] pub async fn readiness(State(state): State) -> impl IntoResponse { // Check database connectivity let db_start = Instant::now(); @@ -144,7 +169,7 @@ pub async fn readiness(State(state): State) -> impl IntoResponse { } /// Detailed health check for monitoring dashboards -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct DetailedHealthResponse { pub status: String, pub version: String, @@ -155,12 +180,20 @@ pub struct DetailedHealthResponse { pub jobs: JobsHealth, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct JobsHealth { pub pending: usize, pub running: usize, } +#[utoipa::path( + get, + path = "/api/v1/health/detailed", + tag = "health", + responses( + (status = 200, description = "Detailed health status", body = DetailedHealthResponse), + ) +)] pub async fn health_detailed( State(state): State, ) -> Json { diff --git a/crates/pinakes-server/src/routes/integrity.rs b/crates/pinakes-server/src/routes/integrity.rs index d6a84ea..f688e79 100644 --- a/crates/pinakes-server/src/routes/integrity.rs +++ b/crates/pinakes-server/src/routes/integrity.rs @@ -3,12 +3,24 @@ use serde::Deserialize; use crate::{error::ApiError, state::AppState}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct OrphanResolveRequest { pub action: String, pub ids: Vec, } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/orphans/detect", + tag = "integrity", + responses( + (status = 200, description = "Orphan detection job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_orphan_detection( State(state): State, ) -> Result, ApiError> { @@ -17,6 +29,19 @@ pub async fn trigger_orphan_detection( Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/verify", + tag = "integrity", + request_body = VerifyIntegrityRequest, + responses( + (status = 200, description = "Integrity verification job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_verify_integrity( State(state): State, Json(req): Json, @@ -31,11 +56,23 @@ pub async fn trigger_verify_integrity( Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct VerifyIntegrityRequest { pub media_ids: Vec, } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/thumbnails/cleanup", + tag = "integrity", + responses( + (status = 200, description = "Thumbnail cleanup job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_cleanup_thumbnails( State(state): State, ) -> Result, ApiError> { @@ -44,7 +81,7 @@ pub async fn trigger_cleanup_thumbnails( Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct GenerateThumbnailsRequest { /// When true, only generate thumbnails for items that don't have one yet. /// When false (default), regenerate all thumbnails. @@ -52,6 +89,19 @@ pub struct GenerateThumbnailsRequest { pub only_missing: bool, } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/thumbnails/generate", + tag = "integrity", + request_body = GenerateThumbnailsRequest, + responses( + (status = 200, description = "Thumbnail generation job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn generate_all_thumbnails( State(state): State, body: Option>, @@ -77,6 +127,19 @@ pub async fn generate_all_thumbnails( }))) } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/orphans/resolve", + tag = "integrity", + request_body = OrphanResolveRequest, + responses( + (status = 200, description = "Orphans resolved"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn resolve_orphans( State(state): State, Json(req): Json, diff --git a/crates/pinakes-server/src/routes/jobs.rs b/crates/pinakes-server/src/routes/jobs.rs index 6016c05..c7319cb 100644 --- a/crates/pinakes-server/src/routes/jobs.rs +++ b/crates/pinakes-server/src/routes/jobs.rs @@ -6,10 +6,34 @@ use pinakes_core::jobs::Job; use crate::{error::ApiError, state::AppState}; +#[utoipa::path( + get, + path = "/api/v1/jobs", + tag = "jobs", + responses( + (status = 200, description = "List of jobs"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_jobs(State(state): State) -> Json> { Json(state.job_queue.list().await) } +#[utoipa::path( + get, + path = "/api/v1/jobs/{id}", + tag = "jobs", + params(("id" = uuid::Uuid, Path, description = "Job ID")), + responses( + (status = 200, description = "Job details"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_job( State(state): State, Path(id): Path, @@ -20,6 +44,19 @@ pub async fn get_job( }) } +#[utoipa::path( + post, + path = "/api/v1/jobs/{id}/cancel", + tag = "jobs", + params(("id" = uuid::Uuid, Path, description = "Job ID")), + responses( + (status = 200, description = "Job cancelled"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn cancel_job( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs index 057aa31..6aa3ec5 100644 --- a/crates/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -99,6 +99,20 @@ async fn apply_import_post_processing( } } +#[utoipa::path( + post, + path = "/api/v1/media/import", + tag = "media", + request_body = ImportRequest, + responses( + (status = 200, description = "Media imported", body = ImportResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn import_media( State(state): State, Json(req): Json, @@ -126,6 +140,22 @@ pub async fn import_media( })) } +#[utoipa::path( + get, + path = "/api/v1/media", + tag = "media", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Page size"), + ("sort" = Option, Query, description = "Sort field"), + ), + responses( + (status = 200, description = "List of media items", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_media( State(state): State, Query(params): Query, @@ -141,6 +171,19 @@ pub async fn list_media( )) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media item", body = MediaResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media( State(state): State, Path(id): Path, @@ -172,6 +215,22 @@ fn validate_optional_text( Ok(()) } +#[utoipa::path( + patch, + path = "/api/v1/media/{id}", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = UpdateMediaRequest, + responses( + (status = 200, description = "Updated media item", body = MediaResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_media( State(state): State, Path(id): Path, @@ -229,6 +288,20 @@ pub async fn update_media( Ok(Json(MediaResponse::new(item, &roots))) } +#[utoipa::path( + delete, + path = "/api/v1/media/{id}", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_media( State(state): State, Path(id): Path, @@ -267,6 +340,19 @@ pub async fn delete_media( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/open", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media opened"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn open_media( State(state): State, Path(id): Path, @@ -284,6 +370,20 @@ pub async fn open_media( Ok(Json(serde_json::json!({"opened": true}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media stream"), + (status = 206, description = "Partial content"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn stream_media( State(state): State, Path(id): Path, @@ -395,6 +495,20 @@ fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> { } } +#[utoipa::path( + post, + path = "/api/v1/media/import/options", + tag = "media", + request_body = ImportWithOptionsRequest, + responses( + (status = 200, description = "Media imported", body = ImportResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn import_with_options( State(state): State, Json(req): Json, @@ -429,6 +543,20 @@ pub async fn import_with_options( })) } +#[utoipa::path( + post, + path = "/api/v1/media/import/batch", + tag = "media", + request_body = BatchImportRequest, + responses( + (status = 200, description = "Batch import results", body = BatchImportResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_import( State(state): State, Json(req): Json, @@ -503,6 +631,20 @@ pub async fn batch_import( })) } +#[utoipa::path( + post, + path = "/api/v1/media/import/directory", + tag = "media", + request_body = DirectoryImportRequest, + responses( + (status = 200, description = "Directory import results", body = BatchImportResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn import_directory_endpoint( State(state): State, Json(req): Json, @@ -571,6 +713,19 @@ pub async fn import_directory_endpoint( })) } +#[utoipa::path( + post, + path = "/api/v1/media/import/preview", + tag = "media", + responses( + (status = 200, description = "Directory preview", body = DirectoryPreviewResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn preview_directory( State(state): State, Json(req): Json, @@ -672,6 +827,22 @@ pub async fn preview_directory( })) } +#[utoipa::path( + put, + path = "/api/v1/media/{id}/custom-fields", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = SetCustomFieldRequest, + responses( + (status = 200, description = "Custom field set"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn set_custom_field( State(state): State, Path(id): Path, @@ -709,6 +880,23 @@ pub async fn set_custom_field( Ok(Json(serde_json::json!({"set": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/media/{id}/custom-fields/{name}", + tag = "media", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("name" = String, Path, description = "Custom field name"), + ), + responses( + (status = 200, description = "Custom field deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_custom_field( State(state): State, Path((id, name)): Path<(Uuid, String)>, @@ -720,6 +908,20 @@ pub async fn delete_custom_field( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/media/batch/tag", + tag = "media", + request_body = BatchTagRequest, + responses( + (status = 200, description = "Batch tag result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_tag( State(state): State, Json(req): Json, @@ -754,6 +956,18 @@ pub async fn batch_tag( } } +#[utoipa::path( + delete, + path = "/api/v1/media", + tag = "media", + responses( + (status = 200, description = "All media deleted", body = BatchOperationResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_all_media( State(state): State, ) -> Result, ApiError> { @@ -785,6 +999,20 @@ pub async fn delete_all_media( } } +#[utoipa::path( + post, + path = "/api/v1/media/batch/delete", + tag = "media", + request_body = BatchDeleteRequest, + responses( + (status = 200, description = "Batch delete result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_delete( State(state): State, Json(req): Json, @@ -829,6 +1057,20 @@ pub async fn batch_delete( } } +#[utoipa::path( + post, + path = "/api/v1/media/batch/collection", + tag = "media", + request_body = BatchCollectionRequest, + responses( + (status = 200, description = "Batch collection result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_add_to_collection( State(state): State, Json(req): Json, @@ -859,6 +1101,20 @@ pub async fn batch_add_to_collection( Ok(Json(BatchOperationResponse { processed, errors })) } +#[utoipa::path( + post, + path = "/api/v1/media/batch/update", + tag = "media", + request_body = BatchUpdateRequest, + responses( + (status = 200, description = "Batch update result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_update( State(state): State, Json(req): Json, @@ -901,6 +1157,19 @@ pub async fn batch_update( } } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/thumbnail", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Thumbnail image"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_thumbnail( State(state): State, Path(id): Path, @@ -934,6 +1203,17 @@ pub async fn get_thumbnail( }) } +#[utoipa::path( + get, + path = "/api/v1/media/count", + tag = "media", + responses( + (status = 200, description = "Media count", body = MediaCountResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media_count( State(state): State, ) -> Result, ApiError> { @@ -941,6 +1221,22 @@ pub async fn get_media_count( Ok(Json(MediaCountResponse { count })) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/rename", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = RenameMediaRequest, + responses( + (status = 200, description = "Renamed media item", body = MediaResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn rename_media( State(state): State, Path(id): Path, @@ -993,6 +1289,22 @@ pub async fn rename_media( Ok(Json(MediaResponse::new(item, &roots))) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/move", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = MoveMediaRequest, + responses( + (status = 200, description = "Moved media item", body = MediaResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn move_media_endpoint( State(state): State, Path(id): Path, @@ -1042,6 +1354,20 @@ pub async fn move_media_endpoint( Ok(Json(MediaResponse::new(item, &roots))) } +#[utoipa::path( + post, + path = "/api/v1/media/batch/move", + tag = "media", + request_body = BatchMoveRequest, + responses( + (status = 200, description = "Batch move result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_move_media( State(state): State, Json(req): Json, @@ -1111,6 +1437,20 @@ pub async fn batch_move_media( } } +#[utoipa::path( + delete, + path = "/api/v1/media/{id}/trash", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media moved to trash"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn soft_delete_media( State(state): State, Path(id): Path, @@ -1157,6 +1497,20 @@ pub async fn soft_delete_media( Ok(Json(serde_json::json!({"deleted": true, "trashed": true}))) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/restore", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media restored", body = MediaResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn restore_media( State(state): State, Path(id): Path, @@ -1204,6 +1558,21 @@ pub async fn restore_media( Ok(Json(MediaResponse::new(item, &roots))) } +#[utoipa::path( + get, + path = "/api/v1/media/trash", + tag = "media", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Page size"), + ), + responses( + (status = 200, description = "Trashed media items", body = TrashResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_trash( State(state): State, Query(params): Query, @@ -1223,6 +1592,17 @@ pub async fn list_trash( })) } +#[utoipa::path( + get, + path = "/api/v1/media/trash/info", + tag = "media", + responses( + (status = 200, description = "Trash info", body = TrashInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trash_info( State(state): State, ) -> Result, ApiError> { @@ -1230,6 +1610,18 @@ pub async fn trash_info( Ok(Json(TrashInfoResponse { count })) } +#[utoipa::path( + delete, + path = "/api/v1/media/trash", + tag = "media", + responses( + (status = 200, description = "Trash emptied", body = EmptyTrashResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn empty_trash( State(state): State, ) -> Result, ApiError> { @@ -1247,6 +1639,23 @@ pub async fn empty_trash( Ok(Json(EmptyTrashResponse { deleted_count })) } +#[utoipa::path( + delete, + path = "/api/v1/media/{id}/permanent", + tag = "media", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("permanent" = Option, Query, description = "Set to 'true' for permanent deletion"), + ), + responses( + (status = 200, description = "Media deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn permanent_delete_media( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/notes.rs b/crates/pinakes-server/src/routes/notes.rs index 0fca5a3..6fe3a37 100644 --- a/crates/pinakes-server/src/routes/notes.rs +++ b/crates/pinakes-server/src/routes/notes.rs @@ -26,14 +26,14 @@ use uuid::Uuid; use crate::{error::ApiError, state::AppState}; /// Response for backlinks query -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BacklinksResponse { pub backlinks: Vec, pub count: usize, } /// Individual backlink item -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BacklinkItem { pub link_id: Uuid, pub source_id: Uuid, @@ -61,14 +61,14 @@ impl From for BacklinkItem { } /// Response for outgoing links query -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct OutgoingLinksResponse { pub links: Vec, pub count: usize, } /// Individual outgoing link item -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct OutgoingLinkItem { pub id: Uuid, pub target_path: String, @@ -94,7 +94,7 @@ impl From for OutgoingLinkItem { } /// Response for graph visualization -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct GraphResponse { pub nodes: Vec, pub edges: Vec, @@ -103,7 +103,7 @@ pub struct GraphResponse { } /// Graph node for visualization -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct GraphNodeResponse { pub id: String, pub label: String, @@ -127,7 +127,7 @@ impl From for GraphNodeResponse { } /// Graph edge for visualization -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct GraphEdgeResponse { pub source: String, pub target: String, @@ -180,20 +180,20 @@ const fn default_depth() -> u32 { } /// Response for reindex operation -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ReindexResponse { pub message: String, pub links_extracted: usize, } /// Response for link resolution -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ResolveLinksResponse { pub resolved_count: u64, } /// Response for unresolved links count -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UnresolvedLinksResponse { pub count: u64, } @@ -201,6 +201,19 @@ pub struct UnresolvedLinksResponse { /// Get backlinks (incoming links) to a media item. /// /// GET /api/v1/media/{id}/backlinks +#[utoipa::path( + get, + path = "/api/v1/media/{id}/backlinks", + tag = "notes", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Backlinks", body = BacklinksResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_backlinks( State(state): State, Path(id): Path, @@ -221,6 +234,19 @@ pub async fn get_backlinks( /// Get outgoing links from a media item. /// /// GET /api/v1/media/{id}/outgoing-links +#[utoipa::path( + get, + path = "/api/v1/media/{id}/outgoing-links", + tag = "notes", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Outgoing links", body = OutgoingLinksResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_outgoing_links( State(state): State, Path(id): Path, @@ -241,6 +267,21 @@ pub async fn get_outgoing_links( /// Get graph data for visualization. /// /// GET /api/v1/notes/graph?center={uuid}&depth={n} +#[utoipa::path( + get, + path = "/api/v1/notes/graph", + tag = "notes", + params( + ("center" = Option, Query, description = "Center node ID"), + ("depth" = Option, Query, description = "Traversal depth (max 5, default 2)"), + ), + responses( + (status = 200, description = "Graph data", body = GraphResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_graph( State(state): State, Query(params): Query, @@ -256,6 +297,19 @@ pub async fn get_graph( /// Re-extract links from a media item. /// /// POST /api/v1/media/{id}/reindex-links +#[utoipa::path( + post, + path = "/api/v1/media/{id}/reindex-links", + tag = "notes", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Links reindexed", body = ReindexResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn reindex_links( State(state): State, Path(id): Path, @@ -304,6 +358,17 @@ pub async fn reindex_links( /// Resolve all unresolved links in the database. /// /// POST /api/v1/notes/resolve-links +#[utoipa::path( + post, + path = "/api/v1/notes/resolve-links", + tag = "notes", + responses( + (status = 200, description = "Links resolved", body = ResolveLinksResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn resolve_links( State(state): State, ) -> Result, ApiError> { @@ -315,6 +380,17 @@ pub async fn resolve_links( /// Get count of unresolved links. /// /// GET /api/v1/notes/unresolved-count +#[utoipa::path( + get, + path = "/api/v1/notes/unresolved-count", + tag = "notes", + responses( + (status = 200, description = "Unresolved link count", body = UnresolvedLinksResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_unresolved_count( State(state): State, ) -> Result, ApiError> { diff --git a/crates/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs index 318c9d0..7320427 100644 --- a/crates/pinakes-server/src/routes/photos.rs +++ b/crates/pinakes-server/src/routes/photos.rs @@ -36,7 +36,7 @@ const fn default_timeline_limit() -> u64 { } /// Timeline group response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TimelineGroup { pub date: String, pub count: usize, @@ -54,7 +54,7 @@ pub struct MapQuery { } /// Map marker response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MapMarker { pub id: String, pub latitude: f64, @@ -63,6 +63,23 @@ pub struct MapMarker { pub date_taken: Option>, } +#[utoipa::path( + get, + path = "/api/v1/photos/timeline", + tag = "photos", + params( + ("group_by" = Option, Query, description = "Grouping: day, month, year"), + ("year" = Option, Query, description = "Filter by year"), + ("month" = Option, Query, description = "Filter by month"), + ("limit" = Option, Query, description = "Max items (default 10000)"), + ), + responses( + (status = 200, description = "Photo timeline groups", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] /// Get timeline of photos grouped by date pub async fn get_timeline( State(state): State, @@ -147,6 +164,24 @@ pub async fn get_timeline( Ok(Json(timeline)) } +#[utoipa::path( + get, + path = "/api/v1/photos/map", + tag = "photos", + params( + ("lat1" = f64, Query, description = "Bounding box latitude 1"), + ("lon1" = f64, Query, description = "Bounding box longitude 1"), + ("lat2" = f64, Query, description = "Bounding box latitude 2"), + ("lon2" = f64, Query, description = "Bounding box longitude 2"), + ), + responses( + (status = 200, description = "Map markers", body = Vec), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] /// Get photos in a bounding box for map view pub async fn get_map_photos( State(state): State, diff --git a/crates/pinakes-server/src/routes/playlists.rs b/crates/pinakes-server/src/routes/playlists.rs index f341458..420897e 100644 --- a/crates/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -51,6 +51,19 @@ async fn check_playlist_access( Ok(playlist) } +#[utoipa::path( + post, + path = "/api/v1/playlists", + tag = "playlists", + request_body = CreatePlaylistRequest, + responses( + (status = 200, description = "Playlist created", body = PlaylistResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_playlist( State(state): State, Extension(username): Extension, @@ -78,6 +91,17 @@ pub async fn create_playlist( Ok(Json(PlaylistResponse::from(playlist))) } +#[utoipa::path( + get, + path = "/api/v1/playlists", + tag = "playlists", + responses( + (status = 200, description = "List of playlists", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_playlists( State(state): State, Extension(username): Extension, @@ -93,6 +117,19 @@ pub async fn list_playlists( Ok(Json(visible)) } +#[utoipa::path( + get, + path = "/api/v1/playlists/{id}", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Playlist details", body = PlaylistResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_playlist( State(state): State, Extension(username): Extension, @@ -104,6 +141,21 @@ pub async fn get_playlist( Ok(Json(PlaylistResponse::from(playlist))) } +#[utoipa::path( + patch, + path = "/api/v1/playlists/{id}", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + request_body = UpdatePlaylistRequest, + responses( + (status = 200, description = "Playlist updated", body = PlaylistResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_playlist( State(state): State, Extension(username): Extension, @@ -133,6 +185,19 @@ pub async fn update_playlist( Ok(Json(PlaylistResponse::from(playlist))) } +#[utoipa::path( + delete, + path = "/api/v1/playlists/{id}", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Playlist deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_playlist( State(state): State, Extension(username): Extension, @@ -144,6 +209,20 @@ pub async fn delete_playlist( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/playlists/{id}/items", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + request_body = PlaylistItemRequest, + responses( + (status = 200, description = "Item added"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_item( State(state): State, Extension(username): Extension, @@ -165,6 +244,22 @@ pub async fn add_item( Ok(Json(serde_json::json!({"added": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/playlists/{id}/items/{media_id}", + tag = "playlists", + params( + ("id" = Uuid, Path, description = "Playlist ID"), + ("media_id" = Uuid, Path, description = "Media item ID"), + ), + responses( + (status = 200, description = "Item removed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn remove_item( State(state): State, Extension(username): Extension, @@ -179,6 +274,19 @@ pub async fn remove_item( Ok(Json(serde_json::json!({"removed": true}))) } +#[utoipa::path( + get, + path = "/api/v1/playlists/{id}/items", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Playlist items", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_items( State(state): State, Extension(username): Extension, @@ -196,6 +304,20 @@ pub async fn list_items( )) } +#[utoipa::path( + patch, + path = "/api/v1/playlists/{id}/items/reorder", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + request_body = ReorderPlaylistRequest, + responses( + (status = 200, description = "Item reordered"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn reorder_item( State(state): State, Extension(username): Extension, @@ -211,6 +333,19 @@ pub async fn reorder_item( Ok(Json(serde_json::json!({"reordered": true}))) } +#[utoipa::path( + post, + path = "/api/v1/playlists/{id}/shuffle", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Shuffled playlist items", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn shuffle_playlist( State(state): State, Extension(username): Extension, diff --git a/crates/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs index e3b13a0..e2b45b0 100644 --- a/crates/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -31,6 +31,17 @@ fn require_plugin_manager( } /// List all installed plugins +#[utoipa::path( + get, + path = "/api/v1/plugins", + tag = "plugins", + responses( + (status = 200, description = "List of plugins", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_plugins( State(state): State, ) -> Result>, ApiError> { @@ -46,6 +57,18 @@ pub async fn list_plugins( } /// Get a specific plugin by ID +#[utoipa::path( + get, + path = "/api/v1/plugins/{id}", + tag = "plugins", + params(("id" = String, Path, description = "Plugin ID")), + responses( + (status = 200, description = "Plugin details", body = PluginResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_plugin( State(state): State, Path(id): Path, @@ -63,6 +86,19 @@ pub async fn get_plugin( } /// Install a plugin from URL or file path +#[utoipa::path( + post, + path = "/api/v1/plugins", + tag = "plugins", + request_body = InstallPluginRequest, + responses( + (status = 200, description = "Plugin installed", body = PluginResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn install_plugin( State(state): State, Json(req): Json, @@ -91,6 +127,19 @@ pub async fn install_plugin( } /// Uninstall a plugin +#[utoipa::path( + delete, + path = "/api/v1/plugins/{id}", + tag = "plugins", + params(("id" = String, Path, description = "Plugin ID")), + responses( + (status = 200, description = "Plugin uninstalled"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn uninstall_plugin( State(state): State, Path(id): Path, @@ -107,6 +156,20 @@ pub async fn uninstall_plugin( } /// Enable or disable a plugin +#[utoipa::path( + patch, + path = "/api/v1/plugins/{id}/toggle", + tag = "plugins", + params(("id" = String, Path, description = "Plugin ID")), + request_body = TogglePluginRequest, + responses( + (status = 200, description = "Plugin toggled"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn toggle_plugin( State(state): State, Path(id): Path, @@ -146,6 +209,16 @@ pub async fn toggle_plugin( } /// List all UI pages provided by loaded plugins +#[utoipa::path( + get, + path = "/api/v1/plugins/ui/pages", + tag = "plugins", + responses( + (status = 200, description = "Plugin UI pages", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_plugin_ui_pages( State(state): State, ) -> Result>, ApiError> { @@ -166,6 +239,16 @@ pub async fn list_plugin_ui_pages( } /// List all UI widgets provided by loaded plugins +#[utoipa::path( + get, + path = "/api/v1/plugins/ui/widgets", + tag = "plugins", + responses( + (status = 200, description = "Plugin UI widgets", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_plugin_ui_widgets( State(state): State, ) -> Result>, ApiError> { @@ -181,6 +264,17 @@ pub async fn list_plugin_ui_widgets( /// Receive a plugin event emitted from the UI and dispatch it to interested /// server-side event-handler plugins via the pipeline. +#[utoipa::path( + post, + path = "/api/v1/plugins/events", + tag = "plugins", + request_body = PluginEventRequest, + responses( + (status = 200, description = "Event received"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn emit_plugin_event( State(state): State, Json(req): Json, @@ -193,6 +287,16 @@ pub async fn emit_plugin_event( } /// List merged CSS custom property overrides from all enabled plugins +#[utoipa::path( + get, + path = "/api/v1/plugins/ui/theme", + tag = "plugins", + responses( + (status = 200, description = "Plugin UI theme extensions"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_plugin_ui_theme_extensions( State(state): State, ) -> Result>, ApiError> { @@ -201,6 +305,19 @@ pub async fn list_plugin_ui_theme_extensions( } /// Reload a plugin (for development) +#[utoipa::path( + post, + path = "/api/v1/plugins/{id}/reload", + tag = "plugins", + params(("id" = String, Path, description = "Plugin ID")), + responses( + (status = 200, description = "Plugin reloaded"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn reload_plugin( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/saved_searches.rs b/crates/pinakes-server/src/routes/saved_searches.rs index 2439240..11bb4f0 100644 --- a/crates/pinakes-server/src/routes/saved_searches.rs +++ b/crates/pinakes-server/src/routes/saved_searches.rs @@ -6,14 +6,14 @@ use serde::{Deserialize, Serialize}; use crate::{error::ApiError, state::AppState}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateSavedSearchRequest { pub name: String, pub query: String, pub sort_order: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SavedSearchResponse { pub id: String, pub name: String, @@ -31,6 +31,19 @@ const VALID_SORT_ORDERS: &[&str] = &[ "size_desc", ]; +#[utoipa::path( + post, + path = "/api/v1/searches", + tag = "saved_searches", + request_body = CreateSavedSearchRequest, + responses( + (status = 200, description = "Search saved", body = SavedSearchResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_saved_search( State(state): State, Json(req): Json, @@ -76,6 +89,17 @@ pub async fn create_saved_search( })) } +#[utoipa::path( + get, + path = "/api/v1/searches", + tag = "saved_searches", + responses( + (status = 200, description = "List of saved searches", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_saved_searches( State(state): State, ) -> Result>, ApiError> { @@ -100,6 +124,19 @@ pub async fn list_saved_searches( )) } +#[utoipa::path( + delete, + path = "/api/v1/searches/{id}", + tag = "saved_searches", + params(("id" = uuid::Uuid, Path, description = "Saved search ID")), + responses( + (status = 200, description = "Saved search deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_saved_search( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/scan.rs b/crates/pinakes-server/src/routes/scan.rs index b9aef1b..f78b089 100644 --- a/crates/pinakes-server/src/routes/scan.rs +++ b/crates/pinakes-server/src/routes/scan.rs @@ -7,6 +7,19 @@ use crate::{ }; /// Trigger a scan as a background job. Returns the job ID immediately. +#[utoipa::path( + post, + path = "/api/v1/scan", + tag = "scan", + request_body = ScanRequest, + responses( + (status = 200, description = "Scan job submitted", body = ScanJobResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_scan( State(state): State, Json(req): Json, @@ -18,6 +31,16 @@ pub async fn trigger_scan( })) } +#[utoipa::path( + get, + path = "/api/v1/scan/status", + tag = "scan", + responses( + (status = 200, description = "Scan status", body = ScanStatusResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn scan_status( State(state): State, ) -> Json { diff --git a/crates/pinakes-server/src/routes/scheduled_tasks.rs b/crates/pinakes-server/src/routes/scheduled_tasks.rs index 4d50f76..270c4ab 100644 --- a/crates/pinakes-server/src/routes/scheduled_tasks.rs +++ b/crates/pinakes-server/src/routes/scheduled_tasks.rs @@ -5,6 +5,17 @@ use axum::{ use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState}; +#[utoipa::path( + get, + path = "/api/v1/scheduled-tasks", + tag = "scheduled_tasks", + responses( + (status = 200, description = "List of scheduled tasks", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_scheduled_tasks( State(state): State, ) -> Result>, ApiError> { @@ -26,6 +37,19 @@ pub async fn list_scheduled_tasks( Ok(Json(responses)) } +#[utoipa::path( + post, + path = "/api/v1/scheduled-tasks/{id}/toggle", + tag = "scheduled_tasks", + params(("id" = String, Path, description = "Task ID")), + responses( + (status = 200, description = "Task toggled"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn toggle_scheduled_task( State(state): State, Path(id): Path, @@ -45,6 +69,19 @@ pub async fn toggle_scheduled_task( } } +#[utoipa::path( + post, + path = "/api/v1/scheduled-tasks/{id}/run", + tag = "scheduled_tasks", + params(("id" = String, Path, description = "Task ID")), + responses( + (status = 200, description = "Task triggered"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn run_scheduled_task_now( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs index eacec6e..bebb04b 100644 --- a/crates/pinakes-server/src/routes/search.rs +++ b/crates/pinakes-server/src/routes/search.rs @@ -22,6 +22,24 @@ fn resolve_sort(sort: Option<&str>) -> SortOrder { } } +#[utoipa::path( + get, + path = "/api/v1/search", + tag = "search", + params( + ("q" = String, Query, description = "Search query"), + ("sort" = Option, Query, description = "Sort order"), + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Search results", body = SearchResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn search( State(state): State, Query(params): Query, @@ -56,6 +74,19 @@ pub async fn search( })) } +#[utoipa::path( + post, + path = "/api/v1/search", + tag = "search", + request_body = SearchRequestBody, + responses( + (status = 200, description = "Search results", body = SearchResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn search_post( State(state): State, Json(body): Json, diff --git a/crates/pinakes-server/src/routes/shares.rs b/crates/pinakes-server/src/routes/shares.rs index 39d00d9..965b79e 100644 --- a/crates/pinakes-server/src/routes/shares.rs +++ b/crates/pinakes-server/src/routes/shares.rs @@ -48,6 +48,19 @@ use crate::{ /// Create a new share /// POST /api/shares +#[utoipa::path( + post, + path = "/api/v1/shares", + tag = "shares", + request_body = CreateShareRequest, + responses( + (status = 200, description = "Share created", body = ShareResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_share( State(state): State, Extension(username): Extension, @@ -201,6 +214,20 @@ pub async fn create_share( /// List outgoing shares (shares I created) /// GET /api/shares/outgoing +#[utoipa::path( + get, + path = "/api/v1/shares/outgoing", + tag = "shares", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Outgoing shares", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_outgoing( State(state): State, Extension(username): Extension, @@ -220,6 +247,20 @@ pub async fn list_outgoing( /// List incoming shares (shares shared with me) /// GET /api/shares/incoming +#[utoipa::path( + get, + path = "/api/v1/shares/incoming", + tag = "shares", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Incoming shares", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_incoming( State(state): State, Extension(username): Extension, @@ -239,6 +280,19 @@ pub async fn list_incoming( /// Get share details /// GET /api/shares/{id} +#[utoipa::path( + get, + path = "/api/v1/shares/{id}", + tag = "shares", + params(("id" = Uuid, Path, description = "Share ID")), + responses( + (status = 200, description = "Share details", body = ShareResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_share( State(state): State, Extension(username): Extension, @@ -269,6 +323,20 @@ pub async fn get_share( /// Update a share /// PATCH /api/shares/{id} +#[utoipa::path( + patch, + path = "/api/v1/shares/{id}", + tag = "shares", + params(("id" = Uuid, Path, description = "Share ID")), + request_body = UpdateShareRequest, + responses( + (status = 200, description = "Share updated", body = ShareResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_share( State(state): State, Extension(username): Extension, @@ -349,6 +417,19 @@ pub async fn update_share( /// Delete (revoke) a share /// DELETE /api/shares/{id} +#[utoipa::path( + delete, + path = "/api/v1/shares/{id}", + tag = "shares", + params(("id" = Uuid, Path, description = "Share ID")), + responses( + (status = 204, description = "Share deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_share( State(state): State, Extension(username): Extension, @@ -393,6 +474,19 @@ pub async fn delete_share( /// Batch delete shares /// POST /api/shares/batch/delete +#[utoipa::path( + post, + path = "/api/v1/shares/batch/delete", + tag = "shares", + request_body = BatchDeleteSharesRequest, + responses( + (status = 200, description = "Shares deleted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_delete( State(state): State, Extension(username): Extension, @@ -432,6 +526,20 @@ pub async fn batch_delete( /// Access a public shared resource /// GET /api/shared/{token} +#[utoipa::path( + get, + path = "/api/v1/shared/{token}", + tag = "shares", + params( + ("token" = String, Path, description = "Share token"), + ("password" = Option, Query, description = "Share password if required"), + ), + responses( + (status = 200, description = "Shared content", body = SharedContentResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ) +)] pub async fn access_shared( State(state): State, Path(token): Path, @@ -599,6 +707,23 @@ pub async fn access_shared( /// Get share activity log /// GET /api/shares/{id}/activity +#[utoipa::path( + get, + path = "/api/v1/shares/{id}/activity", + tag = "shares", + params( + ("id" = Uuid, Path, description = "Share ID"), + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Share activity", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_activity( State(state): State, Extension(username): Extension, @@ -632,6 +757,16 @@ pub async fn get_activity( /// Get unread share notifications /// GET /api/notifications/shares +#[utoipa::path( + get, + path = "/api/v1/notifications/shares", + tag = "shares", + responses( + (status = 200, description = "Unread notifications", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_notifications( State(state): State, Extension(username): Extension, @@ -650,6 +785,17 @@ pub async fn get_notifications( /// Mark a notification as read /// POST /api/notifications/shares/{id}/read +#[utoipa::path( + post, + path = "/api/v1/notifications/shares/{id}/read", + tag = "shares", + params(("id" = Uuid, Path, description = "Notification ID")), + responses( + (status = 200, description = "Notification marked as read"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn mark_notification_read( State(state): State, Extension(username): Extension, @@ -667,6 +813,16 @@ pub async fn mark_notification_read( /// Mark all notifications as read /// POST /api/notifications/shares/read-all +#[utoipa::path( + post, + path = "/api/v1/notifications/shares/read-all", + tag = "shares", + responses( + (status = 200, description = "All notifications marked as read"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn mark_all_read( State(state): State, Extension(username): Extension, diff --git a/crates/pinakes-server/src/routes/social.rs b/crates/pinakes-server/src/routes/social.rs index 116146b..b378026 100644 --- a/crates/pinakes-server/src/routes/social.rs +++ b/crates/pinakes-server/src/routes/social.rs @@ -27,6 +27,20 @@ pub struct ShareLinkQuery { pub password: Option, } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/rate", + tag = "social", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = CreateRatingRequest, + responses( + (status = 200, description = "Rating saved", body = RatingResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn rate_media( State(state): State, Extension(username): Extension, @@ -59,6 +73,18 @@ pub async fn rate_media( Ok(Json(RatingResponse::from(rating))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/ratings", + tag = "social", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media ratings", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media_ratings( State(state): State, Path(id): Path, @@ -69,6 +95,20 @@ pub async fn get_media_ratings( )) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/comments", + tag = "social", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = CreateCommentRequest, + responses( + (status = 200, description = "Comment added", body = CommentResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_comment( State(state): State, Extension(username): Extension, @@ -91,6 +131,18 @@ pub async fn add_comment( Ok(Json(CommentResponse::from(comment))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/comments", + tag = "social", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media comments", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media_comments( State(state): State, Path(id): Path, @@ -101,6 +153,18 @@ pub async fn get_media_comments( )) } +#[utoipa::path( + post, + path = "/api/v1/favorites", + tag = "social", + request_body = FavoriteRequest, + responses( + (status = 200, description = "Added to favorites"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_favorite( State(state): State, Extension(username): Extension, @@ -114,6 +178,18 @@ pub async fn add_favorite( Ok(Json(serde_json::json!({"added": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/favorites/{media_id}", + tag = "social", + params(("media_id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Removed from favorites"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn remove_favorite( State(state): State, Extension(username): Extension, @@ -127,6 +203,17 @@ pub async fn remove_favorite( Ok(Json(serde_json::json!({"removed": true}))) } +#[utoipa::path( + get, + path = "/api/v1/favorites", + tag = "social", + responses( + (status = 200, description = "User favorites", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_favorites( State(state): State, Extension(username): Extension, @@ -145,6 +232,19 @@ pub async fn list_favorites( )) } +#[utoipa::path( + post, + path = "/api/v1/media/share", + tag = "social", + request_body = CreateShareLinkRequest, + responses( + (status = 200, description = "Share link created", body = ShareLinkResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_share_link( State(state): State, Extension(username): Extension, @@ -191,6 +291,20 @@ pub async fn create_share_link( Ok(Json(ShareLinkResponse::from(link))) } +#[utoipa::path( + get, + path = "/api/v1/shared/media/{token}", + tag = "social", + params( + ("token" = String, Path, description = "Share token"), + ("password" = Option, Query, description = "Share password"), + ), + responses( + (status = 200, description = "Shared media", body = MediaResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ) +)] pub async fn access_shared_media( State(state): State, Path(token): Path, diff --git a/crates/pinakes-server/src/routes/statistics.rs b/crates/pinakes-server/src/routes/statistics.rs index 24dc7b9..47d1a3b 100644 --- a/crates/pinakes-server/src/routes/statistics.rs +++ b/crates/pinakes-server/src/routes/statistics.rs @@ -2,6 +2,17 @@ use axum::{Json, extract::State}; use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState}; +#[utoipa::path( + get, + path = "/api/v1/statistics", + tag = "statistics", + responses( + (status = 200, description = "Library statistics", body = LibraryStatisticsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn library_statistics( State(state): State, ) -> Result, ApiError> { diff --git a/crates/pinakes-server/src/routes/streaming.rs b/crates/pinakes-server/src/routes/streaming.rs index 92ae897..622b5aa 100644 --- a/crates/pinakes-server/src/routes/streaming.rs +++ b/crates/pinakes-server/src/routes/streaming.rs @@ -49,6 +49,18 @@ fn escape_xml(s: &str) -> String { .replace('\'', "'") } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/hls/master.m3u8", + tag = "streaming", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "HLS master playlist"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn hls_master_playlist( State(state): State, Path(id): Path, @@ -75,6 +87,22 @@ pub async fn hls_master_playlist( build_response("application/vnd.apple.mpegurl", playlist) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/hls/{profile}/playlist.m3u8", + tag = "streaming", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("profile" = String, Path, description = "Transcode profile name"), + ), + responses( + (status = 200, description = "HLS variant playlist"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn hls_variant_playlist( State(state): State, Path((id, profile)): Path<(Uuid, String)>, @@ -112,6 +140,23 @@ pub async fn hls_variant_playlist( build_response("application/vnd.apple.mpegurl", playlist) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/hls/{profile}/{segment}", + tag = "streaming", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("profile" = String, Path, description = "Transcode profile name"), + ("segment" = String, Path, description = "Segment filename"), + ), + responses( + (status = 200, description = "HLS segment data"), + (status = 202, description = "Segment not yet available"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn hls_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, @@ -167,6 +212,19 @@ pub async fn hls_segment( )) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/dash/manifest.mpd", + tag = "streaming", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "DASH manifest"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn dash_manifest( State(state): State, Path(id): Path, @@ -216,6 +274,23 @@ pub async fn dash_manifest( build_response("application/dash+xml", mpd) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/dash/{profile}/{segment}", + tag = "streaming", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("profile" = String, Path, description = "Transcode profile name"), + ("segment" = String, Path, description = "Segment filename"), + ), + responses( + (status = 200, description = "DASH segment data"), + (status = 202, description = "Segment not yet available"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn dash_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, diff --git a/crates/pinakes-server/src/routes/subtitles.rs b/crates/pinakes-server/src/routes/subtitles.rs index b8be6ca..2f71311 100644 --- a/crates/pinakes-server/src/routes/subtitles.rs +++ b/crates/pinakes-server/src/routes/subtitles.rs @@ -4,62 +4,185 @@ use axum::{ }; use pinakes_core::{ model::MediaId, - subtitles::{Subtitle, SubtitleFormat}, + subtitles::{ + Subtitle, + detect_format, + extract_embedded_track, + list_embedded_tracks, + validate_language_code, + }, }; use uuid::Uuid; use crate::{ - dto::{AddSubtitleRequest, SubtitleResponse, UpdateSubtitleOffsetRequest}, + dto::{ + AddSubtitleRequest, + SubtitleListResponse, + SubtitleResponse, + SubtitleTrackInfoResponse, + UpdateSubtitleOffsetRequest, + }, error::ApiError, state::AppState, }; +#[utoipa::path( + get, + path = "/api/v1/media/{id}/subtitles", + tag = "subtitles", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Subtitles and available embedded tracks", body = SubtitleListResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_subtitles( State(state): State, Path(id): Path, -) -> Result>, ApiError> { +) -> Result, ApiError> { + let item = state.storage.get_media(MediaId(id)).await?; let subtitles = state.storage.get_media_subtitles(MediaId(id)).await?; - Ok(Json( - subtitles.into_iter().map(SubtitleResponse::from).collect(), - )) + + let available_tracks = + list_embedded_tracks(&item.path).await.unwrap_or_default(); + + Ok(Json(SubtitleListResponse { + subtitles: subtitles + .into_iter() + .map(SubtitleResponse::from) + .collect(), + available_tracks: available_tracks + .into_iter() + .map(SubtitleTrackInfoResponse::from) + .collect(), + })) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/subtitles", + tag = "subtitles", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = AddSubtitleRequest, + responses( + (status = 200, description = "Subtitle added", body = SubtitleResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_subtitle( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { - let format: SubtitleFormat = req.format.parse().map_err(|e: String| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation(e)) - })?; + // Validate language code if provided. + if let Some(ref lang) = req.language { + if !validate_language_code(lang) { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()), + )); + } + } + let is_embedded = req.is_embedded.unwrap_or(false); - if !is_embedded && req.file_path.is_none() { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "file_path is required for non-embedded subtitles".into(), - ), - )); - } - if is_embedded && req.track_index.is_none() { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( + + let (file_path, resolved_format) = if is_embedded { + // Embedded subtitle: validate track_index and extract via ffmpeg. + let track_index = req.track_index.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( "track_index is required for embedded subtitles".into(), - ), - )); - } - if req - .language - .as_ref() - .is_some_and(|l| l.is_empty() || l.len() > 64) - { - return Err(ApiError::bad_request("language must be 1-64 bytes")); - } + )) + })?; + + let item = state.storage.get_media(MediaId(id)).await?; + let tracks = list_embedded_tracks(&item.path).await?; + + let track = + tracks + .iter() + .find(|t| t.index == track_index) + .ok_or(ApiError( + pinakes_core::error::PinakesError::SubtitleTrackNotFound { + index: track_index, + }, + ))?; + + // Use the format detected from the embedded track metadata as + // authoritative. + let embedded_format = track.format; + let ext = embedded_format.to_string(); + let output_dir = pinakes_core::config::Config::default_data_dir() + .join("subtitles") + .join(id.to_string()); + let output_path = output_dir.join(format!("{track_index}.{ext}")); + + tokio::fs::create_dir_all(&output_dir).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to create subtitle output dir: {e}"), + )) + })?; + + extract_embedded_track(&item.path, track_index, &output_path).await?; + + (Some(output_path), embedded_format) + } else { + // External subtitle file: validate path then detect format from content. + let path_str = req.file_path.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "file_path is required for non-embedded subtitles".into(), + )) + })?; + + let path = std::path::PathBuf::from(&path_str); + + use std::path::Component; + if !path.is_absolute() + || path.components().any(|c| c == Component::ParentDir) + { + return Err(ApiError::bad_request( + "file_path must be an absolute path within a configured root", + )); + } + let roots = state.config.read().await.directories.roots.clone(); + if !roots.iter().any(|root| path.starts_with(root)) { + return Err(ApiError::bad_request( + "file_path must be an absolute path within a configured root", + )); + } + + let exists = tokio::fs::try_exists(&path).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to check subtitle file: {e}"), + )) + })?; + + if !exists { + return Err(ApiError(pinakes_core::error::PinakesError::FileNotFound( + path, + ))); + } + + // Detect the actual format from the file extension; use it as authoritative + // rather than trusting the client-supplied format field. + let detected_format = detect_format(&path).ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("unrecognised subtitle format for: {}", path.display()), + )) + })?; + + (Some(path), detected_format) + }; + let subtitle = Subtitle { id: Uuid::now_v7(), media_id: MediaId(id), language: req.language, - format, - file_path: req.file_path.map(std::path::PathBuf::from), + format: resolved_format, + file_path, is_embedded, track_index: req.track_index, offset_ms: req.offset_ms.unwrap_or(0), @@ -69,6 +192,18 @@ pub async fn add_subtitle( Ok(Json(SubtitleResponse::from(subtitle))) } +#[utoipa::path( + delete, + path = "/api/v1/subtitles/{id}", + tag = "subtitles", + params(("id" = Uuid, Path, description = "Subtitle ID")), + responses( + (status = 200, description = "Subtitle deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_subtitle( State(state): State, Path(id): Path, @@ -77,6 +212,21 @@ pub async fn delete_subtitle( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{media_id}/subtitles/{subtitle_id}/content", + tag = "subtitles", + params( + ("media_id" = Uuid, Path, description = "Media item ID"), + ("subtitle_id" = Uuid, Path, description = "Subtitle ID"), + ), + responses( + (status = 200, description = "Subtitle content"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_subtitle_content( State(state): State, Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>, @@ -91,40 +241,65 @@ pub async fn get_subtitle_content( ))) })?; - if let Some(ref path) = subtitle.file_path { - let content = tokio::fs::read_to_string(path).await.map_err(|e| { + let path = subtitle.file_path.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "subtitle has no associated file to serve".into(), + )) + })?; + + let fmt = subtitle.format; + let content_type = fmt.mime_type(); + let body = if fmt.is_binary() { + let bytes = tokio::fs::read(&path).await.map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { ApiError(pinakes_core::error::PinakesError::FileNotFound( path.clone(), )) } else { ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to read subtitle file {}: {}", path.display(), e), + format!("failed to read subtitle file {}: {e}", path.display()), )) } })?; - let content_type = match subtitle.format { - SubtitleFormat::Vtt => "text/vtt", - SubtitleFormat::Srt => "application/x-subrip", - _ => "text/plain", - }; - axum::response::Response::builder() - .header("Content-Type", content_type) - .body(axum::body::Body::from(content)) - .map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to build response: {e}"), - )) - }) + axum::body::Body::from(bytes) } else { - Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "subtitle is embedded, no file to serve".into(), - ), - )) - } + let text = tokio::fs::read_to_string(&path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ApiError(pinakes_core::error::PinakesError::FileNotFound( + path.clone(), + )) + } else { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to read subtitle file {}: {e}", path.display()), + )) + } + })?; + axum::body::Body::from(text) + }; + + axum::response::Response::builder() + .header("Content-Type", content_type) + .body(body) + .map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to build response: {e}"), + )) + }) } +#[utoipa::path( + patch, + path = "/api/v1/subtitles/{id}/offset", + tag = "subtitles", + params(("id" = Uuid, Path, description = "Subtitle ID")), + request_body = UpdateSubtitleOffsetRequest, + responses( + (status = 200, description = "Offset updated"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_offset( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/sync.rs b/crates/pinakes-server/src/routes/sync.rs index e2ef48d..debc4cd 100644 --- a/crates/pinakes-server/src/routes/sync.rs +++ b/crates/pinakes-server/src/routes/sync.rs @@ -57,6 +57,19 @@ const DEFAULT_CHANGES_LIMIT: u64 = 100; /// Register a new sync device /// POST /api/sync/devices +#[utoipa::path( + post, + path = "/api/v1/sync/devices", + tag = "sync", + request_body = RegisterDeviceRequest, + responses( + (status = 200, description = "Device registered", body = DeviceRegistrationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn register_device( State(state): State, Extension(username): Extension, @@ -111,6 +124,16 @@ pub async fn register_device( /// List user's sync devices /// GET /api/sync/devices +#[utoipa::path( + get, + path = "/api/v1/sync/devices", + tag = "sync", + responses( + (status = 200, description = "List of devices", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_devices( State(state): State, Extension(username): Extension, @@ -127,6 +150,19 @@ pub async fn list_devices( /// Get device details /// GET /api/sync/devices/{id} +#[utoipa::path( + get, + path = "/api/v1/sync/devices/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Device ID")), + responses( + (status = 200, description = "Device details", body = DeviceResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_device( State(state): State, Extension(username): Extension, @@ -149,6 +185,20 @@ pub async fn get_device( /// Update a device /// PUT /api/sync/devices/{id} +#[utoipa::path( + put, + path = "/api/v1/sync/devices/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Device ID")), + request_body = UpdateDeviceRequest, + responses( + (status = 200, description = "Device updated", body = DeviceResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_device( State(state): State, Extension(username): Extension, @@ -185,6 +235,19 @@ pub async fn update_device( /// Delete a device /// DELETE /api/sync/devices/{id} +#[utoipa::path( + delete, + path = "/api/v1/sync/devices/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Device ID")), + responses( + (status = 204, description = "Device deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_device( State(state): State, Extension(username): Extension, @@ -213,6 +276,19 @@ pub async fn delete_device( /// Regenerate device token /// POST /api/sync/devices/{id}/token +#[utoipa::path( + post, + path = "/api/v1/sync/devices/{id}/token", + tag = "sync", + params(("id" = Uuid, Path, description = "Device ID")), + responses( + (status = 200, description = "Token regenerated", body = DeviceRegistrationResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn regenerate_token( State(state): State, Extension(username): Extension, @@ -253,6 +329,21 @@ pub async fn regenerate_token( /// Get changes since cursor /// GET /api/sync/changes +#[utoipa::path( + get, + path = "/api/v1/sync/changes", + tag = "sync", + params( + ("cursor" = Option, Query, description = "Sync cursor"), + ("limit" = Option, Query, description = "Max changes (max 1000)"), + ), + responses( + (status = 200, description = "Changes since cursor", body = ChangesResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_changes( State(state): State, Query(params): Query, @@ -290,6 +381,18 @@ pub async fn get_changes( /// Report local changes from client /// POST /api/sync/report +#[utoipa::path( + post, + path = "/api/v1/sync/report", + tag = "sync", + request_body = ReportChangesRequest, + responses( + (status = 200, description = "Changes processed", body = ReportChangesResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn report_changes( State(state): State, Extension(_username): Extension, @@ -392,6 +495,18 @@ pub async fn report_changes( /// Acknowledge processed changes /// POST /api/sync/ack +#[utoipa::path( + post, + path = "/api/v1/sync/ack", + tag = "sync", + request_body = AcknowledgeChangesRequest, + responses( + (status = 200, description = "Changes acknowledged"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn acknowledge_changes( State(state): State, Extension(_username): Extension, @@ -422,6 +537,16 @@ pub async fn acknowledge_changes( /// List unresolved conflicts /// GET /api/sync/conflicts +#[utoipa::path( + get, + path = "/api/v1/sync/conflicts", + tag = "sync", + responses( + (status = 200, description = "Unresolved conflicts", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_conflicts( State(state): State, Extension(_username): Extension, @@ -451,6 +576,19 @@ pub async fn list_conflicts( /// Resolve a sync conflict /// POST /api/sync/conflicts/{id}/resolve +#[utoipa::path( + post, + path = "/api/v1/sync/conflicts/{id}/resolve", + tag = "sync", + params(("id" = Uuid, Path, description = "Conflict ID")), + request_body = ResolveConflictRequest, + responses( + (status = 200, description = "Conflict resolved"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn resolve_conflict( State(state): State, Extension(_username): Extension, @@ -477,6 +615,18 @@ pub async fn resolve_conflict( /// Create an upload session for chunked upload /// POST /api/sync/upload +#[utoipa::path( + post, + path = "/api/v1/sync/upload", + tag = "sync", + request_body = CreateUploadSessionRequest, + responses( + (status = 200, description = "Upload session created", body = UploadSessionResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_upload( State(state): State, Extension(_username): Extension, @@ -541,6 +691,23 @@ pub async fn create_upload( /// Upload a chunk /// PUT /api/sync/upload/{id}/chunks/{index} +#[utoipa::path( + put, + path = "/api/v1/sync/upload/{id}/chunks/{index}", + tag = "sync", + params( + ("id" = Uuid, Path, description = "Upload session ID"), + ("index" = u64, Path, description = "Chunk index"), + ), + request_body(content = Vec, description = "Chunk binary data", content_type = "application/octet-stream"), + responses( + (status = 200, description = "Chunk received", body = ChunkUploadedResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn upload_chunk( State(state): State, Path((session_id, chunk_index)): Path<(Uuid, u64)>, @@ -590,6 +757,18 @@ pub async fn upload_chunk( /// Get upload session status /// GET /api/sync/upload/{id} +#[utoipa::path( + get, + path = "/api/v1/sync/upload/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Upload session ID")), + responses( + (status = 200, description = "Upload session status", body = UploadSessionResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_upload_status( State(state): State, Path(id): Path, @@ -603,6 +782,19 @@ pub async fn get_upload_status( /// Complete an upload session /// POST /api/sync/upload/{id}/complete +#[utoipa::path( + post, + path = "/api/v1/sync/upload/{id}/complete", + tag = "sync", + params(("id" = Uuid, Path, description = "Upload session ID")), + responses( + (status = 200, description = "Upload completed"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn complete_upload( State(state): State, Path(id): Path, @@ -759,6 +951,18 @@ pub async fn complete_upload( /// Cancel an upload session /// DELETE /api/sync/upload/{id} +#[utoipa::path( + delete, + path = "/api/v1/sync/upload/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Upload session ID")), + responses( + (status = 204, description = "Upload cancelled"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn cancel_upload( State(state): State, Path(id): Path, @@ -789,6 +993,19 @@ pub async fn cancel_upload( /// Download a file for sync (supports Range header) /// GET /api/sync/download/{*path} +#[utoipa::path( + get, + path = "/api/v1/sync/download/{path}", + tag = "sync", + params(("path" = String, Path, description = "File path")), + responses( + (status = 200, description = "File content"), + (status = 206, description = "Partial content"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn download_file( State(state): State, Path(path): Path, diff --git a/crates/pinakes-server/src/routes/tags.rs b/crates/pinakes-server/src/routes/tags.rs index 3c12ec2..506f855 100644 --- a/crates/pinakes-server/src/routes/tags.rs +++ b/crates/pinakes-server/src/routes/tags.rs @@ -11,6 +11,20 @@ use crate::{ state::AppState, }; +#[utoipa::path( + post, + path = "/api/v1/tags", + tag = "tags", + request_body = CreateTagRequest, + responses( + (status = 200, description = "Tag created", body = TagResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_tag( State(state): State, Json(req): Json, @@ -28,6 +42,17 @@ pub async fn create_tag( Ok(Json(TagResponse::from(tag))) } +#[utoipa::path( + get, + path = "/api/v1/tags", + tag = "tags", + responses( + (status = 200, description = "List of tags", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_tags( State(state): State, ) -> Result>, ApiError> { @@ -35,6 +60,19 @@ pub async fn list_tags( Ok(Json(tags.into_iter().map(TagResponse::from).collect())) } +#[utoipa::path( + get, + path = "/api/v1/tags/{id}", + tag = "tags", + params(("id" = Uuid, Path, description = "Tag ID")), + responses( + (status = 200, description = "Tag", body = TagResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_tag( State(state): State, Path(id): Path, @@ -43,6 +81,20 @@ pub async fn get_tag( Ok(Json(TagResponse::from(tag))) } +#[utoipa::path( + delete, + path = "/api/v1/tags/{id}", + tag = "tags", + params(("id" = Uuid, Path, description = "Tag ID")), + responses( + (status = 200, description = "Tag deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_tag( State(state): State, Path(id): Path, @@ -51,6 +103,21 @@ pub async fn delete_tag( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/media/{media_id}/tags", + tag = "tags", + params(("media_id" = Uuid, Path, description = "Media item ID")), + request_body = TagMediaRequest, + responses( + (status = 200, description = "Tag applied"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn tag_media( State(state): State, Path(media_id): Path, @@ -70,6 +137,23 @@ pub async fn tag_media( Ok(Json(serde_json::json!({"tagged": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/media/{media_id}/tags/{tag_id}", + tag = "tags", + params( + ("media_id" = Uuid, Path, description = "Media item ID"), + ("tag_id" = Uuid, Path, description = "Tag ID"), + ), + responses( + (status = 200, description = "Tag removed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn untag_media( State(state): State, Path((media_id, tag_id)): Path<(Uuid, Uuid)>, @@ -88,6 +172,19 @@ pub async fn untag_media( Ok(Json(serde_json::json!({"untagged": true}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{media_id}/tags", + tag = "tags", + params(("media_id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media tags", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media_tags( State(state): State, Path(media_id): Path, diff --git a/crates/pinakes-server/src/routes/transcode.rs b/crates/pinakes-server/src/routes/transcode.rs index c57becb..81a8b5c 100644 --- a/crates/pinakes-server/src/routes/transcode.rs +++ b/crates/pinakes-server/src/routes/transcode.rs @@ -11,6 +11,20 @@ use crate::{ state::AppState, }; +#[utoipa::path( + post, + path = "/api/v1/media/{id}/transcode", + tag = "transcode", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = CreateTranscodeRequest, + responses( + (status = 200, description = "Transcode job submitted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn start_transcode( State(state): State, Path(id): Path, @@ -29,6 +43,18 @@ pub async fn start_transcode( Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) } +#[utoipa::path( + get, + path = "/api/v1/transcode/{id}", + tag = "transcode", + params(("id" = Uuid, Path, description = "Transcode session ID")), + responses( + (status = 200, description = "Transcode session details", body = TranscodeSessionResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_session( State(state): State, Path(id): Path, @@ -37,6 +63,16 @@ pub async fn get_session( Ok(Json(TranscodeSessionResponse::from(session))) } +#[utoipa::path( + get, + path = "/api/v1/transcode", + tag = "transcode", + responses( + (status = 200, description = "List of transcode sessions", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_sessions( State(state): State, Query(params): Query, @@ -51,6 +87,18 @@ pub async fn list_sessions( )) } +#[utoipa::path( + delete, + path = "/api/v1/transcode/{id}", + tag = "transcode", + params(("id" = Uuid, Path, description = "Transcode session ID")), + responses( + (status = 200, description = "Transcode session cancelled"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn cancel_session( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/upload.rs b/crates/pinakes-server/src/routes/upload.rs index 947757f..cba6451 100644 --- a/crates/pinakes-server/src/routes/upload.rs +++ b/crates/pinakes-server/src/routes/upload.rs @@ -32,6 +32,18 @@ fn sanitize_content_disposition(filename: &str) -> String { /// Upload a file to managed storage /// POST /api/upload +#[utoipa::path( + post, + path = "/api/v1/upload", + tag = "upload", + responses( + (status = 200, description = "File uploaded", body = UploadResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn upload_file( State(state): State, mut multipart: Multipart, @@ -85,6 +97,19 @@ pub async fn upload_file( /// Download a managed file /// GET /api/media/{id}/download +#[utoipa::path( + get, + path = "/api/v1/media/{id}/download", + tag = "upload", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "File content"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn download_file( State(state): State, Path(id): Path, @@ -154,6 +179,19 @@ pub async fn download_file( /// Migrate an external file to managed storage /// POST /api/media/{id}/move-to-managed +#[utoipa::path( + post, + path = "/api/v1/media/{id}/move-to-managed", + tag = "upload", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 204, description = "File migrated"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn move_to_managed( State(state): State, Path(id): Path, @@ -177,6 +215,17 @@ pub async fn move_to_managed( /// Get managed storage statistics /// GET /api/managed/stats +#[utoipa::path( + get, + path = "/api/v1/managed/stats", + tag = "upload", + responses( + (status = 200, description = "Managed storage statistics", body = ManagedStorageStatsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn managed_stats( State(state): State, ) -> ApiResult> { diff --git a/crates/pinakes-server/src/routes/users.rs b/crates/pinakes-server/src/routes/users.rs index e97e8a5..f88e466 100644 --- a/crates/pinakes-server/src/routes/users.rs +++ b/crates/pinakes-server/src/routes/users.rs @@ -16,6 +16,17 @@ use crate::{ }; /// List all users (admin only) +#[utoipa::path( + get, + path = "/api/v1/admin/users", + tag = "users", + responses( + (status = 200, description = "List of users", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_users( State(state): State, ) -> Result>, ApiError> { @@ -24,6 +35,24 @@ pub async fn list_users( } /// Create a new user (admin only) +#[utoipa::path( + post, + path = "/api/v1/admin/users", + tag = "users", + request_body( + content = inline(serde_json::Value), + description = "username, password, role, and optional profile fields", + content_type = "application/json" + ), + responses( + (status = 200, description = "User created", body = UserResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_user( State(state): State, Json(req): Json, @@ -74,6 +103,19 @@ pub async fn create_user( } /// Get a specific user by ID +#[utoipa::path( + get, + path = "/api/v1/admin/users/{id}", + tag = "users", + params(("id" = String, Path, description = "User ID")), + responses( + (status = 200, description = "User details", body = UserResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_user( State(state): State, Path(id): Path, @@ -90,6 +132,25 @@ pub async fn get_user( } /// Update a user +#[utoipa::path( + patch, + path = "/api/v1/admin/users/{id}", + tag = "users", + params(("id" = String, Path, description = "User ID")), + request_body( + content = inline(serde_json::Value), + description = "Optional password, role, or profile fields to update", + content_type = "application/json" + ), + responses( + (status = 200, description = "User updated", body = UserResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_user( State(state): State, Path(id): Path, @@ -125,6 +186,19 @@ pub async fn update_user( } /// Delete a user (admin only) +#[utoipa::path( + delete, + path = "/api/v1/admin/users/{id}", + tag = "users", + params(("id" = String, Path, description = "User ID")), + responses( + (status = 200, description = "User deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_user( State(state): State, Path(id): Path, @@ -141,6 +215,18 @@ pub async fn delete_user( } /// Get user's accessible libraries +#[utoipa::path( + get, + path = "/api/v1/admin/users/{id}/libraries", + tag = "users", + params(("id" = String, Path, description = "User ID")), + responses( + (status = 200, description = "User libraries", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_user_libraries( State(state): State, Path(id): Path, @@ -177,6 +263,20 @@ fn validate_root_path(path: &str) -> Result<(), ApiError> { } /// Grant library access to a user (admin only) +#[utoipa::path( + post, + path = "/api/v1/admin/users/{id}/libraries", + tag = "users", + params(("id" = String, Path, description = "User ID")), + request_body = GrantLibraryAccessRequest, + responses( + (status = 200, description = "Access granted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn grant_library_access( State(state): State, Path(id): Path, @@ -202,6 +302,20 @@ pub async fn grant_library_access( /// /// Uses a JSON body instead of a path parameter because `root_path` may contain /// slashes that conflict with URL routing. +#[utoipa::path( + delete, + path = "/api/v1/admin/users/{id}/libraries", + tag = "users", + params(("id" = String, Path, description = "User ID")), + request_body = RevokeLibraryAccessRequest, + responses( + (status = 200, description = "Access revoked"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn revoke_library_access( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/webhooks.rs b/crates/pinakes-server/src/routes/webhooks.rs index b2c5ca5..ca53d70 100644 --- a/crates/pinakes-server/src/routes/webhooks.rs +++ b/crates/pinakes-server/src/routes/webhooks.rs @@ -3,12 +3,23 @@ use serde::Serialize; use crate::{error::ApiError, state::AppState}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct WebhookInfo { pub url: String, pub events: Vec, } +#[utoipa::path( + get, + path = "/api/v1/webhooks", + tag = "webhooks", + responses( + (status = 200, description = "List of configured webhooks", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_webhooks( State(state): State, ) -> Result>, ApiError> { @@ -26,6 +37,17 @@ pub async fn list_webhooks( Ok(Json(hooks)) } +#[utoipa::path( + post, + path = "/api/v1/webhooks/test", + tag = "webhooks", + responses( + (status = 200, description = "Test webhook sent"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn test_webhook( State(state): State, ) -> Result, ApiError> { diff --git a/crates/pinakes-server/tests/books.rs b/crates/pinakes-server/tests/books.rs index dbd0bce..5b7efdd 100644 --- a/crates/pinakes-server/tests/books.rs +++ b/crates/pinakes-server/tests/books.rs @@ -32,10 +32,7 @@ async fn get_book_metadata_not_found() { .oneshot(get(&format!("/api/v1/books/{fake_id}/metadata"))) .await .unwrap(); - assert!( - resp.status() == StatusCode::NOT_FOUND - || resp.status() == StatusCode::INTERNAL_SERVER_ERROR - ); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] @@ -77,10 +74,8 @@ async fn reading_progress_nonexistent_book() { )) .await .unwrap(); - // Nonexistent book; expect NOT_FOUND or empty response - assert!( - resp.status() == StatusCode::NOT_FOUND || resp.status() == StatusCode::OK - ); + // Nonexistent book always returns 404. + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] @@ -96,11 +91,8 @@ async fn update_reading_progress_nonexistent_book() { )) .await .unwrap(); - // Nonexistent book; expect NOT_FOUND or error - assert!( - resp.status() == StatusCode::NOT_FOUND - || resp.status() == StatusCode::INTERNAL_SERVER_ERROR - ); + // Nonexistent book: handler verifies existence first, so always 404. + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] diff --git a/crates/pinakes-server/tests/common/mod.rs b/crates/pinakes-server/tests/common/mod.rs index d4d38c9..6d8ba18 100644 --- a/crates/pinakes-server/tests/common/mod.rs +++ b/crates/pinakes-server/tests/common/mod.rs @@ -154,6 +154,7 @@ pub fn default_config() -> Config { authentication_disabled: true, cors_enabled: false, cors_origins: vec![], + swagger_ui: false, }, rate_limits: RateLimitConfig::default(), ui: UiConfig::default(), diff --git a/crates/pinakes-server/tests/notes.rs b/crates/pinakes-server/tests/notes.rs index 0dc246e..2dddd1e 100644 --- a/crates/pinakes-server/tests/notes.rs +++ b/crates/pinakes-server/tests/notes.rs @@ -51,7 +51,26 @@ async fn notes_graph_empty() { .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = response_body(resp).await; - assert!(body.is_object() || body.is_array()); + // Fresh database: graph must be empty. + if let Some(arr) = body.as_array() { + assert!(arr.is_empty(), "graph should be empty, got {arr:?}"); + } else if let Some(obj) = body.as_object() { + // Accept an object if the schema uses {nodes:[], edges:[]} style. + let nodes_empty = obj + .get("nodes") + .and_then(|v| v.as_array()) + .map_or(true, |a| a.is_empty()); + let edges_empty = obj + .get("edges") + .and_then(|v| v.as_array()) + .map_or(true, |a| a.is_empty()); + assert!( + nodes_empty && edges_empty, + "graph should be empty, got {obj:?}" + ); + } else { + panic!("expected array or object, got {body}"); + } } #[tokio::test] @@ -62,6 +81,12 @@ async fn unresolved_count_zero() { .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + // Fresh database has no unresolved links. + let count = body["count"] + .as_u64() + .expect("response should have a numeric 'count' field"); + assert_eq!(count, 0, "expected zero unresolved links in fresh database"); } #[tokio::test]