From b07c9df812623d4a689c822f19551cab8882ab75 Mon Sep 17 00:00:00 2001 From: Andrew Phillips Date: Wed, 13 Aug 2025 13:20:57 -0300 Subject: [PATCH] feat: enhance HTTP logging and API responses with content metadata Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) --- src/modes/server/api/item.rs | 201 ++++++++++++++++++++++----------- src/modes/server/api/mod.rs | 20 +++- src/modes/server/api/status.rs | 9 +- src/modes/server/common.rs | 15 ++- 4 files changed, 173 insertions(+), 72 deletions(-) diff --git a/src/modes/server/api/item.rs b/src/modes/server/api/item.rs index 1ada179..19260fe 100644 --- a/src/modes/server/api/item.rs +++ b/src/modes/server/api/item.rs @@ -13,25 +13,27 @@ use anyhow::{Result, anyhow}; use crate::compression_engine::{CompressionType, get_compression_engine}; use crate::db; -use crate::modes::server::common::{AppState, ApiResponse, ItemInfo, TagsQuery, ListItemsQuery}; +use crate::modes::server::common::{AppState, ApiResponse, ItemInfo, ItemContentInfo, TagsQuery, ListItemsQuery}; +use crate::common::is_binary::is_binary; #[utoipa::path( get, path = "/api/item/", responses( - (status = 200, description = "Successfully retrieved list of items", body = ApiResponse>), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully retrieved paginated list of items with metadata and tags", body = ApiResponse>), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 500, description = "Internal server error - Failed to retrieve items from database") ), params( - ("tags" = Option, Query, description = "Comma-separated list of tags to filter by"), - ("order" = Option, Query, description = "Sort order (newest or oldest)"), - ("start" = Option, Query, description = "Starting index for pagination"), - ("count" = Option, Query, description = "Number of items to return") + ("tags" = Option, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work')"), + ("order" = Option, Query, description = "Sort order: 'newest' (default) or 'oldest'"), + ("start" = Option, Query, description = "Starting index for pagination (default: 0)"), + ("count" = Option, Query, description = "Maximum number of items to return (default: 100, max: 1000)") ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_list_items( State(state): State, @@ -121,13 +123,20 @@ pub async fn handle_list_items( post, path = "/api/item/", responses( - (status = 200, description = "Successfully created item", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") + (status = 201, description = "Successfully created new item", body = ApiResponse), + (status = 400, description = "Bad request - Invalid input data"), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 500, description = "Internal server error - Failed to create item") + ), + request_body( + content = String, + description = "Content to store as new item", + content_type = "application/octet-stream" ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_post_item( State(_state): State, @@ -150,17 +159,19 @@ pub async fn handle_post_item( delete, path = "/api/item/{item_id}", responses( - (status = 200, description = "Successfully deleted item"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Item not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully deleted item and associated metadata"), + (status = 400, description = "Bad request - Invalid item ID"), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 404, description = "Item not found - No item exists with the specified ID"), + (status = 500, description = "Internal server error - Failed to delete item") ), params( - ("item_id" = i64, Path, description = "ID of the item to delete") + ("item_id" = i64, Path, description = "Unique identifier of the item to delete (must be positive)") ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_delete_item( State(state): State, @@ -194,22 +205,23 @@ pub async fn handle_delete_item( get, path = "/api/item/latest", responses( - (status = 200, description = "Successfully retrieved latest item content", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Item not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully retrieved latest item with content and metadata. Content is included if item is text-based, otherwise only metadata is returned.", body = ApiResponse), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 404, description = "Item not found - No items exist or no items match the specified tags"), + (status = 500, description = "Internal server error - Failed to retrieve item content") ), params( - ("tags" = Option, Query, description = "Comma-separated list of tags to filter by") + ("tags" = Option, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns the latest item matching ALL tags.") ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_get_item_latest( State(state): State, Query(params): Query, -) -> Result>, StatusCode> { +) -> Result>, StatusCode> { let mut conn = state.db.lock().await; @@ -228,18 +240,18 @@ pub async fn handle_get_item_latest( }; if let Some(item) = item { - match get_item_content(&item, &state.data_dir).await { - Ok(content) => { + match get_item_content_info(&item, &state.data_dir, &mut *conn).await { + Ok(content_info) => { let response = ApiResponse { success: true, - data: Some(content), + data: Some(content_info), error: None, }; Ok(Json(response)) } Err(e) => { warn!("Failed to get content for item {}: {}", item.id.unwrap_or(0), e); - let response = ApiResponse:: { + let response = ApiResponse:: { success: false, data: None, error: Some(format!("Failed to retrieve content: {}", e)), @@ -256,22 +268,24 @@ pub async fn handle_get_item_latest( get, path = "/api/item/{item_id}", responses( - (status = 200, description = "Successfully retrieved item content", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Item not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully retrieved item with content and metadata. Content is included if item is text-based, otherwise only metadata is returned.", body = ApiResponse), + (status = 400, description = "Bad request - Invalid item ID"), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 404, description = "Item not found - No item exists with the specified ID"), + (status = 500, description = "Internal server error - Failed to retrieve item content") ), params( - ("item_id" = i64, Path, description = "ID of the item to retrieve") + ("item_id" = i64, Path, description = "Unique identifier of the item to retrieve (must be positive)") ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_get_item( State(state): State, Path(item_id): Path, -) -> Result>, StatusCode> { +) -> Result>, StatusCode> { // Validate that item ID is positive to prevent path traversal issues if item_id <= 0 { return Err(StatusCode::BAD_REQUEST); @@ -283,18 +297,18 @@ pub async fn handle_get_item( warn!("Failed to get item {} for content: {}", item_id, e); StatusCode::INTERNAL_SERVER_ERROR })? { - match get_item_content(&item, &state.data_dir).await { - Ok(content) => { + match get_item_content_info(&item, &state.data_dir, &mut *conn).await { + Ok(content_info) => { let response = ApiResponse { success: true, - data: Some(content), + data: Some(content_info), error: None, }; Ok(Json(response)) } Err(e) => { warn!("Failed to get content for item {}: {}", item_id, e); - let response = ApiResponse:: { + let response = ApiResponse:: { success: false, data: None, error: Some(format!("Failed to retrieve content: {}", e)), @@ -311,17 +325,18 @@ pub async fn handle_get_item( get, path = "/api/item/latest/content", responses( - (status = 200, description = "Successfully retrieved latest item raw content"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Item not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully retrieved latest item raw content with appropriate MIME type header"), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 404, description = "Item not found - No items exist or no items match the specified tags"), + (status = 500, description = "Internal server error - Failed to retrieve item content") ), params( - ("tags" = Option, Query, description = "Comma-separated list of tags to filter by") + ("tags" = Option, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns the latest item matching ALL tags.") ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_get_item_latest_content( State(state): State, @@ -368,17 +383,19 @@ pub async fn handle_get_item_latest_content( get, path = "/api/item/{item_id}/content", responses( - (status = 200, description = "Successfully retrieved item raw content"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Item not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully retrieved item raw content with appropriate MIME type header"), + (status = 400, description = "Bad request - Invalid item ID"), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 404, description = "Item not found - No item exists with the specified ID"), + (status = 500, description = "Internal server error - Failed to retrieve item content") ), params( - ("item_id" = i64, Path, description = "ID of the item to retrieve") + ("item_id" = i64, Path, description = "Unique identifier of the item to retrieve (must be positive)") ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_get_item_content( State(state): State, @@ -436,6 +453,57 @@ async fn get_item_content(item: &db::Item, data_dir: &PathBuf) -> Result Ok(content) } +async fn get_item_content_info(item: &db::Item, data_dir: &PathBuf, conn: &mut rusqlite::Connection) -> Result { + let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?; + + // Validate that item ID is positive to prevent path traversal issues + if item_id <= 0 { + return Err(anyhow!("Invalid item ID: {}", item_id)); + } + + // Get metadata + let meta_entries = db::get_item_meta(conn, item) + .map_err(|e| anyhow!("Failed to get metadata: {}", e))?; + + let metadata: HashMap = meta_entries + .iter() + .map(|m| (m.name.clone(), m.value.clone())) + .collect(); + + // Determine if content is binary + let is_binary = if let Some(binary_meta) = metadata.get("binary") { + binary_meta == "true" + } else { + // Fall back to checking the actual content + let mut item_path = data_dir.clone(); + item_path.push(item_id.to_string()); + + let compression_type = CompressionType::from_str(&item.compression)?; + let compression_engine = get_compression_engine(compression_type)?; + + let mut reader = compression_engine.open(item_path)?; + let mut buffer = [0u8; 8192]; // Read first 8KB to check + let bytes_read = reader.read(&mut buffer)?; + is_binary(&buffer[..bytes_read]) + }; + + // Get content if not binary + let content = if is_binary { + None + } else { + match get_item_content(item, data_dir).await { + Ok(content_str) => Some(content_str), + Err(_) => None, // If we can't read as string, treat as binary + } + }; + + Ok(ItemContentInfo { + metadata, + content, + binary: is_binary, + }) +} + async fn get_item_raw_content(item: &db::Item, data_dir: &PathBuf, conn: &mut rusqlite::Connection) -> Result<(Vec, String)> { let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?; @@ -472,17 +540,18 @@ async fn get_item_raw_content(item: &db::Item, data_dir: &PathBuf, conn: &mut ru get, path = "/api/item/latest/meta", responses( - (status = 200, description = "Successfully retrieved latest item metadata", body = ApiResponse>), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Item not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully retrieved latest item metadata including file type, encoding, size, and other system information", body = ApiResponse>), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 404, description = "Item not found - No items exist or no items match the specified tags"), + (status = 500, description = "Internal server error - Failed to retrieve item metadata") ), params( - ("tags" = Option, Query, description = "Comma-separated list of tags to filter by") + ("tags" = Option, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns the latest item matching ALL tags.") ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_get_item_latest_meta( State(state): State, @@ -531,17 +600,19 @@ pub async fn handle_get_item_latest_meta( get, path = "/api/item/{item_id}/meta", responses( - (status = 200, description = "Successfully retrieved item metadata", body = ApiResponse>), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Item not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully retrieved item metadata including file type, encoding, size, and other system information", body = ApiResponse>), + (status = 400, description = "Bad request - Invalid item ID"), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 404, description = "Item not found - No item exists with the specified ID"), + (status = 500, description = "Internal server error - Failed to retrieve item metadata") ), params( - ("item_id" = i64, Path, description = "ID of the item to retrieve metadata for") + ("item_id" = i64, Path, description = "Unique identifier of the item to retrieve metadata for (must be positive)") ), security( ("bearerAuth" = []) - ) + ), + tag = "item" )] pub async fn handle_get_item_meta( State(state): State, diff --git a/src/modes/server/api/mod.rs b/src/modes/server/api/mod.rs index cca3acb..271a0f8 100644 --- a/src/modes/server/api/mod.rs +++ b/src/modes/server/api/mod.rs @@ -12,6 +12,14 @@ use utoipa_swagger_ui::SwaggerUi; #[derive(OpenApi)] #[openapi( + info( + title = "Keep API", + version = "0.1.0", + description = "REST API for Keep - a tool to manage temporary files with automatic compression and metadata generation", + contact( + name = "Keep Project", + ) + ), paths( status::handle_status, item::handle_list_items, @@ -27,11 +35,19 @@ use utoipa_swagger_ui::SwaggerUi; schemas( crate::common::status::StatusInfo, crate::modes::server::common::ItemInfo, + crate::modes::server::common::ItemContentInfo, + ), + security_schemes( + ("bearerAuth" = ("http", "bearer")), + ("basicAuth" = ("http", "basic")) ) ), tags( - (name = "status", description = "Status API endpoints"), - (name = "item", description = "Item management API endpoints") + (name = "status", description = "System status and health check endpoints"), + (name = "item", description = "Item management endpoints for storing, retrieving, and managing content with metadata") + ), + servers( + (url = "/", description = "Local server") ) )] struct ApiDoc; diff --git a/src/modes/server/api/status.rs b/src/modes/server/api/status.rs index cf01ac9..2ca7f2d 100644 --- a/src/modes/server/api/status.rs +++ b/src/modes/server/api/status.rs @@ -12,13 +12,14 @@ use crate::meta_plugin::MetaPluginType; get, path = "/api/status", responses( - (status = 200, description = "Successfully retrieved status information", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") + (status = 200, description = "Successfully retrieved status information including database path, data directory, and supported plugins", body = ApiResponse), + (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), + (status = 500, description = "Internal server error - Failed to retrieve status information") ), security( ("bearerAuth" = []) - ) + ), + tag = "status" )] pub async fn handle_status( State(state): State, diff --git a/src/modes/server/common.rs b/src/modes/server/common.rs index 0e2116a..cd51286 100644 --- a/src/modes/server/common.rs +++ b/src/modes/server/common.rs @@ -58,6 +58,14 @@ pub struct ItemInfo { pub metadata: HashMap, } +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ItemContentInfo { + #[serde(flatten)] + pub metadata: HashMap, + pub content: Option, + pub binary: bool, +} + #[derive(Debug, Deserialize)] pub struct TagsQuery { pub tags: Option, @@ -111,12 +119,17 @@ pub async fn logging_middleware( ) -> Response { let method = request.method().clone(); let uri = request.uri().clone(); + let content_length = request.headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); let start = Instant::now(); let response = next.run(request).await; let duration = start.elapsed(); - info!("{} {} {} {} - {:?}", addr, method, uri, response.status(), duration); + info!("{} {} {} {} {} bytes - {:?}", addr, method, uri, response.status(), content_length, duration); response }