contextlite_client/
client.rs1use crate::error::{handle_response_error, ContextLiteError, Result};
7use crate::types::{
8 ClientConfig, ContextRequest, ContextResponse, Document, CompleteHealthStatus, SearchQuery,
9 SearchResponse, StorageInfo,
10};
11use reqwest::{Client as HttpClient, RequestBuilder};
12use serde_json::Value;
13use std::time::Duration;
14use tokio::time::timeout;
15use url::Url;
16
17#[derive(Debug, Clone)]
19pub struct ContextLiteClient {
20 config: ClientConfig,
21 http_client: HttpClient,
22 base_url: Url,
23}
24
25impl ContextLiteClient {
26 pub fn new(base_url: impl Into<String>) -> Result<Self> {
28 Self::with_config(ClientConfig::new(base_url))
29 }
30
31 pub fn with_config(config: ClientConfig) -> Result<Self> {
33 let base_url = Url::parse(&config.base_url)
35 .map_err(|e| ContextLiteError::config(format!("Invalid base URL: {}", e)))?;
36
37 let http_client = HttpClient::builder()
39 .timeout(Duration::from_secs(config.timeout_seconds))
40 .pool_idle_timeout(Duration::from_secs(30))
41 .pool_max_idle_per_host(10) .user_agent(&config.user_agent)
43 .build()
44 .map_err(|e| ContextLiteError::config(format!("Failed to create HTTP client: {}", e)))?;
45
46 Ok(Self {
47 config,
48 http_client,
49 base_url,
50 })
51 }
52
53 fn build_request(&self, method: reqwest::Method, path: &str) -> Result<RequestBuilder> {
55 let url = self.base_url.join(path)
56 .map_err(|e| ContextLiteError::config(format!("Invalid URL path '{}': {}", path, e)))?;
57
58 let mut request = self.http_client.request(method, url);
59
60 if let Some(ref token) = self.config.auth_token {
62 request = request.header("Authorization", format!("Bearer {}", token));
63 }
64
65 request = request
67 .header("Content-Type", "application/json")
68 .header("Accept", "application/json")
69 .header("User-Agent", format!("contextlite-rust/{}", env!("CARGO_PKG_VERSION")));
70
71 Ok(request)
72 }
73
74 async fn execute_request(&self, request: RequestBuilder) -> Result<reqwest::Response> {
76 let response = timeout(
77 Duration::from_secs(self.config.timeout_seconds),
78 request.send(),
79 )
80 .await
81 .map_err(|_| ContextLiteError::timeout(self.config.timeout_seconds))?
82 .map_err(ContextLiteError::HttpError)?;
83
84 handle_response_error(response).await
85 }
86
87 pub async fn health(&self) -> Result<CompleteHealthStatus> {
89 let request = self.build_request(reqwest::Method::GET, "/health")?;
90 let response = self.execute_request(request).await?;
91
92 let health: CompleteHealthStatus = response.json().await?;
93 Ok(health)
94 }
95
96 pub async fn storage_info(&self) -> Result<StorageInfo> {
98 let request = self.build_request(reqwest::Method::GET, "/storage/info")?;
99 let response = self.execute_request(request).await?;
100
101 let info: StorageInfo = response.json().await?;
102 Ok(info)
103 }
104
105 pub async fn add_document(&self, document: &Document) -> Result<String> {
107 if document.path.is_empty() {
109 return Err(ContextLiteError::validation("Document path cannot be empty"));
110 }
111
112 if document.content.is_empty() {
113 return Err(ContextLiteError::validation("Document content cannot be empty"));
114 }
115
116 let request = self.build_request(reqwest::Method::POST, "/api/v1/documents")?
117 .json(document);
118
119 let response = self.execute_request(request).await?;
120
121 let result: Value = response.json().await?;
123
124 result
125 .get("id")
126 .and_then(|id| id.as_str())
127 .map(|id| id.to_string())
128 .ok_or_else(|| ContextLiteError::response("Missing document ID in response"))
129 }
130
131 pub async fn add_documents(&self, documents: &[Document]) -> Result<Vec<String>> {
133 if documents.is_empty() {
134 return Ok(Vec::new());
135 }
136
137 for (i, doc) in documents.iter().enumerate() {
139 if doc.path.is_empty() {
140 return Err(ContextLiteError::validation(
141 format!("Document at index {} has empty path", i)
142 ));
143 }
144 if doc.content.is_empty() {
145 return Err(ContextLiteError::validation(
146 format!("Document at index {} has empty content", i)
147 ));
148 }
149 }
150
151 let request = self.build_request(reqwest::Method::POST, "/api/v1/documents/bulk")?
152 .json(documents);
153
154 let response = self.execute_request(request).await?;
155
156 let result: Value = response.json().await?;
158
159 result
160 .get("ids")
161 .and_then(|ids| ids.as_array())
162 .ok_or_else(|| ContextLiteError::response("Missing document IDs in response"))?
163 .iter()
164 .map(|id| {
165 id.as_str()
166 .map(|s| s.to_string())
167 .ok_or_else(|| ContextLiteError::response("Invalid document ID format"))
168 })
169 .collect()
170 }
171
172 pub async fn get_document(&self, id: &str) -> Result<Document> {
174 if id.is_empty() {
175 return Err(ContextLiteError::validation("Document ID cannot be empty"));
176 }
177
178 let path = format!("/documents/{}", urlencoding::encode(id));
179 let request = self.build_request(reqwest::Method::GET, &path)?;
180
181 let response = self.execute_request(request).await?;
182
183 let document: Document = response.json().await?;
184 Ok(document)
185 }
186
187 pub async fn update_document(&self, id: &str, document: &Document) -> Result<()> {
189 if id.is_empty() {
190 return Err(ContextLiteError::validation("Document ID cannot be empty"));
191 }
192
193 if document.path.is_empty() {
194 return Err(ContextLiteError::validation("Document path cannot be empty"));
195 }
196
197 if document.content.is_empty() {
198 return Err(ContextLiteError::validation("Document content cannot be empty"));
199 }
200
201 let path = format!("/documents/{}", urlencoding::encode(id));
202 let request = self.build_request(reqwest::Method::PUT, &path)?
203 .json(document);
204
205 self.execute_request(request).await?;
206 Ok(())
207 }
208
209 pub async fn delete_document(&self, id: &str) -> Result<()> {
211 if id.is_empty() {
212 return Err(ContextLiteError::validation("Document ID cannot be empty"));
213 }
214
215 let path = format!("/api/v1/documents/{}", urlencoding::encode(id));
216 let request = self.build_request(reqwest::Method::DELETE, &path)?;
217
218 self.execute_request(request).await?;
219 Ok(())
220 }
221
222 pub async fn search(&self, query: &SearchQuery) -> Result<SearchResponse> {
224 if query.q.is_empty() {
225 return Err(ContextLiteError::validation("Search query cannot be empty"));
226 }
227
228 let mut params = vec![("q", query.q.as_str())];
229 let limit_str;
230 let offset_str;
231
232 if let Some(limit) = query.limit {
233 limit_str = limit.to_string();
234 params.push(("limit", &limit_str));
235 }
236 if let Some(offset) = query.offset {
237 offset_str = offset.to_string();
238 params.push(("offset", &offset_str));
239 }
240
241 let request = self.build_request(reqwest::Method::GET, "/api/v1/documents/search")?
242 .query(¶ms);
243
244 let response = self.execute_request(request).await?;
245
246 let search_response: SearchResponse = response.json().await?;
247 Ok(search_response)
248 }
249
250 pub async fn assemble_context(&self, request: &ContextRequest) -> Result<ContextResponse> {
252 if request.q.is_empty() {
253 return Err(ContextLiteError::validation("Context query cannot be empty"));
254 }
255
256 let http_request = self.build_request(reqwest::Method::POST, "/api/v1/context/assemble")?
257 .json(request);
258
259 let response = self.execute_request(http_request).await?;
260
261 let context_response: ContextResponse = response.json().await?;
262 Ok(context_response)
263 }
264
265 pub async fn clear_documents(&self) -> Result<()> {
267 let request = self.build_request(reqwest::Method::DELETE, "/documents")?;
268 self.execute_request(request).await?;
269 Ok(())
270 }
271
272 pub fn config(&self) -> &ClientConfig {
274 &self.config
275 }
276
277 pub fn has_auth(&self) -> bool {
279 self.config.auth_token.is_some()
280 }
281}
282
283impl Default for ContextLiteClient {
284 fn default() -> Self {
285 Self::new("http://127.0.0.1:8082").expect("Failed to create default ContextLite client")
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::types::{Document, SearchQuery, ContextRequest};
293
294 #[test]
295 fn test_client_creation() {
296 let client = ContextLiteClient::new("http://127.0.0.1:8082");
297 assert!(client.is_ok());
298
299 let client = client.unwrap();
300 assert_eq!(client.config().base_url, "http://127.0.0.1:8082");
301 assert!(!client.has_auth());
302 }
303
304 #[test]
305 fn test_client_with_config() {
306 let config = ClientConfig::new("http://localhost:8083")
307 .with_auth_token("test-token")
308 .with_timeout(60);
309
310 let client = ContextLiteClient::with_config(config);
311 assert!(client.is_ok());
312
313 let client = client.unwrap();
314 assert_eq!(client.config().base_url, "http://localhost:8083");
315 assert!(client.has_auth());
316 assert_eq!(client.config().timeout_seconds, 60);
317 }
318
319 #[test]
320 fn test_invalid_base_url() {
321 let config = ClientConfig::new("invalid-url");
322 let client = ContextLiteClient::with_config(config);
323 assert!(client.is_err());
324 assert!(matches!(client.unwrap_err(), ContextLiteError::ConfigError { .. }));
325 }
326
327 #[test]
328 fn test_document_validation() {
329 let client = ContextLiteClient::new("http://127.0.0.1:8082").unwrap();
330
331 let doc = Document::new("", "content");
333 let rt = tokio::runtime::Runtime::new().unwrap();
334 let result = rt.block_on(client.add_document(&doc));
335 assert!(result.is_err());
336 assert!(matches!(result.unwrap_err(), ContextLiteError::ValidationError { .. }));
337
338 let doc = Document::new("test.txt", "");
340 let result = rt.block_on(client.add_document(&doc));
341 assert!(result.is_err());
342 assert!(matches!(result.unwrap_err(), ContextLiteError::ValidationError { .. }));
343 }
344
345 #[test]
346 fn test_search_validation() {
347 let client = ContextLiteClient::new("http://127.0.0.1:8082").unwrap();
348
349 let query = SearchQuery::new("");
351 let rt = tokio::runtime::Runtime::new().unwrap();
352 let result = rt.block_on(client.search(&query));
353 assert!(result.is_err());
354 assert!(matches!(result.unwrap_err(), ContextLiteError::ValidationError { .. }));
355 }
356
357 #[test]
358 fn test_context_validation() {
359 let client = ContextLiteClient::new("http://127.0.0.1:8082").unwrap();
360
361 let request = ContextRequest::new("");
363 let rt = tokio::runtime::Runtime::new().unwrap();
364 let result = rt.block_on(client.assemble_context(&request));
365 assert!(result.is_err());
366 assert!(matches!(result.unwrap_err(), ContextLiteError::ValidationError { .. }));
367 }
368}