Skip to main content

vectorizer_sdk/client/
mod.rs

1//! REST `VectorizerClient` — split per API surface (phase4).
2//!
3//! Public-API entry point for the legacy HTTP transport. Phase4
4//! split the original 1,989-line `client.rs` into one struct + 8
5//! per-surface impl files; every method is reachable through the
6//! same `VectorizerClient` facade for backward compat.
7//!
8//! - Struct, config, ctors, `with_master`, `make_request`,
9//!   read/write transport selection — this file.
10//! - One `impl VectorizerClient` block per surface in the matching
11//!   submodule (Rust permits as many impl blocks as you like for the
12//!   same struct, across files of the same module).
13//!
14//! ## Per-surface modules
15//!
16//! | Surface | Methods |
17//! |---|---|
18//! | [`core`] | `health_check` |
19//! | [`collections`] | `list_collections`, `create_collection`, `delete_collection`, `get_collection_info` |
20//! | [`vectors`] | `get_vector`, `insert_texts`, `embed_text`, `update_vector`, `insert_text`, `list_vectors`, `get_vector_by_path`, `batch_insert_texts`, `insert_vectors`, `batch_search`, `batch_update_vectors`, `delete_vector`, `delete_vectors`, `move_to_collection` |
21//! | [`search`] | `search_vectors`, `intelligent_search`, `semantic_search`, `contextual_search`, `multi_collection_search`, `hybrid_search`, `search_by_file` |
22//! | [`discovery`] | `discover`, `filter_collections`, `score_collections`, `expand_queries`, `broad_discovery`, `semantic_focus`, `promote_readme`, `compress_evidence`, `build_answer_plan`, `render_llm_prompt` |
23//! | [`files`] | `get_file_content`, `list_files_in_collection`, `get_file_summary`, `get_file_chunks_ordered`, `get_project_outline`, `get_related_files`, `search_by_file_type`, `upload_file`, `upload_file_content`, `get_upload_config` |
24//! | [`graph`] | `list_graph_nodes`, `get_graph_neighbors`, `find_related_nodes`, `find_graph_path`, `create_graph_edge`, `delete_graph_edge`, `list_graph_edges`, `discover_graph_edges`, `discover_graph_edges_for_node`, `get_graph_discovery_status` |
25//! | [`qdrant`] | 25 `qdrant_*` methods (Qdrant-compatible REST surface) |
26//! | [`admin`] | `get_stats`, `get_status`, `get_logs`, `get_indexing_progress`, `force_save_collection`, `list_empty_collections`, `cleanup_empty_collections`, `get_config`, `update_config`, `list_backups`, `create_backup`, `restore_backup`, `restart_server`, `list_workspaces`, `get_workspace_config`, `add_workspace`, `remove_workspace` |
27//! | [`auth`] | `me`, `logout`, `refresh_token`, `validate_password`, `create_api_key`, `list_api_keys`, `revoke_api_key`, `create_user`, `list_users`, `delete_user`, `change_password` |
28//! | [`replication`] | `get_replication_status`, `configure_replication`, `get_replication_stats`, `list_replicas` |
29//! | [`hub`] | `list_user_backups`, `create_user_backup`, `restore_user_backup`, `upload_user_backup`, `get_user_backup`, `delete_user_backup`, `download_user_backup`, `get_usage_statistics`, `get_quota_info`, `validate_hub_api_key` |
30//!
31//! ## RPC readiness
32//!
33//! Every per-surface impl calls through `self.make_request` →
34//! `self.transport: Arc<dyn Transport>`. The `Transport` trait
35//! (declared in [`crate::transport`]) is implemented by
36//! [`crate::http_transport::HttpTransport`] today; the RPC backend
37//! from `phase6_sdk-rust-rpc` plugs into the same interface so the
38//! per-surface modules don't need any changes when the canonical
39//! `vectorizer://host:15503` transport lands as the default. See
40//! [`crate::rpc`] for the RPC client built directly on `tokio::net`
41//! — it lives alongside this REST facade rather than under it.
42
43use std::sync::Arc;
44
45use crate::error::{Result, VectorizerError};
46use crate::http_transport::HttpTransport;
47use crate::models::*;
48use crate::transport::{Protocol, Transport};
49#[cfg(feature = "umicp")]
50use crate::umicp_transport::UmicpTransport;
51
52pub mod admin;
53pub mod auth;
54pub mod collections;
55pub mod core;
56pub mod discovery;
57pub mod files;
58pub mod graph;
59pub mod hub;
60pub mod qdrant;
61pub mod replication;
62pub mod search;
63pub mod vectors;
64
65/// Configuration for [`VectorizerClient`].
66#[derive(Clone)]
67pub struct ClientConfig {
68    /// Base URL for HTTP transport (single-node deployments).
69    pub base_url: Option<String>,
70    /// Connection string (supports `http://`, `https://`, `umicp://`).
71    pub connection_string: Option<String>,
72    /// Protocol to use.
73    pub protocol: Option<Protocol>,
74    /// API key for authentication.
75    pub api_key: Option<String>,
76    /// Request timeout in seconds.
77    pub timeout_secs: Option<u64>,
78    /// UMICP configuration.
79    #[cfg(feature = "umicp")]
80    pub umicp: Option<UmicpConfig>,
81    /// Master/replica host configuration for read/write routing.
82    pub hosts: Option<HostConfig>,
83    /// Default read preference for read operations.
84    pub read_preference: Option<ReadPreference>,
85}
86
87#[cfg(feature = "umicp")]
88/// UMICP-specific configuration.
89#[derive(Clone)]
90pub struct UmicpConfig {
91    /// UMICP host name or address.
92    pub host: String,
93    /// UMICP TCP port.
94    pub port: u16,
95}
96
97impl Default for ClientConfig {
98    fn default() -> Self {
99        Self {
100            base_url: Some("http://localhost:15002".to_string()),
101            connection_string: None,
102            protocol: None,
103            api_key: None,
104            timeout_secs: Some(30),
105            #[cfg(feature = "umicp")]
106            umicp: None,
107            hosts: None,
108            read_preference: None,
109        }
110    }
111}
112
113/// Vectorizer REST client with optional master/replica topology
114/// support. Public surface is identical to the pre-phase4
115/// monolithic `VectorizerClient`; the methods are now organised
116/// across per-surface impl blocks (see module docs).
117pub struct VectorizerClient {
118    pub(crate) transport: Arc<dyn Transport>,
119    protocol: Protocol,
120    base_url: String,
121    /// Master transport for write operations (if replica mode is enabled).
122    #[allow(dead_code)]
123    master_transport: Option<Arc<dyn Transport>>,
124    /// Replica transports for read operations (if replica mode is enabled).
125    #[allow(dead_code)]
126    replica_transports: Vec<Arc<dyn Transport>>,
127    /// Current replica index for round-robin selection.
128    #[allow(dead_code)]
129    replica_index: std::sync::atomic::AtomicUsize,
130    /// Default read preference.
131    #[allow(dead_code)]
132    read_preference: ReadPreference,
133    /// Whether replica mode is enabled.
134    #[allow(dead_code)]
135    is_replica_mode: bool,
136    /// Original config for creating child clients (e.g. `with_master`).
137    pub(crate) config: ClientConfig,
138}
139
140impl VectorizerClient {
141    /// Get the base URL the client is configured against.
142    pub fn base_url(&self) -> &str {
143        &self.base_url
144    }
145
146    /// Create a new client with the given configuration.
147    pub fn new(config: ClientConfig) -> Result<Self> {
148        let timeout_secs = config.timeout_secs.unwrap_or(30);
149
150        // Determine protocol and create transport.
151        let (transport, protocol, base_url): (Arc<dyn Transport>, Protocol, String) =
152            if let Some(ref conn_str) = config.connection_string {
153                #[allow(unused_variables)]
154                let (proto, host, port) = crate::transport::parse_connection_string(conn_str)?;
155
156                match proto {
157                    Protocol::Http => {
158                        let transport =
159                            HttpTransport::new(&host, config.api_key.as_deref(), timeout_secs)?;
160                        (Arc::new(transport), Protocol::Http, host.clone())
161                    }
162                    #[cfg(feature = "umicp")]
163                    Protocol::Umicp => {
164                        let umicp_port = port.unwrap_or(15003);
165                        let transport = UmicpTransport::new(
166                            &host,
167                            umicp_port,
168                            config.api_key.as_deref(),
169                            timeout_secs,
170                        )?;
171                        let base_url = format!("umicp://{host}:{umicp_port}");
172                        (Arc::new(transport), Protocol::Umicp, base_url)
173                    }
174                }
175            } else {
176                let proto = config.protocol.unwrap_or(Protocol::Http);
177
178                match proto {
179                    Protocol::Http => {
180                        let base_url = config
181                            .base_url
182                            .clone()
183                            .unwrap_or_else(|| "http://localhost:15002".to_string());
184                        let transport =
185                            HttpTransport::new(&base_url, config.api_key.as_deref(), timeout_secs)?;
186                        (Arc::new(transport), Protocol::Http, base_url)
187                    }
188                    #[cfg(feature = "umicp")]
189                    Protocol::Umicp => {
190                        #[cfg(feature = "umicp")]
191                        {
192                            let umicp_config = config.umicp.clone().ok_or_else(|| {
193                                VectorizerError::configuration(
194                                    "UMICP configuration is required when using UMICP protocol",
195                                )
196                            })?;
197
198                            let transport = UmicpTransport::new(
199                                &umicp_config.host,
200                                umicp_config.port,
201                                config.api_key.as_deref(),
202                                timeout_secs,
203                            )?;
204                            let base_url =
205                                format!("umicp://{}:{}", umicp_config.host, umicp_config.port);
206                            (Arc::new(transport), Protocol::Umicp, base_url)
207                        }
208                        #[cfg(not(feature = "umicp"))]
209                        {
210                            return Err(VectorizerError::configuration(
211                                "UMICP feature is not enabled. Enable it with --features umicp",
212                            ));
213                        }
214                    }
215                }
216            };
217
218        // Initialise replica mode if hosts are configured.
219        let (master_transport, replica_transports, is_replica_mode) =
220            if let Some(ref hosts) = config.hosts {
221                let master =
222                    HttpTransport::new(&hosts.master, config.api_key.as_deref(), timeout_secs)?;
223                let replicas: Result<Vec<Arc<dyn Transport>>> = hosts
224                    .replicas
225                    .iter()
226                    .map(|url| {
227                        let t = HttpTransport::new(url, config.api_key.as_deref(), timeout_secs)?;
228                        Ok(Arc::new(t) as Arc<dyn Transport>)
229                    })
230                    .collect();
231                (
232                    Some(Arc::new(master) as Arc<dyn Transport>),
233                    replicas?,
234                    true,
235                )
236            } else {
237                (None, vec![], false)
238            };
239
240        let read_preference = config.read_preference.unwrap_or(ReadPreference::Replica);
241
242        Ok(Self {
243            transport,
244            protocol,
245            base_url,
246            master_transport,
247            replica_transports,
248            replica_index: std::sync::atomic::AtomicUsize::new(0),
249            read_preference,
250            is_replica_mode,
251            config,
252        })
253    }
254
255    /// Create a new client with default configuration.
256    pub fn new_default() -> Result<Self> {
257        Self::new(ClientConfig::default())
258    }
259
260    /// Create a client with a custom base URL.
261    pub fn new_with_url(base_url: &str) -> Result<Self> {
262        Self::new(ClientConfig {
263            base_url: Some(base_url.to_string()),
264            ..Default::default()
265        })
266    }
267
268    /// Create a client with a custom base URL + API key.
269    pub fn new_with_api_key(base_url: &str, api_key: &str) -> Result<Self> {
270        Self::new(ClientConfig {
271            base_url: Some(base_url.to_string()),
272            api_key: Some(api_key.to_string()),
273            ..Default::default()
274        })
275    }
276
277    /// Create a client from a full connection string
278    /// (`http(s)://host[:port]` or `umicp://host[:port]`).
279    pub fn from_connection_string(connection_string: &str, api_key: Option<&str>) -> Result<Self> {
280        Self::new(ClientConfig {
281            connection_string: Some(connection_string.to_string()),
282            api_key: api_key.map(|s| s.to_string()),
283            ..Default::default()
284        })
285    }
286
287    /// Returns the protocol the client is currently using.
288    pub fn protocol(&self) -> Protocol {
289        self.protocol
290    }
291
292    /// Get transport for write operations (always master).
293    #[allow(dead_code)]
294    pub(crate) fn get_write_transport(&self) -> &Arc<dyn Transport> {
295        if self.is_replica_mode {
296            self.master_transport.as_ref().unwrap_or(&self.transport)
297        } else {
298            &self.transport
299        }
300    }
301
302    /// Get transport for read operations based on the active read
303    /// preference (or the per-call override in `options`).
304    #[allow(dead_code)]
305    pub(crate) fn get_read_transport(&self, options: Option<&ReadOptions>) -> &Arc<dyn Transport> {
306        if !self.is_replica_mode {
307            return &self.transport;
308        }
309
310        let preference = options
311            .and_then(|o| o.read_preference)
312            .unwrap_or(self.read_preference);
313
314        match preference {
315            ReadPreference::Master => self.master_transport.as_ref().unwrap_or(&self.transport),
316            ReadPreference::Replica | ReadPreference::Nearest => {
317                if self.replica_transports.is_empty() {
318                    return self.master_transport.as_ref().unwrap_or(&self.transport);
319                }
320                let idx = self
321                    .replica_index
322                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
323                    % self.replica_transports.len();
324                &self.replica_transports[idx]
325            }
326        }
327    }
328
329    /// Execute a callback with master transport for read-your-writes
330    /// scenarios. All operations within the callback are routed to
331    /// master.
332    pub async fn with_master<F, Fut, T>(&self, callback: F) -> Result<T>
333    where
334        F: FnOnce(VectorizerClient) -> Fut,
335        Fut: std::future::Future<Output = Result<T>>,
336    {
337        let mut master_config = self.config.clone();
338        master_config.read_preference = Some(ReadPreference::Master);
339        let master_client = VectorizerClient::new(master_config)?;
340        callback(master_client).await
341    }
342
343    /// Construct a [`VectorizerClient`] directly from a custom
344    /// [`Transport`] implementation. **Test-only / advanced use.**
345    ///
346    /// The dispatcher fields (`master_transport`, `replica_transports`,
347    /// `is_replica_mode`) are all left empty — the client behaves as
348    /// a single-transport instance. Used by mock-based tests to swap
349    /// the real HTTP backend out for an in-memory one without
350    /// touching the per-surface modules.
351    ///
352    /// This entry point is the **RPC-readiness regression guard**
353    /// (phase 4 task 2.4): if any per-surface module accidentally
354    /// hard-codes `HttpTransport` or `reqwest::Client`, the
355    /// `MockTransport` integration test in
356    /// `tests/mock_transport_regression.rs` stops compiling. The
357    /// same `Transport` trait the [`crate::rpc`] backend will plug
358    /// into from `phase6_sdk-rust-rpc` is what mocks ride here.
359    pub fn with_transport(transport: Arc<dyn Transport>, base_url: impl Into<String>) -> Self {
360        let protocol = transport.protocol();
361        Self {
362            transport,
363            protocol,
364            base_url: base_url.into(),
365            master_transport: None,
366            replica_transports: Vec::new(),
367            replica_index: std::sync::atomic::AtomicUsize::new(0),
368            read_preference: ReadPreference::Master,
369            is_replica_mode: false,
370            config: ClientConfig::default(),
371        }
372    }
373
374    /// Internal helper: dispatch one HTTP-method-name call through
375    /// the active transport. Per-surface modules call this instead
376    /// of poking the `Transport` directly so future routing changes
377    /// (e.g. write-vs-read selection) land in one place.
378    pub(crate) async fn make_request(
379        &self,
380        method: &str,
381        endpoint: &str,
382        payload: Option<serde_json::Value>,
383    ) -> Result<String> {
384        match method {
385            "GET" => self.transport.get(endpoint).await,
386            "POST" => self.transport.post(endpoint, payload.as_ref()).await,
387            "PUT" => self.transport.put(endpoint, payload.as_ref()).await,
388            "DELETE" => self.transport.delete(endpoint).await,
389            "PATCH" => self.transport.patch(endpoint, payload.as_ref()).await,
390            _ => Err(VectorizerError::configuration(format!(
391                "Unsupported method: {method}"
392            ))),
393        }
394    }
395}