- Move ItemInfo to services/types.rs for sharing between client and server - Replace .expect() in compression_service with proper error handling - Add CoreError::PayloadTooLarge variant for semantic error handling - Export CoreError from lib.rs for library users - Unify get_item_meta_name/value to take &str instead of String - Extract item_path() helper in ItemService to reduce duplication - Add warning logs for silent errors in list.rs - Fix pre-existing borrow errors: tx moved in export handler, item_with_meta partial move in TryFrom implementation - Fix unused data_dir variables in server code
515 lines
17 KiB
Rust
515 lines
17 KiB
Rust
use crate::services::{ItemInfo, error::CoreError};
|
|
use base64::Engine;
|
|
use serde::de::DeserializeOwned;
|
|
use std::collections::HashMap;
|
|
use std::io::Read;
|
|
|
|
/// 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
|
|
}
|
|
|
|
fn append_query_params(url: &mut String, params: &[(&str, &str)]) {
|
|
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)));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct KeepClient {
|
|
base_url: String,
|
|
agent: ureq::Agent,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
jwt: Option<String>,
|
|
}
|
|
|
|
impl KeepClient {
|
|
pub fn new(
|
|
base_url: &str,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
jwt: Option<String>,
|
|
) -> Result<Self, CoreError> {
|
|
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 <token>`.
|
|
/// Password is sent as `Basic base64(username:password)`
|
|
/// where username defaults to "keep".
|
|
fn auth_header(&self) -> Option<String> {
|
|
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<T>(&self, result: Result<T, ureq::Error>) -> Result<T, CoreError> {
|
|
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<T: DeserializeOwned>(&self, path: &str) -> Result<T, 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 body: T = self.handle_error(response.into_body().read_json())?;
|
|
Ok(body)
|
|
}
|
|
|
|
pub fn get_json_with_query<T: DeserializeOwned>(
|
|
&self,
|
|
path: &str,
|
|
params: &[(&str, &str)],
|
|
) -> Result<T, CoreError> {
|
|
let mut url = self.url(path);
|
|
append_query_params(&mut url, params);
|
|
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<Vec<u8>, 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<ItemInfo, CoreError> {
|
|
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<ItemInfo, CoreError> {
|
|
let mut url = self.url(path);
|
|
append_query_params(&mut url, params);
|
|
|
|
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<ItemInfo>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
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<crate::common::status::StatusInfo, CoreError> {
|
|
#[derive(serde::Deserialize)]
|
|
struct ApiResponse {
|
|
data: Option<crate::common::status::StatusInfo>,
|
|
error: Option<String>,
|
|
}
|
|
let response: ApiResponse = self.get_json("/api/status")?;
|
|
response.data.ok_or_else(|| {
|
|
CoreError::Other(anyhow::anyhow!(
|
|
"{}",
|
|
response
|
|
.error
|
|
.unwrap_or_else(|| "No status data returned".to_string())
|
|
))
|
|
})
|
|
}
|
|
|
|
pub fn get_item_info(&self, id: i64) -> Result<ItemInfo, CoreError> {
|
|
#[derive(serde::Deserialize)]
|
|
struct ApiResponse {
|
|
data: Option<ItemInfo>,
|
|
error: Option<String>,
|
|
}
|
|
let response: ApiResponse = self.get_json(&format!("/api/item/{id}/info"))?;
|
|
response.data.ok_or_else(|| {
|
|
CoreError::Other(anyhow::anyhow!(
|
|
"{}",
|
|
response
|
|
.error
|
|
.unwrap_or_else(|| "Item not found".to_string())
|
|
))
|
|
})
|
|
}
|
|
|
|
pub fn list_items(
|
|
&self,
|
|
ids: &[i64],
|
|
tags: &[String],
|
|
order: &str,
|
|
start: u64,
|
|
count: u64,
|
|
meta: &HashMap<String, Option<String>>,
|
|
) -> Result<Vec<ItemInfo>, CoreError> {
|
|
#[derive(serde::Deserialize)]
|
|
struct ApiResponse {
|
|
data: Option<Vec<ItemInfo>>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
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 !ids.is_empty() {
|
|
params.push((
|
|
"ids".to_string(),
|
|
ids.iter()
|
|
.map(|i| i.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
));
|
|
}
|
|
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)?;
|
|
if let Some(data) = response.data {
|
|
return Ok(data);
|
|
}
|
|
if let Some(err) = response.error {
|
|
return Err(CoreError::Other(anyhow::anyhow!("Server error: {err}")));
|
|
}
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
pub fn save_item(
|
|
&self,
|
|
content: &[u8],
|
|
tags: &[String],
|
|
metadata: &HashMap<String, String>,
|
|
compress: bool,
|
|
meta: bool,
|
|
) -> Result<ItemInfo, CoreError> {
|
|
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<String, String>,
|
|
) -> 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(())
|
|
}
|
|
|
|
/// Set the uncompressed size for an item.
|
|
pub fn set_item_size(&self, id: i64, size: u64) -> Result<(), CoreError> {
|
|
let url = format!(
|
|
"{}?uncompressed_size={}",
|
|
self.url(&format!("/api/item/{id}/update")),
|
|
url_encode(&size.to_string())
|
|
);
|
|
let mut req = self.agent.post(&url);
|
|
if let Some(ref auth) = self.auth_header() {
|
|
req = req.header("Authorization", auth);
|
|
}
|
|
self.handle_error(req.send(ureq::SendBody::from_reader(&mut std::io::empty())))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_item_content_raw(&self, id: i64) -> Result<(Vec<u8>, String), CoreError> {
|
|
let (mut reader, compression) = self.get_item_content_stream(id)?;
|
|
let mut bytes = Vec::new();
|
|
reader
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?;
|
|
Ok((bytes, compression))
|
|
}
|
|
|
|
/// Get a streaming reader for item content without decompression.
|
|
///
|
|
/// Returns a reader over the HTTP response body and the compression type
|
|
/// from the X-Keep-Compression header. The caller can stream through
|
|
/// decompression readers without buffering the entire file in memory.
|
|
pub fn get_item_content_stream(&self, id: i64) -> Result<(Box<dyn Read>, 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("raw")
|
|
.to_string();
|
|
|
|
let reader = response.into_body().into_reader();
|
|
Ok((Box::new(reader), compression))
|
|
}
|
|
|
|
pub fn diff_items(&self, id_a: i64, id_b: i64) -> Result<Vec<String>, CoreError> {
|
|
#[derive(serde::Deserialize)]
|
|
struct ApiResponse {
|
|
data: Option<Vec<String>>,
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
/// Export items to a tar archive, streaming the response to a file.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `ids` - Item IDs to export (mutually exclusive with tags).
|
|
/// * `tags` - Tags to search for items (mutually exclusive with ids).
|
|
/// * `dest` - Destination file path.
|
|
pub fn export_items_to_file(
|
|
&self,
|
|
ids: &[i64],
|
|
tags: &[String],
|
|
dest: &std::path::Path,
|
|
) -> Result<(), CoreError> {
|
|
let mut params: Vec<(String, String)> = Vec::new();
|
|
if !ids.is_empty() {
|
|
let id_strs: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
|
|
params.push(("ids".to_string(), id_strs.join(",")));
|
|
}
|
|
if !tags.is_empty() {
|
|
params.push(("tags".to_string(), tags.join(",")));
|
|
}
|
|
let param_refs: Vec<(&str, &str)> = params
|
|
.iter()
|
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
|
.collect();
|
|
|
|
let mut url = self.url("/api/export");
|
|
append_query_params(&mut url, ¶m_refs);
|
|
|
|
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 reader = response.into_body().into_reader();
|
|
|
|
let mut file = std::fs::File::create(dest).map_err(CoreError::Io)?;
|
|
let mut buf = [0u8; crate::common::PIPESIZE];
|
|
loop {
|
|
let n = reader.read(&mut buf).map_err(CoreError::Io)?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
std::io::Write::write_all(&mut file, &buf[..n]).map_err(CoreError::Io)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Import items from a tar archive, streaming the file to the server.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `tar_path` - Path to the `.keep.tar` file.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A list of newly assigned item IDs.
|
|
pub fn import_tar_file(&self, tar_path: &std::path::Path) -> Result<Vec<i64>, CoreError> {
|
|
#[derive(serde::Deserialize)]
|
|
struct ApiResponse {
|
|
data: Option<ImportResponse>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct ImportResponse {
|
|
ids: Vec<i64>,
|
|
}
|
|
|
|
let mut file = std::fs::File::open(tar_path).map_err(CoreError::Io)?;
|
|
|
|
let url = self.url("/api/import");
|
|
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/x-tar");
|
|
|
|
let response = self.handle_error(req.send(ureq::SendBody::from_reader(&mut file)))?;
|
|
|
|
let body = response
|
|
.into_body()
|
|
.read_to_string()
|
|
.map_err(|e| CoreError::InvalidInput(format!("Cannot read response: {e}")))?;
|
|
|
|
let api_response: ApiResponse = serde_json::from_str(&body)
|
|
.map_err(|e| CoreError::InvalidInput(format!("Cannot parse response: {e}")))?;
|
|
|
|
if let Some(error) = api_response.error {
|
|
return Err(CoreError::InvalidInput(error));
|
|
}
|
|
|
|
Ok(api_response.data.map(|d| d.ids).unwrap_or_default())
|
|
}
|
|
}
|