feat: enhance HTTP logging and API responses with content metadata

Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
This commit is contained in:
Andrew Phillips
2025-08-13 13:20:57 -03:00
parent 8bd918129c
commit b07c9df812
4 changed files with 173 additions and 72 deletions

View File

@@ -13,25 +13,27 @@ use anyhow::{Result, anyhow};
use crate::compression_engine::{CompressionType, get_compression_engine}; use crate::compression_engine::{CompressionType, get_compression_engine};
use crate::db; 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( #[utoipa::path(
get, get,
path = "/api/item/", path = "/api/item/",
responses( responses(
(status = 200, description = "Successfully retrieved list of items", body = ApiResponse<Vec<ItemInfo>>), (status = 200, description = "Successfully retrieved paginated list of items with metadata and tags", body = ApiResponse<Vec<ItemInfo>>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error - Failed to retrieve items from database")
), ),
params( params(
("tags" = Option<String>, Query, description = "Comma-separated list of tags to filter by"), ("tags" = Option<String>, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work')"),
("order" = Option<String>, Query, description = "Sort order (newest or oldest)"), ("order" = Option<String>, Query, description = "Sort order: 'newest' (default) or 'oldest'"),
("start" = Option<u64>, Query, description = "Starting index for pagination"), ("start" = Option<u64>, Query, description = "Starting index for pagination (default: 0)"),
("count" = Option<u64>, Query, description = "Number of items to return") ("count" = Option<u64>, Query, description = "Maximum number of items to return (default: 100, max: 1000)")
), ),
security( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_list_items( pub async fn handle_list_items(
State(state): State<AppState>, State(state): State<AppState>,
@@ -121,13 +123,20 @@ pub async fn handle_list_items(
post, post,
path = "/api/item/", path = "/api/item/",
responses( responses(
(status = 200, description = "Successfully created item", body = ApiResponse<ItemInfo>), (status = 201, description = "Successfully created new item", body = ApiResponse<ItemInfo>),
(status = 401, description = "Unauthorized"), (status = 400, description = "Bad request - Invalid input data"),
(status = 500, description = "Internal server error") (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( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_post_item( pub async fn handle_post_item(
State(_state): State<AppState>, State(_state): State<AppState>,
@@ -150,17 +159,19 @@ pub async fn handle_post_item(
delete, delete,
path = "/api/item/{item_id}", path = "/api/item/{item_id}",
responses( responses(
(status = 200, description = "Successfully deleted item"), (status = 200, description = "Successfully deleted item and associated metadata"),
(status = 401, description = "Unauthorized"), (status = 400, description = "Bad request - Invalid item ID"),
(status = 404, description = "Item not found"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 500, description = "Internal server error") (status = 404, description = "Item not found - No item exists with the specified ID"),
(status = 500, description = "Internal server error - Failed to delete item")
), ),
params( 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( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_delete_item( pub async fn handle_delete_item(
State(state): State<AppState>, State(state): State<AppState>,
@@ -194,22 +205,23 @@ pub async fn handle_delete_item(
get, get,
path = "/api/item/latest", path = "/api/item/latest",
responses( responses(
(status = 200, description = "Successfully retrieved latest item content", body = ApiResponse<String>), (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<ItemContentInfo>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 404, description = "Item not found"), (status = 404, description = "Item not found - No items exist or no items match the specified tags"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error - Failed to retrieve item content")
), ),
params( params(
("tags" = Option<String>, Query, description = "Comma-separated list of tags to filter by") ("tags" = Option<String>, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns the latest item matching ALL tags.")
), ),
security( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_get_item_latest( pub async fn handle_get_item_latest(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<TagsQuery>, Query(params): Query<TagsQuery>,
) -> Result<Json<ApiResponse<String>>, StatusCode> { ) -> Result<Json<ApiResponse<ItemContentInfo>>, StatusCode> {
let mut conn = state.db.lock().await; let mut conn = state.db.lock().await;
@@ -228,18 +240,18 @@ pub async fn handle_get_item_latest(
}; };
if let Some(item) = item { if let Some(item) = item {
match get_item_content(&item, &state.data_dir).await { match get_item_content_info(&item, &state.data_dir, &mut *conn).await {
Ok(content) => { Ok(content_info) => {
let response = ApiResponse { let response = ApiResponse {
success: true, success: true,
data: Some(content), data: Some(content_info),
error: None, error: None,
}; };
Ok(Json(response)) Ok(Json(response))
} }
Err(e) => { Err(e) => {
warn!("Failed to get content for item {}: {}", item.id.unwrap_or(0), e); warn!("Failed to get content for item {}: {}", item.id.unwrap_or(0), e);
let response = ApiResponse::<String> { let response = ApiResponse::<ItemContentInfo> {
success: false, success: false,
data: None, data: None,
error: Some(format!("Failed to retrieve content: {}", e)), error: Some(format!("Failed to retrieve content: {}", e)),
@@ -256,22 +268,24 @@ pub async fn handle_get_item_latest(
get, get,
path = "/api/item/{item_id}", path = "/api/item/{item_id}",
responses( responses(
(status = 200, description = "Successfully retrieved item content", body = ApiResponse<String>), (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<ItemContentInfo>),
(status = 401, description = "Unauthorized"), (status = 400, description = "Bad request - Invalid item ID"),
(status = 404, description = "Item not found"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 500, description = "Internal server error") (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( 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( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_get_item( pub async fn handle_get_item(
State(state): State<AppState>, State(state): State<AppState>,
Path(item_id): Path<i64>, Path(item_id): Path<i64>,
) -> Result<Json<ApiResponse<String>>, StatusCode> { ) -> Result<Json<ApiResponse<ItemContentInfo>>, StatusCode> {
// Validate that item ID is positive to prevent path traversal issues // Validate that item ID is positive to prevent path traversal issues
if item_id <= 0 { if item_id <= 0 {
return Err(StatusCode::BAD_REQUEST); 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); warn!("Failed to get item {} for content: {}", item_id, e);
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})? { })? {
match get_item_content(&item, &state.data_dir).await { match get_item_content_info(&item, &state.data_dir, &mut *conn).await {
Ok(content) => { Ok(content_info) => {
let response = ApiResponse { let response = ApiResponse {
success: true, success: true,
data: Some(content), data: Some(content_info),
error: None, error: None,
}; };
Ok(Json(response)) Ok(Json(response))
} }
Err(e) => { Err(e) => {
warn!("Failed to get content for item {}: {}", item_id, e); warn!("Failed to get content for item {}: {}", item_id, e);
let response = ApiResponse::<String> { let response = ApiResponse::<ItemContentInfo> {
success: false, success: false,
data: None, data: None,
error: Some(format!("Failed to retrieve content: {}", e)), error: Some(format!("Failed to retrieve content: {}", e)),
@@ -311,17 +325,18 @@ pub async fn handle_get_item(
get, get,
path = "/api/item/latest/content", path = "/api/item/latest/content",
responses( responses(
(status = 200, description = "Successfully retrieved latest item raw content"), (status = 200, description = "Successfully retrieved latest item raw content with appropriate MIME type header"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 404, description = "Item not found"), (status = 404, description = "Item not found - No items exist or no items match the specified tags"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error - Failed to retrieve item content")
), ),
params( params(
("tags" = Option<String>, Query, description = "Comma-separated list of tags to filter by") ("tags" = Option<String>, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns the latest item matching ALL tags.")
), ),
security( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_get_item_latest_content( pub async fn handle_get_item_latest_content(
State(state): State<AppState>, State(state): State<AppState>,
@@ -368,17 +383,19 @@ pub async fn handle_get_item_latest_content(
get, get,
path = "/api/item/{item_id}/content", path = "/api/item/{item_id}/content",
responses( responses(
(status = 200, description = "Successfully retrieved item raw content"), (status = 200, description = "Successfully retrieved item raw content with appropriate MIME type header"),
(status = 401, description = "Unauthorized"), (status = 400, description = "Bad request - Invalid item ID"),
(status = 404, description = "Item not found"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 500, description = "Internal server error") (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( 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( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_get_item_content( pub async fn handle_get_item_content(
State(state): State<AppState>, State(state): State<AppState>,
@@ -436,6 +453,57 @@ async fn get_item_content(item: &db::Item, data_dir: &PathBuf) -> Result<String>
Ok(content) Ok(content)
} }
async fn get_item_content_info(item: &db::Item, data_dir: &PathBuf, conn: &mut rusqlite::Connection) -> Result<ItemContentInfo> {
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<String, String> = 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<u8>, String)> { async fn get_item_raw_content(item: &db::Item, data_dir: &PathBuf, conn: &mut rusqlite::Connection) -> Result<(Vec<u8>, String)> {
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?; 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, get,
path = "/api/item/latest/meta", path = "/api/item/latest/meta",
responses( responses(
(status = 200, description = "Successfully retrieved latest item metadata", body = ApiResponse<HashMap<String, String>>), (status = 200, description = "Successfully retrieved latest item metadata including file type, encoding, size, and other system information", body = ApiResponse<HashMap<String, String>>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 404, description = "Item not found"), (status = 404, description = "Item not found - No items exist or no items match the specified tags"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error - Failed to retrieve item metadata")
), ),
params( params(
("tags" = Option<String>, Query, description = "Comma-separated list of tags to filter by") ("tags" = Option<String>, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns the latest item matching ALL tags.")
), ),
security( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_get_item_latest_meta( pub async fn handle_get_item_latest_meta(
State(state): State<AppState>, State(state): State<AppState>,
@@ -531,17 +600,19 @@ pub async fn handle_get_item_latest_meta(
get, get,
path = "/api/item/{item_id}/meta", path = "/api/item/{item_id}/meta",
responses( responses(
(status = 200, description = "Successfully retrieved item metadata", body = ApiResponse<HashMap<String, String>>), (status = 200, description = "Successfully retrieved item metadata including file type, encoding, size, and other system information", body = ApiResponse<HashMap<String, String>>),
(status = 401, description = "Unauthorized"), (status = 400, description = "Bad request - Invalid item ID"),
(status = 404, description = "Item not found"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 500, description = "Internal server error") (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( 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( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "item"
)] )]
pub async fn handle_get_item_meta( pub async fn handle_get_item_meta(
State(state): State<AppState>, State(state): State<AppState>,

View File

@@ -12,6 +12,14 @@ use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[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( paths(
status::handle_status, status::handle_status,
item::handle_list_items, item::handle_list_items,
@@ -27,11 +35,19 @@ use utoipa_swagger_ui::SwaggerUi;
schemas( schemas(
crate::common::status::StatusInfo, crate::common::status::StatusInfo,
crate::modes::server::common::ItemInfo, crate::modes::server::common::ItemInfo,
crate::modes::server::common::ItemContentInfo,
),
security_schemes(
("bearerAuth" = ("http", "bearer")),
("basicAuth" = ("http", "basic"))
) )
), ),
tags( tags(
(name = "status", description = "Status API endpoints"), (name = "status", description = "System status and health check endpoints"),
(name = "item", description = "Item management API endpoints") (name = "item", description = "Item management endpoints for storing, retrieving, and managing content with metadata")
),
servers(
(url = "/", description = "Local server")
) )
)] )]
struct ApiDoc; struct ApiDoc;

View File

@@ -12,13 +12,14 @@ use crate::meta_plugin::MetaPluginType;
get, get,
path = "/api/status", path = "/api/status",
responses( responses(
(status = 200, description = "Successfully retrieved status information", body = ApiResponse<StatusInfo>), (status = 200, description = "Successfully retrieved status information including database path, data directory, and supported plugins", body = ApiResponse<StatusInfo>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error - Failed to retrieve status information")
), ),
security( security(
("bearerAuth" = []) ("bearerAuth" = [])
) ),
tag = "status"
)] )]
pub async fn handle_status( pub async fn handle_status(
State(state): State<AppState>, State(state): State<AppState>,

View File

@@ -58,6 +58,14 @@ pub struct ItemInfo {
pub metadata: HashMap<String, String>, pub metadata: HashMap<String, String>,
} }
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ItemContentInfo {
#[serde(flatten)]
pub metadata: HashMap<String, String>,
pub content: Option<String>,
pub binary: bool,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TagsQuery { pub struct TagsQuery {
pub tags: Option<String>, pub tags: Option<String>,
@@ -111,12 +119,17 @@ pub async fn logging_middleware(
) -> Response { ) -> Response {
let method = request.method().clone(); let method = request.method().clone();
let uri = request.uri().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::<u64>().ok())
.unwrap_or(0);
let start = Instant::now(); let start = Instant::now();
let response = next.run(request).await; let response = next.run(request).await;
let duration = start.elapsed(); let duration = start.elapsed();
info!("{} {} {} {} - {:?}", addr, method, uri, response.status(), duration); info!("{} {} {} {} {} bytes - {:?}", addr, method, uri, response.status(), content_length, duration);
response response
} }