feat: unify CLI and API with DataService trait

- Add DataService trait with streaming support for save/get operations
- Implement SyncDataService for CLI and AsyncDataService for API
- Add missing API endpoints: DELETE /api/item/{id}, GET /api/item/{id}/info, GET /api/diff
- Add GET /api/plugins/status endpoint
- Preserve stdin/stdout streaming performance via Read trait
This commit is contained in:
2026-03-10 22:31:31 -03:00
parent fb4c1a2b11
commit e8ea42506e
8 changed files with 742 additions and 2 deletions

View File

@@ -11,6 +11,7 @@ use axum::{
};
use log::{debug, warn};
use std::collections::HashMap;
use std::io::Read;
// Helper functions to replace the missing binary_detection module
async fn check_binary_content_allowed(
@@ -701,3 +702,231 @@ pub async fn handle_get_item_meta(
Err(e) => Err(handle_item_error(e)),
}
}
#[utoipa::path(
delete,
path = "/api/item/{item_id}",
tag = "items",
params(
("item_id" = i64, Path, description = "ID of the item to delete")
),
responses(
(status = 200, description = "Item deleted successfully", body = ApiResponse<ItemInfo>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Item not found"),
(status = 500, description = "Internal server error")
),
security(
("bearerAuth" = [])
)
)]
pub async fn handle_delete_item(
State(state): State<AppState>,
Path(item_id): Path<i64>,
) -> Result<Json<ApiResponse<ItemInfo>>, StatusCode> {
let conn = state.db.lock().await;
let sync_service =
crate::services::SyncDataService::new(state.data_dir.clone(), state.settings.clone());
let deleted_item = sync_service
.delete_item(&mut conn.clone(), item_id)
.map_err(handle_item_error)?;
let item_info = ItemInfo {
id: deleted_item.id,
ts: deleted_item.ts,
size: deleted_item.size,
compression: deleted_item.compression,
tags: vec![],
meta: HashMap::new(),
};
let response = ApiResponse {
success: true,
data: Some(item_info),
error: None,
};
Ok(Json(response))
}
#[utoipa::path(
get,
path = "/api/item/{item_id}/info",
tag = "items",
params(
("item_id" = i64, Path, description = "ID of the item to get info for")
),
responses(
(status = 200, description = "Item info retrieved successfully", body = ApiResponse<ItemInfo>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Item not found"),
(status = 500, description = "Internal server error")
),
security(
("bearerAuth" = [])
)
)]
pub async fn handle_get_item_info(
State(state): State<AppState>,
Path(item_id): Path<i64>,
) -> Result<Json<ApiResponse<ItemInfo>>, StatusCode> {
let conn = state.db.lock().await;
let sync_service =
crate::services::SyncDataService::new(state.data_dir.clone(), state.settings.clone());
let item_with_meta = sync_service
.get_item(&mut conn.clone(), item_id)
.map_err(handle_item_error)?;
let tags: Vec<String> = item_with_meta.tags.iter().map(|t| t.name.clone()).collect();
let item_info = ItemInfo {
id: item_with_meta.item.id,
ts: item_with_meta.item.ts,
size: item_with_meta.item.size,
compression: item_with_meta.item.compression,
tags,
meta: item_with_meta.meta_as_map(),
};
let response = ApiResponse {
success: true,
data: Some(item_info),
error: None,
};
Ok(Json(response))
}
#[derive(serde::Deserialize)]
pub struct DiffQuery {
id_a: Option<i64>,
id_b: Option<i64>,
tag_a: Option<String>,
tag_b: Option<String>,
}
#[utoipa::path(
get,
path = "/api/diff",
tag = "items",
params(
("id_a" = Option<i64>, Query, description = "First item ID"),
("id_b" = Option<i64>, Query, description = "Second item ID"),
("tag_a" = Option<String>, Query, description = "Tag to find first item"),
("tag_b" = Option<String>, Query, description = "Tag to find second item"),
),
responses(
(status = 200, description = "Diff between two items", body = ApiResponse<Vec<String>>),
(status = 400, description = "Invalid request - need two items to compare"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Item not found"),
(status = 500, description = "Internal server error")
),
security(
("bearerAuth" = [])
)
)]
pub async fn handle_diff_items(
State(state): State<AppState>,
Query(query): Query<DiffQuery>,
) -> Result<Json<ApiResponse<Vec<String>>>, StatusCode> {
let conn = state.db.lock().await;
let sync_service =
crate::services::SyncDataService::new(state.data_dir.clone(), state.settings.clone());
let item_a = if let Some(id_a) = query.id_a {
sync_service
.get_item(&mut conn.clone(), id_a)
.map_err(handle_item_error)?
} else if let Some(tag) = &query.tag_a {
sync_service
.find_item(&mut conn.clone(), vec![], vec![tag.clone()], HashMap::new())
.map_err(handle_item_error)?
} else {
return Err(StatusCode::BAD_REQUEST);
};
let item_b = if let Some(id_b) = query.id_b {
sync_service
.get_item(&mut conn.clone(), id_b)
.map_err(handle_item_error)?
} else if let Some(tag) = &query.tag_b {
sync_service
.find_item(&mut conn.clone(), vec![], vec![tag.clone()], HashMap::new())
.map_err(handle_item_error)?
} else {
return Err(StatusCode::BAD_REQUEST);
};
let id_a = item_a.item.id.unwrap();
let id_b = item_b.item.id.unwrap();
let (reader_a, _) = sync_service
.get_content(&mut conn.clone(), id_a)
.map_err(handle_item_error)?;
let (reader_b, _) = sync_service
.get_content(&mut conn.clone(), id_b)
.map_err(handle_item_error)?;
let mut content_a = Vec::new();
reader_a.read_to_end(&mut content_a).map_err(|e| {
log::error!("Failed to read content A: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut content_b = Vec::new();
reader_b.read_to_end(&mut content_b).map_err(|e| {
log::error!("Failed to read content B: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let diff_lines = compute_diff(&content_a, &content_b);
let response = ApiResponse {
success: true,
data: Some(diff_lines),
error: None,
};
Ok(Json(response))
}
fn compute_diff(a: &[u8], b: &[u8]) -> Vec<String> {
let text_a = String::from_utf8_lossy(a);
let text_b = String::from_utf8_lossy(b);
let lines_a: Vec<&str> = text_a.lines().collect();
let lines_b: Vec<&str> = text_b.lines().collect();
let mut diff_lines = Vec::new();
let max_lines = std::cmp::max(lines_a.len(), lines_b.len());
for i in 0..max_lines {
let line_a = lines_a.get(i).copied();
let line_b = lines_b.get(i).copied();
match (line_a, line_b) {
(Some(la), Some(lb)) if la == lb => {
diff_lines.push(format!(" {}", la));
}
(Some(la), Some(lb)) => {
diff_lines.push(format!("- {}", la));
diff_lines.push(format!("+ {}", lb));
}
(Some(la), None) => {
diff_lines.push(format!("- {}", la));
}
(None, Some(lb)) => {
diff_lines.push(format!("+ {}", lb));
}
(None, None) => {}
}
}
diff_lines
}

View File

@@ -4,7 +4,10 @@ pub mod item;
pub mod mcp;
pub mod status;
use axum::{Router, routing::get};
use axum::{
Router,
routing::{delete, get},
};
use crate::modes::server::common::AppState;
use utoipa::OpenApi;
@@ -59,6 +62,7 @@ pub fn add_routes(router: Router<AppState>) -> Router<AppState> {
let router = router
// Status endpoints
.route("/api/status", get(status::handle_status))
.route("/api/plugins/status", get(status::handle_plugins_status))
// Item endpoints
.route(
"/api/item/",
@@ -76,7 +80,10 @@ pub fn add_routes(router: Router<AppState>) -> Router<AppState> {
.route(
"/api/item/{item_id}/content",
get(item::handle_get_item_content),
);
)
.route("/api/item/{item_id}", delete(item::handle_delete_item))
.route("/api/item/{item_id}/info", get(item::handle_get_item_info))
.route("/api/diff", get(item::handle_diff_items));
#[cfg(feature = "mcp")]
{

View File

@@ -75,3 +75,61 @@ pub async fn handle_status(
Ok(Json(response))
}
#[derive(Debug, serde::Serialize)]
pub struct PluginsStatusResponse {
pub meta_plugins: std::collections::HashMap<String, crate::common::status::MetaPluginInfo>,
pub filter_plugins: Vec<crate::common::status::FilterPluginInfo>,
pub compression: Vec<crate::common::status::CompressionInfo>,
}
#[utoipa::path(
get,
path = "/api/plugins/status",
operation_id = "keep_plugins_status",
summary = "Get plugins status",
description = "Retrieve detailed status of all available plugins including meta, filter, and compression plugins.",
responses(
(status = 200, description = "Plugins status retrieved", body = ApiResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearerAuth" = [])
),
tag = "status"
)]
pub async fn handle_plugins_status(
State(state): State<AppState>,
) -> Result<Json<crate::modes::server::common::ApiResponse<PluginsStatusResponse>>, StatusCode> {
let db_path = state
.db
.lock()
.await
.path()
.unwrap_or("unknown")
.to_string();
let status_service = crate::services::status_service::StatusService::new();
let mut cmd = state.cmd.lock().await;
let status_info = status_service.generate_status(
&mut cmd,
&state.settings,
state.data_dir.clone(),
db_path.into(),
);
let response_data = PluginsStatusResponse {
meta_plugins: status_info.meta_plugins,
filter_plugins: status_info.filter_plugins,
compression: status_info.compression,
};
let response = crate::modes::server::common::ApiResponse::<PluginsStatusResponse> {
success: true,
data: Some(response_data),
error: None,
};
Ok(Json(response))
}

View File

@@ -567,6 +567,21 @@ fn default_as_meta() -> bool {
false
}
/// Request body for creating a new item.
///
/// Contains the content to store and optional tags.
#[derive(Debug, Deserialize, Serialize, ToSchema)]
pub struct CreateItemRequest {
/// The content to store.
#[schema(example = "Hello, world!")]
pub content: String,
/// Optional tags to associate with the item.
#[schema(example = json!(["important", "work"]))]
pub tags: Option<Vec<String>>,
/// Optional metadata key-value pairs.
pub metadata: Option<std::collections::HashMap<String, String>>,
}
/// Validates bearer authentication token.
///
/// This function checks if the provided authorization string is a valid Bearer token