use crate::services::error::CoreError; use base64::Engine; use serde::de::DeserializeOwned; use std::collections::HashMap; use std::io::Read; /// Item information returned from the server API. #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct ItemInfo { pub id: i64, pub ts: String, pub size: Option, pub compression: String, pub tags: Vec, pub metadata: HashMap, } /// Percent-encode a value for use in a URL query string. fn url_encode(s: &str) -> String { let mut result = String::with_capacity(s.len() * 3); for byte in s.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { result.push(byte as char); } _ => { result.push('%'); result.push(char::from_digit((byte >> 4) as u32, 16).unwrap()); result.push(char::from_digit((byte & 0xF) as u32, 16).unwrap()); } } } result } pub struct KeepClient { base_url: String, agent: ureq::Agent, username: Option, password: Option, jwt: Option, } impl KeepClient { pub fn new( base_url: &str, username: Option, password: Option, jwt: Option, ) -> Result { let base_url = base_url.trim_end_matches('/').to_string(); let agent = ureq::Agent::new_with_defaults(); Ok(Self { base_url, agent, username, password, jwt, }) } pub fn base_url(&self) -> &str { &self.base_url } pub fn username(&self) -> Option<&String> { self.username.as_ref() } pub fn password(&self) -> Option<&String> { self.password.as_ref() } pub fn jwt(&self) -> Option<&String> { self.jwt.as_ref() } fn url(&self, path: &str) -> String { format!("{}{}", self.base_url, path) } /// Get the Authorization header value for the current credentials. /// /// JWT token is sent as `Bearer `. /// Password is sent as `Basic base64(username:password)` /// where username defaults to "keep". fn auth_header(&self) -> Option { if let Some(ref jwt) = self.jwt { Some(format!("Bearer {jwt}")) } else if let Some(ref password) = self.password { let username = self.username.as_deref().unwrap_or("keep"); let credentials = format!("{username}:{password}"); let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials); Some(format!("Basic {encoded}")) } else { None } } fn handle_error(&self, result: Result) -> Result { match result { Ok(v) => Ok(v), Err(ureq::Error::StatusCode(code)) => Err(CoreError::Other(anyhow::anyhow!( "Server returned error: HTTP {}", code ))), Err(e) => Err(CoreError::Other(anyhow::anyhow!("Request failed: {}", e))), } } pub fn get_json(&self, path: &str) -> Result { let url = self.url(path); let mut req = self.agent.get(&url); if let Some(ref auth) = self.auth_header() { req = req.header("Authorization", auth); } let response = self.handle_error(req.call())?; let body: T = self.handle_error(response.into_body().read_json())?; Ok(body) } pub fn get_json_with_query( &self, path: &str, params: &[(&str, &str)], ) -> Result { let mut url = self.url(path); if !params.is_empty() { url.push('?'); for (i, (key, value)) in params.iter().enumerate() { if i > 0 { url.push('&'); } url.push_str(&format!("{}={}", url_encode(key), url_encode(value))); } } let mut req = self.agent.get(&url); if let Some(ref auth) = self.auth_header() { req = req.header("Authorization", auth); } let response = self.handle_error(req.call())?; let body: T = self.handle_error(response.into_body().read_json())?; Ok(body) } pub fn get_bytes(&self, path: &str) -> Result, CoreError> { let url = self.url(path); let mut req = self.agent.get(&url); if let Some(ref auth) = self.auth_header() { req = req.header("Authorization", auth); } let response = self.handle_error(req.call())?; let mut body = response.into_body(); let bytes = body .read_to_vec() .map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?; Ok(bytes) } pub fn post_bytes( &self, path: &str, body_bytes: &[u8], params: &[(&str, &str)], ) -> Result { let mut cursor = std::io::Cursor::new(body_bytes); self.post_stream(path, &mut cursor, params) } /// Stream data from a reader to the server using chunked transfer encoding. /// /// The reader is consumed in chunks and sent to the server without buffering /// the entire body in memory. This enables true streaming for large payloads. pub fn post_stream( &self, path: &str, body_reader: &mut dyn Read, params: &[(&str, &str)], ) -> Result { let mut url = self.url(path); if !params.is_empty() { url.push('?'); for (i, (key, value)) in params.iter().enumerate() { if i > 0 { url.push('&'); } url.push_str(&format!("{}={}", url_encode(key), url_encode(value))); } } let mut req = self.agent.post(&url); if let Some(ref auth) = self.auth_header() { req = req.header("Authorization", auth); } req = req.header("Content-Type", "application/octet-stream"); let response = self.handle_error(req.send(ureq::SendBody::from_reader(body_reader)))?; #[derive(serde::Deserialize)] struct ApiResponse { data: Option, error: Option, } let api_response: ApiResponse = self.handle_error(response.into_body().read_json())?; if let Some(error) = api_response.error { return Err(CoreError::Other(anyhow::anyhow!("Server error: {}", error))); } api_response .data .ok_or_else(|| CoreError::Other(anyhow::anyhow!("No data in response"))) } pub fn delete(&self, path: &str) -> Result<(), CoreError> { let url = self.url(path); let mut req = self.agent.delete(&url); if let Some(ref auth) = self.auth_header() { req = req.header("Authorization", auth); } self.handle_error(req.call())?; Ok(()) } pub fn get_status(&self) -> Result { self.get_json("/api/status") } pub fn get_item_info(&self, id: i64) -> Result { #[derive(serde::Deserialize)] struct ApiResponse { data: Option, } let response: ApiResponse = self.get_json(&format!("/api/item/{id}/info"))?; response .data .ok_or_else(|| CoreError::Other(anyhow::anyhow!("Item not found"))) } pub fn list_items( &self, tags: &[String], order: &str, start: u64, count: u64, meta: &HashMap>, ) -> Result, CoreError> { #[derive(serde::Deserialize)] struct ApiResponse { data: Option>, } let mut params: Vec<(String, String)> = Vec::new(); params.push(("order".to_string(), order.to_string())); params.push(("start".to_string(), start.to_string())); params.push(("count".to_string(), count.to_string())); if !tags.is_empty() { params.push(("tags".to_string(), tags.join(","))); } if !meta.is_empty() { let meta_json = serde_json::to_string(meta).map_err(|e| { CoreError::Other(anyhow::anyhow!("Failed to serialize meta filter: {}", e)) })?; params.push(("meta".to_string(), meta_json)); } let param_refs: Vec<(&str, &str)> = params .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); let response: ApiResponse = self.get_json_with_query("/api/item/", ¶m_refs)?; Ok(response.data.unwrap_or_default()) } pub fn save_item( &self, content: &[u8], tags: &[String], metadata: &HashMap, compress: bool, meta: bool, ) -> Result { let mut params: Vec<(String, String)> = Vec::new(); if !tags.is_empty() { params.push(("tags".to_string(), tags.join(","))); } if !metadata.is_empty() { let meta_json = serde_json::to_string(metadata).map_err(|e| { CoreError::Other(anyhow::anyhow!("Failed to serialize metadata: {}", e)) })?; params.push(("metadata".to_string(), meta_json)); } params.push(("compress".to_string(), compress.to_string())); params.push(("meta".to_string(), meta.to_string())); let param_refs: Vec<(&str, &str)> = params .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); self.post_bytes("/api/item/", content, ¶m_refs) } pub fn delete_item(&self, id: i64) -> Result<(), CoreError> { self.delete(&format!("/api/item/{id}")) } /// Add metadata to an existing item. pub fn post_metadata( &self, id: i64, metadata: &HashMap, ) -> Result<(), CoreError> { let url = self.url(&format!("/api/item/{id}/meta")); let mut req = self.agent.post(&url); if let Some(ref auth) = self.auth_header() { req = req.header("Authorization", auth); } req = req.header("Content-Type", "application/json"); let body = serde_json::to_vec(metadata) .map_err(|e| CoreError::Other(anyhow::anyhow!("Failed to serialize metadata: {e}")))?; let mut cursor = std::io::Cursor::new(body); self.handle_error(req.send(ureq::SendBody::from_reader(&mut cursor)))?; Ok(()) } pub fn get_item_content_raw(&self, id: i64) -> Result<(Vec, String), CoreError> { let url = format!( "{}?decompress=false", self.url(&format!("/api/item/{id}/content")) ); let mut req = self.agent.get(&url); if let Some(ref auth) = self.auth_header() { req = req.header("Authorization", auth); } let response = self.handle_error(req.call())?; let compression = response .headers() .get("X-Keep-Compression") .and_then(|v| v.to_str().ok()) .unwrap_or("none") .to_string(); let mut body = response.into_body(); let bytes = body .read_to_vec() .map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?; Ok((bytes, compression)) } pub fn diff_items(&self, id_a: i64, id_b: i64) -> Result, CoreError> { #[derive(serde::Deserialize)] struct ApiResponse { data: Option>, } let params = [("id_a", id_a.to_string()), ("id_b", id_b.to_string())]; let param_refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); let response: ApiResponse = self.get_json_with_query("/api/diff", ¶m_refs)?; Ok(response.data.unwrap_or_default()) } }