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>,
#[schema(value_type = Option<String>)]
pub kind: Option<String>,
#[schema(value_type = Option<String>)]
pub cursor: Option<Uuid>,
#[schema(value_type = Option<i64>, example = 50)]
pub limit: Option<i64>,
@@ -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<String>,
pub language: Option<String>,
pub page_count: Option<i32>,
#[schema(value_type = String)]
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize, ToSchema)]
pub struct BooksPage {
pub items: Vec<BookItem>,
#[schema(value_type = Option<String>)]
pub next_cursor: Option<Uuid>,
}
#[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<String>,
}
/// List books with optional filtering and pagination
#[utoipa::path(
get,
path = "/books",
tag = "books",
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)"),
("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)"),
),
responses(
(status = 200, body = BooksPage),
)
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn list_books(
State(state): State<AppState>,
@@ -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<AppState>,

View File

@@ -15,6 +15,7 @@ pub struct RebuildRequest {
#[derive(Serialize, ToSchema)]
pub struct IndexJobResponse {
#[schema(value_type = String)]
pub id: Uuid,
#[schema(value_type = Option<String>)]
pub library_id: Option<Uuid>,
@@ -26,6 +27,7 @@ pub struct IndexJobResponse {
pub finished_at: Option<DateTime<Utc>>,
pub stats_json: Option<serde_json::Value>,
pub error_opt: Option<String>,
#[schema(value_type = String)]
pub created_at: DateTime<Utc>,
}
@@ -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<RebuildRequest>,
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<AppState>,
@@ -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<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> {
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()))
}
/// Cancel a pending or running indexing job
#[utoipa::path(
post,
path = "/index/cancel/{id}",
tag = "admin",
tag = "indexing",
params(
("id" = String, Path, description = "Job UUID"),
),
responses(
(status = 200, body = IndexJobResponse),
(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(
State(state): State<AppState>,
@@ -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<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> {
let libraries_path = std::path::Path::new("/libraries");

View File

@@ -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<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> {
let rows = sqlx::query(
@@ -56,15 +61,19 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
Ok(Json(items))
}
/// Create a new library from an absolute path
#[utoipa::path(
post,
path = "/libraries",
tag = "admin",
tag = "libraries",
request_body = CreateLibraryRequest,
responses(
(status = 200, body = LibraryResponse),
(status = 400, description = "Invalid input"),
)
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn create_library(
State(state): State<AppState>,
@@ -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<AppState>,

View File

@@ -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_<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(
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<String>, Query, description = "Output format: webp, jpeg, png"),
("quality" = Option<u8>, Query, description = "JPEG quality 1-100"),
("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 = 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<AppState>,

View File

@@ -25,12 +25,13 @@ pub struct SearchResponse {
pub processing_time_ms: Option<u64>,
}
/// 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<String>, Query, description = "Filter by library ID"),
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
("kind" = Option<String>, 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<AppState>,

View File

@@ -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<DateTime<Utc>>,
#[schema(value_type = Option<String>)]
pub revoked_at: Option<DateTime<Utc>>,
#[schema(value_type = String)]
pub created_at: DateTime<Utc>,
}
#[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<AppState>,
@@ -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<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> {
let rows = sqlx::query(
@@ -128,14 +139,21 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
Ok(Json(items))
}
/// Revoke an API token by ID
#[utoipa::path(
delete,
path = "/admin/tokens/{id}",
tag = "admin",
tag = "tokens",
params(
("id" = String, Path, description = "Token UUID"),
),
responses(
(status = 200, description = "Token revoked"),
(status = 404, description = "Token not found"),
)
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn revoke_token(
State(state): State<AppState>,