From 3ad1d57db66aedb0f308753ea576c140a8fcc8c3 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Thu, 5 Mar 2026 22:16:10 +0100 Subject: [PATCH] feat(api): improve Swagger/OpenAPI documentation - Fix Uuid and DateTime schema references (convert to String types) - Add Bearer authentication scheme with global authorize button - Add detailed descriptions to all API routes - Reorganize tags into logical categories (books, libraries, indexing, tokens) - Add security requirements and response documentation - Fix dead_code warning --- apps/api/src/books.rs | 24 ++++++++++--- apps/api/src/index_jobs.rs | 37 +++++++++++++++----- apps/api/src/libraries.rs | 28 ++++++++++++---- apps/api/src/openapi.rs | 69 ++++++++++++++++++++++++++++++++++++-- apps/api/src/pages.rs | 9 +++-- apps/api/src/search.rs | 7 ++-- apps/api/src/tokens.rs | 30 +++++++++++++---- 7 files changed, 172 insertions(+), 32 deletions(-) diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index fcbaff5..7871867 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -13,6 +13,7 @@ pub struct ListBooksQuery { pub library_id: Option, #[schema(value_type = Option)] pub kind: Option, + #[schema(value_type = Option)] pub cursor: Option, #[schema(value_type = Option, example = 50)] pub limit: Option, @@ -20,7 +21,9 @@ pub struct ListBooksQuery { #[derive(Serialize, ToSchema)] pub struct BookItem { + #[schema(value_type = String)] pub id: Uuid, + #[schema(value_type = String)] pub library_id: Uuid, pub kind: String, pub title: String, @@ -29,18 +32,22 @@ pub struct BookItem { pub volume: Option, pub language: Option, pub page_count: Option, + #[schema(value_type = String)] pub updated_at: DateTime, } #[derive(Serialize, ToSchema)] pub struct BooksPage { pub items: Vec, + #[schema(value_type = Option)] pub next_cursor: Option, } #[derive(Serialize, ToSchema)] pub struct BookDetails { + #[schema(value_type = String)] pub id: Uuid, + #[schema(value_type = String)] pub library_id: Uuid, pub kind: String, pub title: String, @@ -54,19 +61,22 @@ pub struct BookDetails { pub file_parse_status: Option, } +/// List books with optional filtering and pagination #[utoipa::path( get, path = "/books", tag = "books", params( - ("library_id" = Option, Query, description = "Filter by library ID"), + ("library_id" = Option, Query, description = "Filter by library ID"), ("kind" = Option, Query, description = "Filter by book kind (cbz, cbr, pdf)"), - ("cursor" = Option, Query, description = "Cursor for pagination"), + ("cursor" = Option, Query, description = "Cursor for pagination"), ("limit" = Option, Query, description = "Max items to return (max 200)"), ), responses( (status = 200, body = BooksPage), - ) + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) )] pub async fn list_books( State(state): State, @@ -120,14 +130,20 @@ pub async fn list_books( })) } +/// Get detailed information about a specific book #[utoipa::path( get, path = "/books/{id}", tag = "books", + params( + ("id" = String, Path, description = "Book UUID"), + ), responses( (status = 200, body = BookDetails), (status = 404, description = "Book not found"), - ) + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) )] pub async fn get_book( State(state): State, diff --git a/apps/api/src/index_jobs.rs b/apps/api/src/index_jobs.rs index 76b0877..ebba8be 100644 --- a/apps/api/src/index_jobs.rs +++ b/apps/api/src/index_jobs.rs @@ -15,6 +15,7 @@ pub struct RebuildRequest { #[derive(Serialize, ToSchema)] pub struct IndexJobResponse { + #[schema(value_type = String)] pub id: Uuid, #[schema(value_type = Option)] pub library_id: Option, @@ -26,6 +27,7 @@ pub struct IndexJobResponse { pub finished_at: Option>, pub stats_json: Option, pub error_opt: Option, + #[schema(value_type = String)] pub created_at: DateTime, } @@ -35,14 +37,18 @@ pub struct FolderItem { pub path: String, } +/// Enqueue a job to rebuild the search index for a library (or all libraries if no library_id specified) #[utoipa::path( post, path = "/index/rebuild", - tag = "admin", + tag = "indexing", request_body = Option, responses( (status = 200, body = IndexJobResponse), - ) + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) )] pub async fn enqueue_rebuild( State(state): State, @@ -69,13 +75,17 @@ pub async fn enqueue_rebuild( Ok(Json(map_row(row))) } +/// List recent indexing jobs with their status #[utoipa::path( get, path = "/index/status", - tag = "admin", + tag = "indexing", responses( (status = 200, body = Vec), - ) + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) )] pub async fn list_index_jobs(State(state): State) -> Result>, ApiError> { let rows = sqlx::query( @@ -87,14 +97,21 @@ pub async fn list_index_jobs(State(state): State) -> Result, @@ -121,13 +138,17 @@ pub async fn cancel_job( Ok(Json(map_row(row))) } +/// List available folders in /libraries for library creation #[utoipa::path( get, path = "/folders", - tag = "admin", + tag = "indexing", responses( (status = 200, body = Vec), - ) + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) )] pub async fn list_folders(State(_state): State) -> Result>, ApiError> { let libraries_path = std::path::Path::new("/libraries"); diff --git a/apps/api/src/libraries.rs b/apps/api/src/libraries.rs index 81d8c5e..badb504 100644 --- a/apps/api/src/libraries.rs +++ b/apps/api/src/libraries.rs @@ -10,6 +10,7 @@ use crate::{error::ApiError, AppState}; #[derive(Serialize, ToSchema)] pub struct LibraryResponse { + #[schema(value_type = String)] pub id: Uuid, pub name: String, pub root_path: String, @@ -25,13 +26,17 @@ pub struct CreateLibraryRequest { pub root_path: String, } +/// List all libraries with their book counts #[utoipa::path( get, path = "/libraries", - tag = "admin", + tag = "libraries", responses( (status = 200, body = Vec), - ) + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) )] pub async fn list_libraries(State(state): State) -> Result>, ApiError> { let rows = sqlx::query( @@ -56,15 +61,19 @@ pub async fn list_libraries(State(state): State) -> Result, @@ -96,14 +105,21 @@ pub async fn create_library( })) } +/// Delete a library by ID #[utoipa::path( delete, path = "/libraries/{id}", - tag = "admin", + tag = "libraries", + params( + ("id" = String, Path, description = "Library UUID"), + ), responses( (status = 200, description = "Library deleted"), (status = 404, description = "Library not found"), - ) + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) )] pub async fn delete_library( State(state): State, diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index dc8a807..55ea9f8 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -1,3 +1,4 @@ +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa::OpenApi; #[derive(OpenApi)] @@ -35,11 +36,73 @@ use utoipa::OpenApi; crate::tokens::CreateTokenRequest, crate::tokens::TokenResponse, crate::tokens::CreatedTokenResponse, + ErrorResponse, ) ), + security( + ("Bearer" = []) + ), tags( - (name = "books", description = "Books management endpoints"), - (name = "admin", description = "Admin management endpoints"), - ) + (name = "books", description = "Read-only endpoints for browsing and searching books"), + (name = "libraries", description = "Library management endpoints (Admin only)"), + (name = "indexing", description = "Search index management and job control (Admin only)"), + (name = "tokens", description = "API token management (Admin only)"), + ), + modifiers(&SecurityAddon) )] pub struct ApiDoc; + +pub 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", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some( + "Enter your API Bearer token (format: stl__)", + )) + .build(), + ), + ); + } + } +} + +#[derive(utoipa::ToSchema)] +pub struct ErrorResponse { + #[allow(dead_code)] + pub error: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use utoipa::OpenApi; + + #[test] + fn test_openapi_generation() { + let api_doc = ApiDoc::openapi(); + let json = api_doc + .to_pretty_json() + .expect("Failed to serialize OpenAPI"); + + // Check that there are no references to non-existent schemas + assert!( + !json.contains("\"/components/schemas/Uuid\""), + "Uuid schema should not be referenced" + ); + assert!( + !json.contains("\"/components/schemas/DateTime\""), + "DateTime schema should not be referenced" + ); + + // Save to file for inspection + std::fs::write("/tmp/openapi.json", &json).expect("Failed to write file"); + println!("OpenAPI JSON saved to /tmp/openapi.json"); + } +} diff --git a/apps/api/src/pages.rs b/apps/api/src/pages.rs index b62fb4d..8bb2102 100644 --- a/apps/api/src/pages.rs +++ b/apps/api/src/pages.rs @@ -64,13 +64,14 @@ impl OutputFormat { } } +/// Get a specific page image from a book with optional format conversion #[utoipa::path( get, path = "/books/{book_id}/pages/{n}", tag = "books", params( - ("book_id" = Uuid, description = "Book UUID"), - ("n" = u32, description = "Page number (starts at 1)"), + ("book_id" = String, Path, description = "Book UUID"), + ("n" = u32, Path, description = "Page number (starts at 1)"), ("format" = Option, Query, description = "Output format: webp, jpeg, png"), ("quality" = Option, Query, description = "JPEG quality 1-100"), ("width" = Option, Query, description = "Max width (max 2160)"), @@ -79,7 +80,9 @@ impl OutputFormat { (status = 200, description = "Page image", content_type = "image/webp"), (status = 400, description = "Invalid parameters"), (status = 404, description = "Book or page not found"), - ) + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) )] pub async fn get_page( State(state): State, diff --git a/apps/api/src/search.rs b/apps/api/src/search.rs index c726dd2..82ea888 100644 --- a/apps/api/src/search.rs +++ b/apps/api/src/search.rs @@ -25,12 +25,13 @@ pub struct SearchResponse { pub processing_time_ms: Option, } +/// Search books across all libraries using Meilisearch #[utoipa::path( get, path = "/search", tag = "books", params( - ("q" = String, description = "Search query"), + ("q" = String, Query, description = "Search query"), ("library_id" = Option, Query, description = "Filter by library ID"), ("type" = Option, Query, description = "Filter by type (cbz, cbr, pdf)"), ("kind" = Option, Query, description = "Filter by kind (alias for type)"), @@ -38,7 +39,9 @@ pub struct SearchResponse { ), responses( (status = 200, body = SearchResponse), - ) + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) )] pub async fn search_books( State(state): State, diff --git a/apps/api/src/tokens.rs b/apps/api/src/tokens.rs index db0de41..a1cacb9 100644 --- a/apps/api/src/tokens.rs +++ b/apps/api/src/tokens.rs @@ -20,6 +20,7 @@ pub struct CreateTokenRequest { #[derive(Serialize, ToSchema)] pub struct TokenResponse { + #[schema(value_type = String)] pub id: Uuid, pub name: String, pub scope: String, @@ -28,11 +29,13 @@ pub struct TokenResponse { pub last_used_at: Option>, #[schema(value_type = Option)] pub revoked_at: Option>, + #[schema(value_type = String)] pub created_at: DateTime, } #[derive(Serialize, ToSchema)] pub struct CreatedTokenResponse { + #[schema(value_type = String)] pub id: Uuid, pub name: String, pub scope: String, @@ -40,15 +43,19 @@ pub struct CreatedTokenResponse { pub prefix: String, } +/// Create a new API token with read or admin scope. The token is only shown once. #[utoipa::path( post, path = "/admin/tokens", - tag = "admin", + tag = "tokens", request_body = CreateTokenRequest, responses( (status = 200, body = CreatedTokenResponse, description = "Token created - token is only shown once"), (status = 400, description = "Invalid input"), - ) + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) )] pub async fn create_token( State(state): State, @@ -97,13 +104,17 @@ pub async fn create_token( })) } +/// List all API tokens #[utoipa::path( get, path = "/admin/tokens", - tag = "admin", + tag = "tokens", responses( (status = 200, body = Vec), - ) + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) )] pub async fn list_tokens(State(state): State) -> Result>, ApiError> { let rows = sqlx::query( @@ -128,14 +139,21 @@ pub async fn list_tokens(State(state): State) -> Result,