contextlite_client/
client.rs

1//! ContextLite HTTP client implementation.
2//!
3//! This module provides a high-performance async HTTP client for ContextLite,
4//! with connection pooling, retry logic, and comprehensive error handling.
5
6use 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/// The main ContextLite client
18#[derive(Debug, Clone)]
19pub struct ContextLiteClient {
20    config: ClientConfig,
21    http_client: HttpClient,
22    base_url: Url,
23}
24
25impl ContextLiteClient {
26    /// Create a new ContextLite client with default configuration
27    pub fn new(base_url: impl Into<String>) -> Result<Self> {
28        Self::with_config(ClientConfig::new(base_url))
29    }
30    
31    /// Create a new ContextLite client with custom configuration
32    pub fn with_config(config: ClientConfig) -> Result<Self> {
33        // Validate base URL
34        let base_url = Url::parse(&config.base_url)
35            .map_err(|e| ContextLiteError::config(format!("Invalid base URL: {}", e)))?;
36        
37        // Build HTTP client with connection pooling and timeouts
38        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) // Default connection pool size
42            .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    /// Build a request with common headers and authentication
54    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        // Add authentication header if configured
61        if let Some(ref token) = self.config.auth_token {
62            request = request.header("Authorization", format!("Bearer {}", token));
63        }
64        
65        // Add common headers
66        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    /// Execute a request with timeout and error handling
75    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    /// Check server health status
88    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    /// Get storage information
97    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    /// Add a document to the index
106    pub async fn add_document(&self, document: &Document) -> Result<String> {
107        // Validate document
108        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        // Parse response to get document ID
122        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    /// Add multiple documents in batch
132    pub async fn add_documents(&self, documents: &[Document]) -> Result<Vec<String>> {
133        if documents.is_empty() {
134            return Ok(Vec::new());
135        }
136        
137        // Validate all documents
138        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        // Parse response to get document IDs
157        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    /// Get a document by ID
173    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    /// Update a document
188    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    /// Delete a document by ID
210    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    /// Search for documents
223    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(&params);
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    /// Assemble context from documents
251    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    /// Clear all documents from the index
266    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    /// Get the current client configuration
273    pub fn config(&self) -> &ClientConfig {
274        &self.config
275    }
276    
277    /// Check if the client is configured with authentication
278    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        // Test empty path validation
332        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        // Test empty content validation
339        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        // Test empty query validation
350        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        // Test empty query validation
362        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}