dakera_client/client.rs
1//! Dakera client implementation
2
3use reqwest::{Client, StatusCode};
4use std::time::Duration;
5use tracing::{debug, instrument};
6
7use crate::error::{ClientError, Result};
8use crate::types::*;
9
10/// Default timeout for requests
11const DEFAULT_TIMEOUT_SECS: u64 = 30;
12
13/// Dakeraclient for interacting with the vector database
14#[derive(Debug, Clone)]
15pub struct DakeraClient {
16 /// HTTP client
17 pub(crate) client: Client,
18 /// Base URL of the Dakera server
19 pub(crate) base_url: String,
20}
21
22impl DakeraClient {
23 /// Create a new client with the given base URL
24 ///
25 /// # Example
26 ///
27 /// ```rust,no_run
28 /// use dakera_client::DakeraClient;
29 ///
30 /// let client = DakeraClient::new("http://localhost:3000").unwrap();
31 /// ```
32 pub fn new(base_url: impl Into<String>) -> Result<Self> {
33 DakeraClientBuilder::new(base_url).build()
34 }
35
36 /// Create a new client builder for more configuration options
37 pub fn builder(base_url: impl Into<String>) -> DakeraClientBuilder {
38 DakeraClientBuilder::new(base_url)
39 }
40
41 // ========================================================================
42 // Health & Status
43 // ========================================================================
44
45 /// Check server health
46 #[instrument(skip(self))]
47 pub async fn health(&self) -> Result<HealthResponse> {
48 let url = format!("{}/health", self.base_url);
49 let response = self.client.get(&url).send().await?;
50
51 if response.status().is_success() {
52 Ok(response.json().await?)
53 } else {
54 // Health endpoint might return simple OK
55 Ok(HealthResponse {
56 healthy: true,
57 version: None,
58 uptime_seconds: None,
59 })
60 }
61 }
62
63 /// Check if server is ready
64 #[instrument(skip(self))]
65 pub async fn ready(&self) -> Result<ReadinessResponse> {
66 let url = format!("{}/health/ready", self.base_url);
67 let response = self.client.get(&url).send().await?;
68
69 if response.status().is_success() {
70 Ok(response.json().await?)
71 } else {
72 Ok(ReadinessResponse {
73 ready: false,
74 components: None,
75 })
76 }
77 }
78
79 /// Check if server is live
80 #[instrument(skip(self))]
81 pub async fn live(&self) -> Result<bool> {
82 let url = format!("{}/health/live", self.base_url);
83 let response = self.client.get(&url).send().await?;
84 Ok(response.status().is_success())
85 }
86
87 // ========================================================================
88 // Namespace Operations
89 // ========================================================================
90
91 /// List all namespaces
92 #[instrument(skip(self))]
93 pub async fn list_namespaces(&self) -> Result<Vec<String>> {
94 let url = format!("{}/v1/namespaces", self.base_url);
95 let response = self.client.get(&url).send().await?;
96 self.handle_response::<ListNamespacesResponse>(response)
97 .await
98 .map(|r| r.namespaces)
99 }
100
101 /// Get namespace information
102 #[instrument(skip(self))]
103 pub async fn get_namespace(&self, namespace: &str) -> Result<NamespaceInfo> {
104 let url = format!("{}/v1/namespaces/{}", self.base_url, namespace);
105 let response = self.client.get(&url).send().await?;
106 self.handle_response(response).await
107 }
108
109 // ========================================================================
110 // Vector Operations
111 // ========================================================================
112
113 /// Upsert vectors into a namespace
114 #[instrument(skip(self, request), fields(vector_count = request.vectors.len()))]
115 pub async fn upsert(&self, namespace: &str, request: UpsertRequest) -> Result<UpsertResponse> {
116 let url = format!("{}/v1/namespaces/{}/vectors", self.base_url, namespace);
117 debug!(
118 "Upserting {} vectors to {}",
119 request.vectors.len(),
120 namespace
121 );
122
123 let response = self.client.post(&url).json(&request).send().await?;
124 self.handle_response(response).await
125 }
126
127 /// Upsert a single vector (convenience method)
128 #[instrument(skip(self, vector))]
129 pub async fn upsert_one(&self, namespace: &str, vector: Vector) -> Result<UpsertResponse> {
130 self.upsert(namespace, UpsertRequest::single(vector)).await
131 }
132
133 /// Upsert vectors in column format (Turbopuffer-inspired)
134 ///
135 /// This format is more efficient for bulk upserts as it avoids repeating
136 /// field names for each vector. All arrays must have equal length.
137 ///
138 /// # Example
139 ///
140 /// ```rust,no_run
141 /// use dakera_client::{DakeraClient, ColumnUpsertRequest};
142 ///
143 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
144 /// let client = DakeraClient::new("http://localhost:3000")?;
145 ///
146 /// let request = ColumnUpsertRequest::new(
147 /// vec!["id1".to_string(), "id2".to_string(), "id3".to_string()],
148 /// vec![
149 /// vec![0.1, 0.2, 0.3],
150 /// vec![0.4, 0.5, 0.6],
151 /// vec![0.7, 0.8, 0.9],
152 /// ],
153 /// )
154 /// .with_attribute("category", vec![
155 /// serde_json::json!("A"),
156 /// serde_json::json!("B"),
157 /// serde_json::json!("A"),
158 /// ]);
159 ///
160 /// let response = client.upsert_columns("my-namespace", request).await?;
161 /// println!("Upserted {} vectors", response.upserted_count);
162 /// # Ok(())
163 /// # }
164 /// ```
165 #[instrument(skip(self, request), fields(namespace = %namespace, count = request.ids.len()))]
166 pub async fn upsert_columns(
167 &self,
168 namespace: &str,
169 request: ColumnUpsertRequest,
170 ) -> Result<UpsertResponse> {
171 let url = format!(
172 "{}/v1/namespaces/{}/upsert-columns",
173 self.base_url, namespace
174 );
175 debug!(
176 "Upserting {} vectors in column format to {}",
177 request.ids.len(),
178 namespace
179 );
180
181 let response = self.client.post(&url).json(&request).send().await?;
182 self.handle_response(response).await
183 }
184
185 /// Query for similar vectors
186 #[instrument(skip(self, request), fields(top_k = request.top_k))]
187 pub async fn query(&self, namespace: &str, request: QueryRequest) -> Result<QueryResponse> {
188 let url = format!("{}/v1/namespaces/{}/query", self.base_url, namespace);
189 debug!(
190 "Querying namespace {} for top {} results",
191 namespace, request.top_k
192 );
193
194 let response = self.client.post(&url).json(&request).send().await?;
195 self.handle_response(response).await
196 }
197
198 /// Simple query with just a vector and top_k (convenience method)
199 #[instrument(skip(self, vector))]
200 pub async fn query_simple(
201 &self,
202 namespace: &str,
203 vector: Vec<f32>,
204 top_k: u32,
205 ) -> Result<QueryResponse> {
206 self.query(namespace, QueryRequest::new(vector, top_k))
207 .await
208 }
209
210 /// Execute multiple queries in a single request
211 ///
212 /// This allows executing multiple vector similarity queries in parallel,
213 /// which is more efficient than making separate requests.
214 ///
215 /// # Example
216 ///
217 /// ```rust,no_run
218 /// use dakera_client::{DakeraClient, BatchQueryRequest, BatchQueryItem};
219 ///
220 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
221 /// let client = DakeraClient::new("http://localhost:3000")?;
222 ///
223 /// let request = BatchQueryRequest::new(vec![
224 /// BatchQueryItem::new(vec![0.1, 0.2, 0.3], 5).with_id("query1"),
225 /// BatchQueryItem::new(vec![0.4, 0.5, 0.6], 10).with_id("query2"),
226 /// ]);
227 ///
228 /// let response = client.batch_query("my-namespace", request).await?;
229 /// println!("Executed {} queries in {}ms", response.query_count, response.total_latency_ms);
230 /// # Ok(())
231 /// # }
232 /// ```
233 #[instrument(skip(self, request), fields(namespace = %namespace, query_count = request.queries.len()))]
234 pub async fn batch_query(
235 &self,
236 namespace: &str,
237 request: BatchQueryRequest,
238 ) -> Result<BatchQueryResponse> {
239 let url = format!("{}/v1/namespaces/{}/batch-query", self.base_url, namespace);
240 debug!(
241 "Batch querying namespace {} with {} queries",
242 namespace,
243 request.queries.len()
244 );
245
246 let response = self.client.post(&url).json(&request).send().await?;
247 self.handle_response(response).await
248 }
249
250 /// Delete vectors by ID
251 #[instrument(skip(self, request), fields(id_count = request.ids.len()))]
252 pub async fn delete(&self, namespace: &str, request: DeleteRequest) -> Result<DeleteResponse> {
253 let url = format!(
254 "{}/v1/namespaces/{}/vectors/delete",
255 self.base_url, namespace
256 );
257 debug!("Deleting {} vectors from {}", request.ids.len(), namespace);
258
259 let response = self.client.post(&url).json(&request).send().await?;
260 self.handle_response(response).await
261 }
262
263 /// Delete a single vector by ID (convenience method)
264 #[instrument(skip(self))]
265 pub async fn delete_one(&self, namespace: &str, id: &str) -> Result<DeleteResponse> {
266 self.delete(namespace, DeleteRequest::single(id)).await
267 }
268
269 // ========================================================================
270 // Full-Text Search Operations
271 // ========================================================================
272
273 /// Index documents for full-text search
274 #[instrument(skip(self, request), fields(doc_count = request.documents.len()))]
275 pub async fn index_documents(
276 &self,
277 namespace: &str,
278 request: IndexDocumentsRequest,
279 ) -> Result<IndexDocumentsResponse> {
280 let url = format!(
281 "{}/v1/namespaces/{}/fulltext/index",
282 self.base_url, namespace
283 );
284 debug!(
285 "Indexing {} documents in {}",
286 request.documents.len(),
287 namespace
288 );
289
290 let response = self.client.post(&url).json(&request).send().await?;
291 self.handle_response(response).await
292 }
293
294 /// Index a single document (convenience method)
295 #[instrument(skip(self, document))]
296 pub async fn index_document(
297 &self,
298 namespace: &str,
299 document: Document,
300 ) -> Result<IndexDocumentsResponse> {
301 self.index_documents(
302 namespace,
303 IndexDocumentsRequest {
304 documents: vec![document],
305 },
306 )
307 .await
308 }
309
310 /// Perform full-text search
311 #[instrument(skip(self, request))]
312 pub async fn fulltext_search(
313 &self,
314 namespace: &str,
315 request: FullTextSearchRequest,
316 ) -> Result<FullTextSearchResponse> {
317 let url = format!(
318 "{}/v1/namespaces/{}/fulltext/search",
319 self.base_url, namespace
320 );
321 debug!("Full-text search in {} for: {}", namespace, request.query);
322
323 let response = self.client.post(&url).json(&request).send().await?;
324 self.handle_response(response).await
325 }
326
327 /// Simple full-text search (convenience method)
328 #[instrument(skip(self))]
329 pub async fn search_text(
330 &self,
331 namespace: &str,
332 query: &str,
333 top_k: u32,
334 ) -> Result<FullTextSearchResponse> {
335 self.fulltext_search(namespace, FullTextSearchRequest::new(query, top_k))
336 .await
337 }
338
339 /// Get full-text index statistics
340 #[instrument(skip(self))]
341 pub async fn fulltext_stats(&self, namespace: &str) -> Result<FullTextStats> {
342 let url = format!(
343 "{}/v1/namespaces/{}/fulltext/stats",
344 self.base_url, namespace
345 );
346 let response = self.client.get(&url).send().await?;
347 self.handle_response(response).await
348 }
349
350 /// Delete documents from full-text index
351 #[instrument(skip(self, request))]
352 pub async fn fulltext_delete(
353 &self,
354 namespace: &str,
355 request: DeleteRequest,
356 ) -> Result<DeleteResponse> {
357 let url = format!(
358 "{}/v1/namespaces/{}/fulltext/delete",
359 self.base_url, namespace
360 );
361 let response = self.client.post(&url).json(&request).send().await?;
362 self.handle_response(response).await
363 }
364
365 // ========================================================================
366 // Hybrid Search Operations
367 // ========================================================================
368
369 /// Perform hybrid search (vector + full-text)
370 #[instrument(skip(self, request), fields(top_k = request.top_k))]
371 pub async fn hybrid_search(
372 &self,
373 namespace: &str,
374 request: HybridSearchRequest,
375 ) -> Result<HybridSearchResponse> {
376 let url = format!("{}/v1/namespaces/{}/hybrid", self.base_url, namespace);
377 debug!(
378 "Hybrid search in {} with vector_weight={}",
379 namespace, request.vector_weight
380 );
381
382 let response = self.client.post(&url).json(&request).send().await?;
383 self.handle_response(response).await
384 }
385
386 // ========================================================================
387 // Multi-Vector Search Operations
388 // ========================================================================
389
390 /// Multi-vector search with positive/negative vectors and MMR
391 ///
392 /// This performs semantic search using multiple positive vectors (to search towards)
393 /// and optional negative vectors (to search away from). Supports MMR (Maximal Marginal
394 /// Relevance) for result diversity.
395 ///
396 /// # Example
397 ///
398 /// ```rust,no_run
399 /// use dakera_client::{DakeraClient, MultiVectorSearchRequest};
400 ///
401 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
402 /// let client = DakeraClient::new("http://localhost:3000")?;
403 ///
404 /// // Search towards multiple concepts, away from others
405 /// let request = MultiVectorSearchRequest::new(vec![
406 /// vec![0.1, 0.2, 0.3], // positive vector 1
407 /// vec![0.4, 0.5, 0.6], // positive vector 2
408 /// ])
409 /// .with_negative_vectors(vec![
410 /// vec![0.7, 0.8, 0.9], // negative vector
411 /// ])
412 /// .with_top_k(10)
413 /// .with_mmr(0.7); // Enable MMR with lambda=0.7
414 ///
415 /// let response = client.multi_vector_search("my-namespace", request).await?;
416 /// for result in response.results {
417 /// println!("ID: {}, Score: {}", result.id, result.score);
418 /// }
419 /// # Ok(())
420 /// # }
421 /// ```
422 #[instrument(skip(self, request), fields(namespace = %namespace))]
423 pub async fn multi_vector_search(
424 &self,
425 namespace: &str,
426 request: MultiVectorSearchRequest,
427 ) -> Result<MultiVectorSearchResponse> {
428 let url = format!("{}/v1/namespaces/{}/multi-vector", self.base_url, namespace);
429 debug!(
430 "Multi-vector search in {} with {} positive vectors",
431 namespace,
432 request.positive_vectors.len()
433 );
434
435 let response = self.client.post(&url).json(&request).send().await?;
436 self.handle_response(response).await
437 }
438
439 // ========================================================================
440 // Aggregation Operations
441 // ========================================================================
442
443 /// Aggregate vectors with grouping (Turbopuffer-inspired)
444 ///
445 /// This performs aggregation queries on vector metadata, supporting
446 /// count, sum, avg, min, and max operations with optional grouping.
447 ///
448 /// # Example
449 ///
450 /// ```rust,no_run
451 /// use dakera_client::{DakeraClient, AggregationRequest};
452 ///
453 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
454 /// let client = DakeraClient::new("http://localhost:3000")?;
455 ///
456 /// // Count all vectors and sum scores, grouped by category
457 /// let request = AggregationRequest::new()
458 /// .with_count("total_count")
459 /// .with_sum("total_score", "score")
460 /// .with_avg("avg_score", "score")
461 /// .with_group_by("category");
462 ///
463 /// let response = client.aggregate("my-namespace", request).await?;
464 /// if let Some(groups) = response.aggregation_groups {
465 /// for group in groups {
466 /// println!("Group: {:?}", group.group_key);
467 /// }
468 /// }
469 /// # Ok(())
470 /// # }
471 /// ```
472 #[instrument(skip(self, request), fields(namespace = %namespace))]
473 pub async fn aggregate(
474 &self,
475 namespace: &str,
476 request: AggregationRequest,
477 ) -> Result<AggregationResponse> {
478 let url = format!("{}/v1/namespaces/{}/aggregate", self.base_url, namespace);
479 debug!(
480 "Aggregating in namespace {} with {} aggregations",
481 namespace,
482 request.aggregate_by.len()
483 );
484
485 let response = self.client.post(&url).json(&request).send().await?;
486 self.handle_response(response).await
487 }
488
489 // ========================================================================
490 // Unified Query Operations
491 // ========================================================================
492
493 /// Unified query with flexible ranking options (Turbopuffer-inspired)
494 ///
495 /// This provides a unified API for vector search (ANN/kNN), full-text search (BM25),
496 /// and attribute ordering. Supports combining multiple ranking functions with
497 /// Sum, Max, and Product operators.
498 ///
499 /// # Example
500 ///
501 /// ```rust,no_run
502 /// use dakera_client::{DakeraClient, UnifiedQueryRequest, SortDirection};
503 ///
504 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
505 /// let client = DakeraClient::new("http://localhost:3000")?;
506 ///
507 /// // Vector ANN search
508 /// let request = UnifiedQueryRequest::vector_search(vec![0.1, 0.2, 0.3], 10);
509 /// let response = client.unified_query("my-namespace", request).await?;
510 ///
511 /// // Full-text BM25 search
512 /// let request = UnifiedQueryRequest::fulltext_search("content", "hello world", 10);
513 /// let response = client.unified_query("my-namespace", request).await?;
514 ///
515 /// // Attribute ordering with filter
516 /// let request = UnifiedQueryRequest::attribute_order("timestamp", SortDirection::Desc, 10)
517 /// .with_filter(serde_json::json!({"category": {"$eq": "science"}}));
518 /// let response = client.unified_query("my-namespace", request).await?;
519 ///
520 /// for result in response.results {
521 /// println!("ID: {}, Score: {:?}", result.id, result.dist);
522 /// }
523 /// # Ok(())
524 /// # }
525 /// ```
526 #[instrument(skip(self, request), fields(namespace = %namespace))]
527 pub async fn unified_query(
528 &self,
529 namespace: &str,
530 request: UnifiedQueryRequest,
531 ) -> Result<UnifiedQueryResponse> {
532 let url = format!(
533 "{}/v1/namespaces/{}/unified-query",
534 self.base_url, namespace
535 );
536 debug!(
537 "Unified query in namespace {} with top_k={}",
538 namespace, request.top_k
539 );
540
541 let response = self.client.post(&url).json(&request).send().await?;
542 self.handle_response(response).await
543 }
544
545 /// Simple vector search using the unified query API (convenience method)
546 ///
547 /// This is a shortcut for `unified_query` with a vector ANN search.
548 #[instrument(skip(self, vector))]
549 pub async fn unified_vector_search(
550 &self,
551 namespace: &str,
552 vector: Vec<f32>,
553 top_k: usize,
554 ) -> Result<UnifiedQueryResponse> {
555 self.unified_query(namespace, UnifiedQueryRequest::vector_search(vector, top_k))
556 .await
557 }
558
559 /// Simple full-text search using the unified query API (convenience method)
560 ///
561 /// This is a shortcut for `unified_query` with a BM25 full-text search.
562 #[instrument(skip(self))]
563 pub async fn unified_text_search(
564 &self,
565 namespace: &str,
566 field: &str,
567 query: &str,
568 top_k: usize,
569 ) -> Result<UnifiedQueryResponse> {
570 self.unified_query(
571 namespace,
572 UnifiedQueryRequest::fulltext_search(field, query, top_k),
573 )
574 .await
575 }
576
577 // ========================================================================
578 // Query Explain Operations
579 // ========================================================================
580
581 /// Explain query execution plan (similar to SQL EXPLAIN)
582 ///
583 /// This provides detailed information about how a query will be executed,
584 /// including index selection, execution stages, cost estimates, and
585 /// performance recommendations.
586 ///
587 /// # Example
588 ///
589 /// ```rust,no_run
590 /// use dakera_client::{DakeraClient, QueryExplainRequest};
591 ///
592 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
593 /// let client = DakeraClient::new("http://localhost:3000")?;
594 ///
595 /// // Explain a vector search query
596 /// let request = QueryExplainRequest::vector_search(vec![0.1, 0.2, 0.3], 10)
597 /// .with_verbose();
598 /// let plan = client.explain_query("my-namespace", request).await?;
599 ///
600 /// println!("Query plan: {}", plan.summary);
601 /// println!("Estimated time: {}ms", plan.cost_estimate.estimated_time_ms);
602 ///
603 /// for stage in &plan.stages {
604 /// println!("Stage {}: {} - {}", stage.order, stage.name, stage.description);
605 /// }
606 ///
607 /// for rec in &plan.recommendations {
608 /// println!("Recommendation ({}): {}", rec.priority, rec.description);
609 /// }
610 /// # Ok(())
611 /// # }
612 /// ```
613 #[instrument(skip(self, request), fields(namespace = %namespace))]
614 pub async fn explain_query(
615 &self,
616 namespace: &str,
617 request: QueryExplainRequest,
618 ) -> Result<QueryExplainResponse> {
619 let url = format!("{}/v1/namespaces/{}/explain", self.base_url, namespace);
620 debug!(
621 "Explaining query in namespace {} (query_type={:?}, top_k={})",
622 namespace, request.query_type, request.top_k
623 );
624
625 let response = self.client.post(&url).json(&request).send().await?;
626 self.handle_response(response).await
627 }
628
629 // ========================================================================
630 // Cache Warming Operations
631 // ========================================================================
632
633 /// Warm cache for vectors in a namespace
634 ///
635 /// This pre-loads vectors into cache tiers for faster subsequent access.
636 /// Supports priority levels and can run in the background.
637 ///
638 /// # Example
639 ///
640 /// ```rust,no_run
641 /// use dakera_client::{DakeraClient, WarmCacheRequest, WarmingPriority};
642 ///
643 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
644 /// let client = DakeraClient::new("http://localhost:3000")?;
645 ///
646 /// // Warm entire namespace with high priority
647 /// let response = client.warm_cache(
648 /// WarmCacheRequest::new("my-namespace")
649 /// .with_priority(WarmingPriority::High)
650 /// ).await?;
651 ///
652 /// println!("Warmed {} entries", response.entries_warmed);
653 /// # Ok(())
654 /// # }
655 /// ```
656 #[instrument(skip(self, request), fields(namespace = %request.namespace, priority = ?request.priority))]
657 pub async fn warm_cache(&self, request: WarmCacheRequest) -> Result<WarmCacheResponse> {
658 let url = format!(
659 "{}/v1/namespaces/{}/cache/warm",
660 self.base_url, request.namespace
661 );
662 debug!(
663 "Warming cache for namespace {} with priority {:?}",
664 request.namespace, request.priority
665 );
666
667 let response = self.client.post(&url).json(&request).send().await?;
668 self.handle_response(response).await
669 }
670
671 /// Warm specific vectors by ID (convenience method)
672 #[instrument(skip(self, vector_ids))]
673 pub async fn warm_vectors(
674 &self,
675 namespace: &str,
676 vector_ids: Vec<String>,
677 ) -> Result<WarmCacheResponse> {
678 self.warm_cache(WarmCacheRequest::new(namespace).with_vector_ids(vector_ids))
679 .await
680 }
681
682 // ========================================================================
683 // Export Operations
684 // ========================================================================
685
686 /// Export vectors from a namespace with pagination
687 ///
688 /// This exports all vectors from a namespace, supporting pagination for
689 /// large datasets. Use the `next_cursor` from the response to fetch
690 /// subsequent pages.
691 ///
692 /// # Example
693 ///
694 /// ```rust,no_run
695 /// use dakera_client::{DakeraClient, ExportRequest};
696 ///
697 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
698 /// let client = DakeraClient::new("http://localhost:3000")?;
699 ///
700 /// // Export first page of vectors
701 /// let mut request = ExportRequest::new().with_top_k(1000);
702 /// let response = client.export("my-namespace", request).await?;
703 ///
704 /// println!("Exported {} vectors", response.returned_count);
705 ///
706 /// // Fetch next page if available
707 /// if let Some(cursor) = response.next_cursor {
708 /// let next_request = ExportRequest::new().with_cursor(cursor);
709 /// let next_response = client.export("my-namespace", next_request).await?;
710 /// }
711 /// # Ok(())
712 /// # }
713 /// ```
714 #[instrument(skip(self, request), fields(namespace = %namespace))]
715 pub async fn export(&self, namespace: &str, request: ExportRequest) -> Result<ExportResponse> {
716 let url = format!("{}/v1/namespaces/{}/export", self.base_url, namespace);
717 debug!(
718 "Exporting vectors from namespace {} (top_k={}, cursor={:?})",
719 namespace, request.top_k, request.cursor
720 );
721
722 let response = self.client.post(&url).json(&request).send().await?;
723 self.handle_response(response).await
724 }
725
726 /// Export all vectors from a namespace (convenience method)
727 ///
728 /// This is a simple wrapper that exports with default settings.
729 #[instrument(skip(self))]
730 pub async fn export_all(&self, namespace: &str) -> Result<ExportResponse> {
731 self.export(namespace, ExportRequest::new()).await
732 }
733
734 // ========================================================================
735 // Operations
736 // ========================================================================
737
738 /// Get system diagnostics
739 #[instrument(skip(self))]
740 pub async fn diagnostics(&self) -> Result<SystemDiagnostics> {
741 let url = format!("{}/ops/diagnostics", self.base_url);
742 let response = self.client.get(&url).send().await?;
743 self.handle_response(response).await
744 }
745
746 /// List background jobs
747 #[instrument(skip(self))]
748 pub async fn list_jobs(&self) -> Result<Vec<JobInfo>> {
749 let url = format!("{}/ops/jobs", self.base_url);
750 let response = self.client.get(&url).send().await?;
751 self.handle_response(response).await
752 }
753
754 /// Get a specific job status
755 #[instrument(skip(self))]
756 pub async fn get_job(&self, job_id: &str) -> Result<Option<JobInfo>> {
757 let url = format!("{}/ops/jobs/{}", self.base_url, job_id);
758 let response = self.client.get(&url).send().await?;
759
760 if response.status() == StatusCode::NOT_FOUND {
761 return Ok(None);
762 }
763
764 self.handle_response(response).await.map(Some)
765 }
766
767 /// Trigger index compaction
768 #[instrument(skip(self, request))]
769 pub async fn compact(&self, request: CompactionRequest) -> Result<CompactionResponse> {
770 let url = format!("{}/ops/compact", self.base_url);
771 let response = self.client.post(&url).json(&request).send().await?;
772 self.handle_response(response).await
773 }
774
775 /// Request graceful shutdown
776 #[instrument(skip(self))]
777 pub async fn shutdown(&self) -> Result<()> {
778 let url = format!("{}/ops/shutdown", self.base_url);
779 let response = self.client.post(&url).send().await?;
780
781 if response.status().is_success() {
782 Ok(())
783 } else {
784 let status = response.status().as_u16();
785 let text = response.text().await.unwrap_or_default();
786 Err(ClientError::Server {
787 status,
788 message: text,
789 })
790 }
791 }
792
793 // ========================================================================
794 // Private Helpers
795 // ========================================================================
796
797 /// Handle response and deserialize JSON
798 pub(crate) async fn handle_response<T: serde::de::DeserializeOwned>(
799 &self,
800 response: reqwest::Response,
801 ) -> Result<T> {
802 let status = response.status();
803
804 if status.is_success() {
805 Ok(response.json().await?)
806 } else {
807 let status_code = status.as_u16();
808 let text = response.text().await.unwrap_or_default();
809
810 // Try to parse error message from JSON
811 let message = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
812 json.get("error")
813 .and_then(|e| e.as_str())
814 .unwrap_or(&text)
815 .to_string()
816 } else {
817 text
818 };
819
820 Err(ClientError::Server {
821 status: status_code,
822 message,
823 })
824 }
825 }
826}
827
828/// Builder for DakeraClient
829#[derive(Debug)]
830pub struct DakeraClientBuilder {
831 base_url: String,
832 timeout: Duration,
833 user_agent: Option<String>,
834}
835
836impl DakeraClientBuilder {
837 /// Create a new builder
838 pub fn new(base_url: impl Into<String>) -> Self {
839 Self {
840 base_url: base_url.into(),
841 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
842 user_agent: None,
843 }
844 }
845
846 /// Set the request timeout
847 pub fn timeout(mut self, timeout: Duration) -> Self {
848 self.timeout = timeout;
849 self
850 }
851
852 /// Set the request timeout in seconds
853 pub fn timeout_secs(mut self, secs: u64) -> Self {
854 self.timeout = Duration::from_secs(secs);
855 self
856 }
857
858 /// Set a custom user agent
859 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
860 self.user_agent = Some(user_agent.into());
861 self
862 }
863
864 /// Build the client
865 pub fn build(self) -> Result<DakeraClient> {
866 // Normalize base URL (remove trailing slash)
867 let base_url = self.base_url.trim_end_matches('/').to_string();
868
869 // Validate URL
870 if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
871 return Err(ClientError::InvalidUrl(
872 "URL must start with http:// or https://".to_string(),
873 ));
874 }
875
876 let user_agent = self
877 .user_agent
878 .unwrap_or_else(|| format!("dakera-client/{}", env!("CARGO_PKG_VERSION")));
879
880 let client = Client::builder()
881 .timeout(self.timeout)
882 .user_agent(user_agent)
883 .build()
884 .map_err(|e| ClientError::Config(e.to_string()))?;
885
886 Ok(DakeraClient { client, base_url })
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn test_client_builder() {
896 let client = DakeraClient::new("http://localhost:3000");
897 assert!(client.is_ok());
898 }
899
900 #[test]
901 fn test_client_builder_with_options() {
902 let client = DakeraClient::builder("http://localhost:3000")
903 .timeout_secs(60)
904 .user_agent("test-client/1.0")
905 .build();
906 assert!(client.is_ok());
907 }
908
909 #[test]
910 fn test_client_builder_invalid_url() {
911 let client = DakeraClient::new("invalid-url");
912 assert!(client.is_err());
913 }
914
915 #[test]
916 fn test_client_builder_trailing_slash() {
917 let client = DakeraClient::new("http://localhost:3000/").unwrap();
918 assert!(!client.base_url.ends_with('/'));
919 }
920
921 #[test]
922 fn test_vector_creation() {
923 let v = Vector::new("test", vec![0.1, 0.2, 0.3]);
924 assert_eq!(v.id, "test");
925 assert_eq!(v.values.len(), 3);
926 assert!(v.metadata.is_none());
927 }
928
929 #[test]
930 fn test_query_request_builder() {
931 let req = QueryRequest::new(vec![0.1, 0.2], 10)
932 .with_filter(serde_json::json!({"category": "test"}))
933 .include_metadata(false);
934
935 assert_eq!(req.top_k, 10);
936 assert!(req.filter.is_some());
937 assert!(!req.include_metadata);
938 }
939
940 #[test]
941 fn test_hybrid_search_request() {
942 let req = HybridSearchRequest::new(vec![0.1], "test query", 5).with_vector_weight(0.7);
943
944 assert_eq!(req.vector_weight, 0.7);
945 assert_eq!(req.text, "test query");
946 }
947
948 #[test]
949 fn test_hybrid_search_weight_clamping() {
950 let req = HybridSearchRequest::new(vec![0.1], "test", 5).with_vector_weight(1.5); // Should be clamped to 1.0
951
952 assert_eq!(req.vector_weight, 1.0);
953 }
954}