Files
keep/src/modes/server/api/item.rs
Andrew Phillips fdeb5f7951 Ugh
2026-02-19 13:57:39 -04:00

704 lines
24 KiB
Rust

use crate::modes::server::common::{
ApiResponse, AppState, ItemContentQuery, ItemInfo, ItemInfoListResponse, ItemInfoResponse,
ItemQuery, ListItemsQuery, MetadataResponse, TagsQuery,
};
use crate::services::async_item_service::AsyncItemService;
use crate::services::error::CoreError;
use axum::{
extract::{Path, Query, State},
http::{StatusCode, header},
response::{Json, Response},
};
use log::{debug, warn};
use std::collections::HashMap;
// Helper functions to replace the missing binary_detection module
async fn check_binary_content_allowed(
item_service: &AsyncItemService,
item_id: i64,
metadata: &HashMap<String, String>,
allow_binary: bool,
) -> Result<(), StatusCode> {
if !allow_binary {
let is_binary = is_content_binary(item_service, item_id, metadata).await?;
if is_binary {
return Err(StatusCode::BAD_REQUEST);
}
}
Ok(())
}
/// Helper function to determine if content is binary
async fn is_content_binary(
item_service: &AsyncItemService,
item_id: i64,
metadata: &HashMap<String, String>,
) -> Result<bool, StatusCode> {
if let Some(text_val) = metadata.get("text") {
Ok(text_val == "false")
} else {
// If text metadata isn't set, we need to check the content using streaming approach
match item_service
.get_item_content_info_streaming(item_id, None)
.await
{
Ok((_, _, is_binary)) => Ok(is_binary),
Err(e) => {
log::warn!(
"Failed to get content info for binary check for item {}: {}",
item_id,
e
);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
// Helper function to replace missing build_filter_string
fn build_filter_string(_params: &ItemQuery) -> Option<String> {
// Implement this based on your needs
None
}
// Create a simple ResponseBuilder to replace the missing one
struct ResponseBuilder;
impl ResponseBuilder {
pub fn json<T: serde::Serialize>(data: T) -> Result<Response, StatusCode> {
let json = serde_json::to_vec(&data).map_err(|e| {
log::warn!("Failed to serialize response: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.header(header::CONTENT_LENGTH, json.len().to_string())
.body(axum::body::Body::from(json))
.map_err(|e| {
log::warn!("Failed to build response: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})
}
pub fn binary(content: &[u8], mime_type: &str) -> Result<Response, StatusCode> {
Response::builder()
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_LENGTH, content.len().to_string())
.body(axum::body::Body::from(content.to_vec()))
.map_err(|e| {
log::warn!("Failed to build response: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})
}
}
/// Helper function to get mime type from metadata
fn get_mime_type(metadata: &HashMap<String, String>) -> String {
metadata
.get("mime_type")
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string())
}
/// Helper function to apply offset and length to content
fn apply_offset_length(content: &[u8], offset: u64, length: u64) -> &[u8] {
let content_len = content.len() as u64;
let start = std::cmp::min(offset, content_len);
let end = if length > 0 {
std::cmp::min(start + length, content_len)
} else {
content_len
};
if start < content_len {
&content[start as usize..end as usize]
} else {
&[]
}
}
/// Helper function to handle item not found errors
fn handle_item_error(error: CoreError) -> StatusCode {
match error {
CoreError::ItemNotFound(_) | CoreError::ItemNotFoundGeneric => StatusCode::NOT_FOUND,
_ => {
warn!("Failed to get item: {}", error);
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
/// Helper function to create AsyncItemService from AppState
fn create_item_service(state: &AppState) -> AsyncItemService {
AsyncItemService::new(
state.data_dir.clone(),
state.db.clone(),
state.item_service.clone(),
state.cmd.clone(),
state.settings.clone(),
)
}
#[utoipa::path(
get,
path = "/api/item/",
operation_id = "keep_list_items",
summary = "List stored items",
description = "Get paginated items with metadata and tags. Filter by tags, sort by creation time.",
responses(
(status = 200, description = "Items retrieved", body = ItemInfoListResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
params(
("tags" = Option<String>, Query, description = "Comma-separated tags to filter"),
("order" = Option<String>, Query, description = "Sort order: 'newest' or 'oldest'"),
("start" = Option<u64>, Query, description = "Pagination start index"),
("count" = Option<u64>, Query, description = "Number of items to return")
),
security(
("bearerAuth" = [])
),
tag = "item"
)]
pub async fn handle_list_items(
State(state): State<AppState>,
Query(params): Query<ListItemsQuery>,
) -> Result<Response, StatusCode> {
let tags: Vec<String> = params
.tags
.as_ref()
.map(|s| s.split(',').map(|t| t.trim().to_string()).collect())
.unwrap_or_default();
let item_service = create_item_service(&state);
let mut items_with_meta = item_service
.list_items(tags, HashMap::new())
.await
.map_err(|e| {
warn!("Failed to get items: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Apply ordering (default is newest first)
match params.order.as_deref().unwrap_or("newest") {
"newest" => items_with_meta.sort_by(|a, b| b.item.ts.cmp(&a.item.ts)),
"oldest" => items_with_meta.sort_by(|a, b| a.item.ts.cmp(&b.item.ts)),
_ => items_with_meta.sort_by(|a, b| b.item.ts.cmp(&a.item.ts)), // default to newest
}
// Apply pagination
let start = params.start.unwrap_or(0) as usize;
let count = params.count.unwrap_or(100) as usize;
let items_with_meta: Vec<_> = items_with_meta
.into_iter()
.skip(start)
.take(count)
.collect();
let item_infos: Vec<ItemInfo> = items_with_meta
.into_iter()
.map(|item_with_meta| {
let item_id = item_with_meta.item.id.unwrap_or(0);
let item_tags: Vec<String> =
item_with_meta.tags.iter().map(|t| t.name.clone()).collect();
let item_meta = item_with_meta.meta_as_map();
ItemInfo {
id: item_id,
ts: item_with_meta.item.ts.to_rfc3339(),
size: item_with_meta.item.size,
compression: item_with_meta.item.compression,
tags: item_tags,
metadata: item_meta,
}
})
.collect();
ResponseBuilder::json(ApiResponse {
success: true,
data: Some(item_infos),
error: None,
})
}
/// Handle as_meta=true response by returning JSON with metadata and content
async fn handle_as_meta_response(
item_service: &AsyncItemService,
item_id: i64,
offset: u64,
length: u64,
) -> Result<Response, StatusCode> {
// Get the item with metadata
let item_with_meta = item_service.get_item(item_id).await.map_err(|e| {
warn!("Failed to get item {} for as_meta content: {}", item_id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let metadata = item_with_meta.meta_as_map();
handle_as_meta_response_with_metadata(item_service, item_id, &metadata, offset, length).await
}
/// Handle as_meta=true response with pre-fetched metadata
async fn handle_as_meta_response_with_metadata(
item_service: &AsyncItemService,
item_id: i64,
metadata: &HashMap<String, String>,
offset: u64,
length: u64,
) -> Result<Response, StatusCode> {
// Check if content is binary
let is_binary = is_content_binary(item_service, item_id, metadata).await?;
// Get the content if it's not binary
if is_binary {
// Return JSON with content as None and error message
let response_body = serde_json::json!({
"metadata": metadata,
"content": serde_json::Value::Null,
"error": "Content is binary"
});
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.status(StatusCode::UNPROCESSABLE_ENTITY)
.body(axum::body::Body::from(response_body.to_string()))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} else {
// Get the content as text
match item_service.get_item_content_info(item_id, None).await {
Ok((content, _, _)) => {
// Apply offset and length
let content_len = content.len() as u64;
let start = std::cmp::min(offset, content_len);
let end = if length > 0 {
std::cmp::min(start + length, content_len)
} else {
content_len
};
let response_content = if start < content_len {
&content[start as usize..end as usize]
} else {
&[]
};
// Convert to UTF-8 string
let content_str = match String::from_utf8(response_content.to_vec()) {
Ok(s) => s,
Err(_) => {
// This shouldn't happen since we checked is_binary, but handle it just in case
let response_body = serde_json::json!({
"metadata": metadata,
"content": serde_json::Value::Null,
"error": "Content is not valid UTF-8"
});
let response = Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.status(StatusCode::UNPROCESSABLE_ENTITY)
.body(axum::body::Body::from(response_body.to_string()))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
return Ok(response);
}
};
// Return JSON with metadata and content
let response_body = serde_json::json!({
"metadata": metadata,
"content": content_str,
"error": serde_json::Value::Null
});
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(response_body.to_string()))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
Err(e) => {
warn!("Failed to get content for item {}: {}", item_id, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
#[utoipa::path(
post,
path = "/api/item/",
operation_id = "keep_post_item",
summary = "Store new item",
description = "Upload content to store as a new item. Content is compressed, analyzed for metadata, and stored.",
responses(
(status = 201, description = "Item created", body = ItemInfoResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
request_body(
content = String,
description = "Content to store",
content_type = "application/octet-stream"
),
security(
("bearerAuth" = [])
),
tag = "item"
)]
pub async fn handle_post_item(
State(_state): State<AppState>,
) -> Result<Json<ApiResponse<ItemInfo>>, StatusCode> {
// This is a simplified implementation
// In a real implementation, you'd need to properly parse multipart/form-data
// or JSON payload with the item data
let response = ApiResponse::<ItemInfo> {
success: false,
data: None,
error: Some("POST /api/item/ not yet implemented".to_string()),
};
Ok(Json(response))
}
#[utoipa::path(
get,
path = "/api/item/latest/content",
operation_id = "keep_get_item_latest_content",
summary = "Download latest item content",
description = "Get raw content of the most recent item. Filter by tags. Binary content can be restricted. \
AI agents should use as_meta=true to get content and metadata in a structured JSON format.",
responses(
(status = 200, description = "Content retrieved"),
(status = 400, description = "Binary content not allowed"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Item not found"),
(status = 500, description = "Internal server error")
),
params(
("tags" = Option<String>, Query, description = "Tags to filter latest item"),
("allow_binary" = Option<bool>, Query, description = "Allow binary content"),
("offset" = Option<u64>, Query, description = "Byte offset to start reading"),
("length" = Option<u64>, Query, description = "Number of bytes to read"),
("stream" = Option<bool>, Query, description = "Stream response (true) or build in memory (false)"),
("as_meta" = Option<bool>, Query, description = "Return content and metadata in JSON format (recommended for AI agents)")
),
security(
("bearerAuth" = [])
),
tag = "item"
)]
pub async fn handle_get_item_latest_content(
State(state): State<AppState>,
Query(params): Query<ItemContentQuery>,
) -> Result<Response, StatusCode> {
let tags: Vec<String> = params
.tags
.as_ref()
.map(|s| s.split(',').map(|t| t.trim().to_string()).collect())
.unwrap_or_default();
let item_service = create_item_service(&state);
// First find the item to get its ID and metadata
let item_with_meta = item_service.find_item(vec![], tags, HashMap::new()).await;
match item_with_meta {
Ok(item) => {
let item_id = item.item.id.unwrap();
let metadata = item.meta_as_map();
// Handle as_meta parameter
if params.as_meta {
// Force stream=false and allow_binary=false for as_meta=true
handle_as_meta_response_with_metadata(
&item_service,
item_id,
&metadata,
params.offset,
params.length,
)
.await
} else {
stream_item_content_response_with_metadata(
&item_service,
item_id,
&metadata,
params.allow_binary,
params.offset,
params.length,
params.stream,
None,
)
.await
}
}
Err(CoreError::ItemNotFoundGeneric) => Err(StatusCode::NOT_FOUND),
Err(e) => {
warn!("Failed to find latest item for content: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[utoipa::path(
get,
path = "/api/item/{item_id}/content",
operation_id = "keep_get_item_content",
summary = "Download item content",
description = "Get raw content of a specific item by ID. Binary content can be restricted. \
AI agents should use as_meta=true to get content and metadata in a structured JSON format.",
responses(
(status = 200, description = "Content retrieved"),
(status = 400, description = "Invalid ID or binary not allowed"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Item not found"),
(status = 500, description = "Internal server error")
),
params(
("item_id" = i64, Path, description = "Item ID"),
("allow_binary" = Option<bool>, Query, description = "Allow binary content"),
("offset" = Option<u64>, Query, description = "Byte offset to start reading"),
("length" = Option<u64>, Query, description = "Number of bytes to read"),
("stream" = Option<bool>, Query, description = "Stream response (true) or build in memory (false)"),
("as_meta" = Option<bool>, Query, description = "Return content and metadata in JSON format (recommended for AI agents)")
),
security(
("bearerAuth" = [])
),
tag = "item"
)]
pub async fn handle_get_item_content(
State(state): State<AppState>,
Path(item_id): Path<i64>,
Query(params): Query<ItemQuery>,
) -> Result<Response, StatusCode> {
// Validate that item ID is positive to prevent path traversal issues
if item_id <= 0 {
return Err(StatusCode::BAD_REQUEST);
}
debug!(
"ITEM_API: Getting content for item {} with stream={}, allow_binary={}, offset={}, length={}",
item_id, params.stream, params.allow_binary, params.offset, params.length
);
let filter = build_filter_string(&params);
let item_service = create_item_service(&state);
// Handle as_meta parameter
if params.as_meta {
// Force stream=false and allow_binary=false for as_meta=true
let result =
handle_as_meta_response(&item_service, item_id, params.offset, params.length).await;
if let Ok(response) = &result {
debug!(
"ITEM_API: Response content-length: {:?}",
response.headers().get("content-length")
);
}
result
} else {
let result = stream_item_content_response(
&item_service,
item_id,
params.allow_binary,
params.offset,
params.length,
params.stream,
filter,
)
.await;
if let Ok(response) = &result {
debug!(
"ITEM_API: Response content-length: {:?}",
response.headers().get("content-length")
);
}
result
}
}
async fn stream_item_content_response(
item_service: &AsyncItemService,
item_id: i64,
allow_binary: bool,
offset: u64,
length: u64,
stream: bool,
filter: Option<String>,
) -> Result<Response, StatusCode> {
debug!("STREAM_ITEM_CONTENT_RESPONSE: stream={}", stream);
// Get the item with metadata once
let item_with_meta = item_service.get_item(item_id).await.map_err(|e| {
warn!("Failed to get item {} for content: {}", item_id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let metadata = item_with_meta.meta_as_map();
stream_item_content_response_with_metadata(
item_service,
item_id,
&metadata,
allow_binary,
offset,
length,
stream,
filter,
)
.await
}
async fn stream_item_content_response_with_metadata(
item_service: &AsyncItemService,
item_id: i64,
metadata: &HashMap<String, String>,
allow_binary: bool,
offset: u64,
length: u64,
stream: bool,
filter: Option<String>,
) -> Result<Response, StatusCode> {
debug!(
"STREAM_ITEM_CONTENT_RESPONSE_WITH_METADATA: stream={}",
stream
);
let mime_type = get_mime_type(metadata);
// Check if content is binary when allow_binary is false
check_binary_content_allowed(item_service, item_id, metadata, allow_binary).await?;
if stream {
debug!("STREAMING: Using streaming approach");
match item_service
.stream_item_content_by_id_with_metadata(
item_id, metadata, true, offset, length, filter,
)
.await
{
Ok((stream, _)) => {
let body = axum::body::Body::from_stream(stream);
let response = Response::builder()
.header(header::CONTENT_TYPE, mime_type)
.body(body)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(response)
}
Err(e) => {
warn!("Failed to stream content for item {}: {}", item_id, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
} else {
debug!("NON-STREAMING: Building full response in memory");
match item_service.get_item_content_info(item_id, filter).await {
Ok((content, _, _)) => {
let response_content = apply_offset_length(&content, offset, length);
debug!(
"NON-STREAMING: Content length: {}, response length: {}",
content.len(),
response_content.len()
);
ResponseBuilder::binary(response_content, &mime_type)
}
Err(e) => {
warn!("Failed to get content for item {}: {}", item_id, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
#[utoipa::path(
get,
path = "/api/item/latest/meta",
operation_id = "keep_get_item_latest_meta",
summary = "Get latest item metadata",
description = "Retrieve metadata for the most recent item. Filter by tags.",
responses(
(status = 200, description = "Metadata retrieved", body = MetadataResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Item not found"),
(status = 500, description = "Internal server error")
),
params(
("tags" = Option<String>, Query, description = "Tags to filter latest item")
),
security(
("bearerAuth" = [])
),
tag = "item"
)]
pub async fn handle_get_item_latest_meta(
State(state): State<AppState>,
Query(params): Query<TagsQuery>,
) -> Result<Json<ApiResponse<HashMap<String, String>>>, StatusCode> {
let tags: Vec<String> = params
.tags
.as_ref()
.map(|s| s.split(',').map(|t| t.trim().to_string()).collect())
.unwrap_or_default();
let item_service = create_item_service(&state);
match item_service.find_item(vec![], tags, HashMap::new()).await {
Ok(item_with_meta) => {
let item_meta = item_with_meta.meta_as_map();
let response = ApiResponse {
success: true,
data: Some(item_meta),
error: None,
};
Ok(Json(response))
}
Err(e) => Err(handle_item_error(e)),
}
}
#[utoipa::path(
get,
path = "/api/item/{item_id}/meta",
operation_id = "keep_get_item_meta",
summary = "Get item metadata",
description = "Retrieve metadata for a specific item by ID.",
responses(
(status = 200, description = "Metadata retrieved", body = MetadataResponse),
(status = 400, description = "Invalid ID"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Item not found"),
(status = 500, description = "Internal server error")
),
params(
("item_id" = i64, Path, description = "Item ID")
),
security(
("bearerAuth" = [])
),
tag = "item"
)]
pub async fn handle_get_item_meta(
State(state): State<AppState>,
Path(item_id): Path<i64>,
) -> Result<Json<ApiResponse<HashMap<String, String>>>, StatusCode> {
let item_service = create_item_service(&state);
match item_service.get_item(item_id).await {
Ok(item_with_meta) => {
let item_meta = item_with_meta.meta_as_map();
let response = ApiResponse {
success: true,
data: Some(item_meta),
error: None,
};
Ok(Json(response))
}
Err(e) => Err(handle_item_error(e)),
}
}