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:
@@ -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>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user