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
This commit is contained in:
2026-03-05 22:16:10 +01:00
parent 40b7200bb3
commit 3ad1d57db6
7 changed files with 172 additions and 32 deletions

View File

@@ -13,6 +13,7 @@ pub struct ListBooksQuery {
pub library_id: Option<Uuid>, pub library_id: Option<Uuid>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub kind: Option<String>, pub kind: Option<String>,
#[schema(value_type = Option<String>)]
pub cursor: Option<Uuid>, pub cursor: Option<Uuid>,
#[schema(value_type = Option<i64>, example = 50)] #[schema(value_type = Option<i64>, example = 50)]
pub limit: Option<i64>, pub limit: Option<i64>,
@@ -20,7 +21,9 @@ pub struct ListBooksQuery {
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct BookItem { pub struct BookItem {
#[schema(value_type = String)]
pub id: Uuid, pub id: Uuid,
#[schema(value_type = String)]
pub library_id: Uuid, pub library_id: Uuid,
pub kind: String, pub kind: String,
pub title: String, pub title: String,
@@ -29,18 +32,22 @@ pub struct BookItem {
pub volume: Option<String>, pub volume: Option<String>,
pub language: Option<String>, pub language: Option<String>,
pub page_count: Option<i32>, pub page_count: Option<i32>,
#[schema(value_type = String)]
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct BooksPage { pub struct BooksPage {
pub items: Vec<BookItem>, pub items: Vec<BookItem>,
#[schema(value_type = Option<String>)]
pub next_cursor: Option<Uuid>, pub next_cursor: Option<Uuid>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct BookDetails { pub struct BookDetails {
#[schema(value_type = String)]
pub id: Uuid, pub id: Uuid,
#[schema(value_type = String)]
pub library_id: Uuid, pub library_id: Uuid,
pub kind: String, pub kind: String,
pub title: String, pub title: String,
@@ -54,19 +61,22 @@ pub struct BookDetails {
pub file_parse_status: Option<String>, pub file_parse_status: Option<String>,
} }
/// List books with optional filtering and pagination
#[utoipa::path( #[utoipa::path(
get, get,
path = "/books", path = "/books",
tag = "books", tag = "books",
params( params(
("library_id" = Option<Uuid>, Query, description = "Filter by library ID"), ("library_id" = Option<String>, Query, description = "Filter by library ID"),
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"), ("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"),
("cursor" = Option<Uuid>, Query, description = "Cursor for pagination"), ("cursor" = Option<String>, Query, description = "Cursor for pagination"),
("limit" = Option<i64>, Query, description = "Max items to return (max 200)"), ("limit" = Option<i64>, Query, description = "Max items to return (max 200)"),
), ),
responses( responses(
(status = 200, body = BooksPage), (status = 200, body = BooksPage),
) (status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)] )]
pub async fn list_books( pub async fn list_books(
State(state): State<AppState>, State(state): State<AppState>,
@@ -120,14 +130,20 @@ pub async fn list_books(
})) }))
} }
/// Get detailed information about a specific book
#[utoipa::path( #[utoipa::path(
get, get,
path = "/books/{id}", path = "/books/{id}",
tag = "books", tag = "books",
params(
("id" = String, Path, description = "Book UUID"),
),
responses( responses(
(status = 200, body = BookDetails), (status = 200, body = BookDetails),
(status = 404, description = "Book not found"), (status = 404, description = "Book not found"),
) (status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)] )]
pub async fn get_book( pub async fn get_book(
State(state): State<AppState>, State(state): State<AppState>,

View File

@@ -15,6 +15,7 @@ pub struct RebuildRequest {
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct IndexJobResponse { pub struct IndexJobResponse {
#[schema(value_type = String)]
pub id: Uuid, pub id: Uuid,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub library_id: Option<Uuid>, pub library_id: Option<Uuid>,
@@ -26,6 +27,7 @@ pub struct IndexJobResponse {
pub finished_at: Option<DateTime<Utc>>, pub finished_at: Option<DateTime<Utc>>,
pub stats_json: Option<serde_json::Value>, pub stats_json: Option<serde_json::Value>,
pub error_opt: Option<String>, pub error_opt: Option<String>,
#[schema(value_type = String)]
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@@ -35,14 +37,18 @@ pub struct FolderItem {
pub path: String, pub path: String,
} }
/// Enqueue a job to rebuild the search index for a library (or all libraries if no library_id specified)
#[utoipa::path( #[utoipa::path(
post, post,
path = "/index/rebuild", path = "/index/rebuild",
tag = "admin", tag = "indexing",
request_body = Option<RebuildRequest>, request_body = Option<RebuildRequest>,
responses( responses(
(status = 200, body = IndexJobResponse), (status = 200, body = IndexJobResponse),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn enqueue_rebuild( pub async fn enqueue_rebuild(
State(state): State<AppState>, State(state): State<AppState>,
@@ -69,13 +75,17 @@ pub async fn enqueue_rebuild(
Ok(Json(map_row(row))) Ok(Json(map_row(row)))
} }
/// List recent indexing jobs with their status
#[utoipa::path( #[utoipa::path(
get, get,
path = "/index/status", path = "/index/status",
tag = "admin", tag = "indexing",
responses( responses(
(status = 200, body = Vec<IndexJobResponse>), (status = 200, body = Vec<IndexJobResponse>),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn list_index_jobs(State(state): State<AppState>) -> Result<Json<Vec<IndexJobResponse>>, ApiError> { pub async fn list_index_jobs(State(state): State<AppState>) -> Result<Json<Vec<IndexJobResponse>>, ApiError> {
let rows = sqlx::query( let rows = sqlx::query(
@@ -87,14 +97,21 @@ pub async fn list_index_jobs(State(state): State<AppState>) -> Result<Json<Vec<I
Ok(Json(rows.into_iter().map(map_row).collect())) Ok(Json(rows.into_iter().map(map_row).collect()))
} }
/// Cancel a pending or running indexing job
#[utoipa::path( #[utoipa::path(
post, post,
path = "/index/cancel/{id}", path = "/index/cancel/{id}",
tag = "admin", tag = "indexing",
params(
("id" = String, Path, description = "Job UUID"),
),
responses( responses(
(status = 200, body = IndexJobResponse), (status = 200, body = IndexJobResponse),
(status = 404, description = "Job not found or already finished"), (status = 404, description = "Job not found or already finished"),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn cancel_job( pub async fn cancel_job(
State(state): State<AppState>, State(state): State<AppState>,
@@ -121,13 +138,17 @@ pub async fn cancel_job(
Ok(Json(map_row(row))) Ok(Json(map_row(row)))
} }
/// List available folders in /libraries for library creation
#[utoipa::path( #[utoipa::path(
get, get,
path = "/folders", path = "/folders",
tag = "admin", tag = "indexing",
responses( responses(
(status = 200, body = Vec<FolderItem>), (status = 200, body = Vec<FolderItem>),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn list_folders(State(_state): State<AppState>) -> Result<Json<Vec<FolderItem>>, ApiError> { pub async fn list_folders(State(_state): State<AppState>) -> Result<Json<Vec<FolderItem>>, ApiError> {
let libraries_path = std::path::Path::new("/libraries"); let libraries_path = std::path::Path::new("/libraries");

View File

@@ -10,6 +10,7 @@ use crate::{error::ApiError, AppState};
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct LibraryResponse { pub struct LibraryResponse {
#[schema(value_type = String)]
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub root_path: String, pub root_path: String,
@@ -25,13 +26,17 @@ pub struct CreateLibraryRequest {
pub root_path: String, pub root_path: String,
} }
/// List all libraries with their book counts
#[utoipa::path( #[utoipa::path(
get, get,
path = "/libraries", path = "/libraries",
tag = "admin", tag = "libraries",
responses( responses(
(status = 200, body = Vec<LibraryResponse>), (status = 200, body = Vec<LibraryResponse>),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> { pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
let rows = sqlx::query( let rows = sqlx::query(
@@ -56,15 +61,19 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
Ok(Json(items)) Ok(Json(items))
} }
/// Create a new library from an absolute path
#[utoipa::path( #[utoipa::path(
post, post,
path = "/libraries", path = "/libraries",
tag = "admin", tag = "libraries",
request_body = CreateLibraryRequest, request_body = CreateLibraryRequest,
responses( responses(
(status = 200, body = LibraryResponse), (status = 200, body = LibraryResponse),
(status = 400, description = "Invalid input"), (status = 400, description = "Invalid input"),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn create_library( pub async fn create_library(
State(state): State<AppState>, State(state): State<AppState>,
@@ -96,14 +105,21 @@ pub async fn create_library(
})) }))
} }
/// Delete a library by ID
#[utoipa::path( #[utoipa::path(
delete, delete,
path = "/libraries/{id}", path = "/libraries/{id}",
tag = "admin", tag = "libraries",
params(
("id" = String, Path, description = "Library UUID"),
),
responses( responses(
(status = 200, description = "Library deleted"), (status = 200, description = "Library deleted"),
(status = 404, description = "Library not found"), (status = 404, description = "Library not found"),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn delete_library( pub async fn delete_library(
State(state): State<AppState>, State(state): State<AppState>,

View File

@@ -1,3 +1,4 @@
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::OpenApi; use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
@@ -35,11 +36,73 @@ use utoipa::OpenApi;
crate::tokens::CreateTokenRequest, crate::tokens::CreateTokenRequest,
crate::tokens::TokenResponse, crate::tokens::TokenResponse,
crate::tokens::CreatedTokenResponse, crate::tokens::CreatedTokenResponse,
ErrorResponse,
) )
), ),
security(
("Bearer" = [])
),
tags( tags(
(name = "books", description = "Books management endpoints"), (name = "books", description = "Read-only endpoints for browsing and searching books"),
(name = "admin", description = "Admin management endpoints"), (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 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_<prefix>_<secret>)",
))
.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");
}
}

View File

@@ -64,13 +64,14 @@ impl OutputFormat {
} }
} }
/// Get a specific page image from a book with optional format conversion
#[utoipa::path( #[utoipa::path(
get, get,
path = "/books/{book_id}/pages/{n}", path = "/books/{book_id}/pages/{n}",
tag = "books", tag = "books",
params( params(
("book_id" = Uuid, description = "Book UUID"), ("book_id" = String, Path, description = "Book UUID"),
("n" = u32, description = "Page number (starts at 1)"), ("n" = u32, Path, description = "Page number (starts at 1)"),
("format" = Option<String>, Query, description = "Output format: webp, jpeg, png"), ("format" = Option<String>, Query, description = "Output format: webp, jpeg, png"),
("quality" = Option<u8>, Query, description = "JPEG quality 1-100"), ("quality" = Option<u8>, Query, description = "JPEG quality 1-100"),
("width" = Option<u32>, Query, description = "Max width (max 2160)"), ("width" = Option<u32>, Query, description = "Max width (max 2160)"),
@@ -79,7 +80,9 @@ impl OutputFormat {
(status = 200, description = "Page image", content_type = "image/webp"), (status = 200, description = "Page image", content_type = "image/webp"),
(status = 400, description = "Invalid parameters"), (status = 400, description = "Invalid parameters"),
(status = 404, description = "Book or page not found"), (status = 404, description = "Book or page not found"),
) (status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)] )]
pub async fn get_page( pub async fn get_page(
State(state): State<AppState>, State(state): State<AppState>,

View File

@@ -25,12 +25,13 @@ pub struct SearchResponse {
pub processing_time_ms: Option<u64>, pub processing_time_ms: Option<u64>,
} }
/// Search books across all libraries using Meilisearch
#[utoipa::path( #[utoipa::path(
get, get,
path = "/search", path = "/search",
tag = "books", tag = "books",
params( params(
("q" = String, description = "Search query"), ("q" = String, Query, description = "Search query"),
("library_id" = Option<String>, Query, description = "Filter by library ID"), ("library_id" = Option<String>, Query, description = "Filter by library ID"),
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"), ("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"), ("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
@@ -38,7 +39,9 @@ pub struct SearchResponse {
), ),
responses( responses(
(status = 200, body = SearchResponse), (status = 200, body = SearchResponse),
) (status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)] )]
pub async fn search_books( pub async fn search_books(
State(state): State<AppState>, State(state): State<AppState>,

View File

@@ -20,6 +20,7 @@ pub struct CreateTokenRequest {
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct TokenResponse { pub struct TokenResponse {
#[schema(value_type = String)]
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub scope: String, pub scope: String,
@@ -28,11 +29,13 @@ pub struct TokenResponse {
pub last_used_at: Option<DateTime<Utc>>, pub last_used_at: Option<DateTime<Utc>>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub revoked_at: Option<DateTime<Utc>>, pub revoked_at: Option<DateTime<Utc>>,
#[schema(value_type = String)]
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct CreatedTokenResponse { pub struct CreatedTokenResponse {
#[schema(value_type = String)]
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub scope: String, pub scope: String,
@@ -40,15 +43,19 @@ pub struct CreatedTokenResponse {
pub prefix: String, pub prefix: String,
} }
/// Create a new API token with read or admin scope. The token is only shown once.
#[utoipa::path( #[utoipa::path(
post, post,
path = "/admin/tokens", path = "/admin/tokens",
tag = "admin", tag = "tokens",
request_body = CreateTokenRequest, request_body = CreateTokenRequest,
responses( responses(
(status = 200, body = CreatedTokenResponse, description = "Token created - token is only shown once"), (status = 200, body = CreatedTokenResponse, description = "Token created - token is only shown once"),
(status = 400, description = "Invalid input"), (status = 400, description = "Invalid input"),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn create_token( pub async fn create_token(
State(state): State<AppState>, State(state): State<AppState>,
@@ -97,13 +104,17 @@ pub async fn create_token(
})) }))
} }
/// List all API tokens
#[utoipa::path( #[utoipa::path(
get, get,
path = "/admin/tokens", path = "/admin/tokens",
tag = "admin", tag = "tokens",
responses( responses(
(status = 200, body = Vec<TokenResponse>), (status = 200, body = Vec<TokenResponse>),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> { pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
let rows = sqlx::query( let rows = sqlx::query(
@@ -128,14 +139,21 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
Ok(Json(items)) Ok(Json(items))
} }
/// Revoke an API token by ID
#[utoipa::path( #[utoipa::path(
delete, delete,
path = "/admin/tokens/{id}", path = "/admin/tokens/{id}",
tag = "admin", tag = "tokens",
params(
("id" = String, Path, description = "Token UUID"),
),
responses( responses(
(status = 200, description = "Token revoked"), (status = 200, description = "Token revoked"),
(status = 404, description = "Token not found"), (status = 404, description = "Token not found"),
) (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)] )]
pub async fn revoke_token( pub async fn revoke_token(
State(state): State<AppState>, State(state): State<AppState>,