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