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    ///
148    /// `config.base_url` is an **`http(s)://`** URL — this client speaks REST
149    /// over HTTP. A `vectorizer://` URL belongs to the RPC transport; pass it
150    /// to [`crate::rpc::RpcClient::connect_url`] instead. Handing one to this
151    /// constructor is rejected here rather than at the first request, which is
152    /// where it used to surface as an opaque reqwest builder error (#392).
153    pub fn new(config: ClientConfig) -> Result<Self> {
154        let timeout_secs = config.timeout_secs.unwrap_or(30);
155
156        // Determine protocol and create transport.
157        let (transport, protocol, base_url): (Arc<dyn Transport>, Protocol, String) =
158            if let Some(ref conn_str) = config.connection_string {
159                #[allow(unused_variables)]
160                let (proto, host, port) = crate::transport::parse_connection_string(conn_str)?;
161
162                match proto {
163                    Protocol::Http => {
164                        let transport =
165                            HttpTransport::new(&host, config.api_key.as_deref(), timeout_secs)?;
166                        (Arc::new(transport), Protocol::Http, host.clone())
167                    }
168                    #[cfg(feature = "umicp")]
169                    Protocol::Umicp => {
170                        let umicp_port = port.unwrap_or(15003);
171                        let transport = UmicpTransport::new(
172                            &host,
173                            umicp_port,
174                            config.api_key.as_deref(),
175                            timeout_secs,
176                        )?;
177                        let base_url = format!("umicp://{host}:{umicp_port}");
178                        (Arc::new(transport), Protocol::Umicp, base_url)
179                    }
180                }
181            } else {
182                let proto = config.protocol.unwrap_or(Protocol::Http);
183
184                match proto {
185                    Protocol::Http => {
186                        let base_url = config
187                            .base_url
188                            .clone()
189                            .unwrap_or_else(|| "http://localhost:15002".to_string());
190                        let transport =
191                            HttpTransport::new(&base_url, config.api_key.as_deref(), timeout_secs)?;
192                        (Arc::new(transport), Protocol::Http, base_url)
193                    }
194                    #[cfg(feature = "umicp")]
195                    Protocol::Umicp => {
196                        #[cfg(feature = "umicp")]
197                        {
198                            let umicp_config = config.umicp.clone().ok_or_else(|| {
199                                VectorizerError::configuration(
200                                    "UMICP configuration is required when using UMICP protocol",
201                                )
202                            })?;
203
204                            let transport = UmicpTransport::new(
205                                &umicp_config.host,
206                                umicp_config.port,
207                                config.api_key.as_deref(),
208                                timeout_secs,
209                            )?;
210                            let base_url =
211                                format!("umicp://{}:{}", umicp_config.host, umicp_config.port);
212                            (Arc::new(transport), Protocol::Umicp, base_url)
213                        }
214                        #[cfg(not(feature = "umicp"))]
215                        {
216                            return Err(VectorizerError::configuration(
217                                "UMICP feature is not enabled. Enable it with --features umicp",
218                            ));
219                        }
220                    }
221                }
222            };
223
224        // Initialise replica mode if hosts are configured.
225        let (master_transport, replica_transports, is_replica_mode) =
226            if let Some(ref hosts) = config.hosts {
227                let master =
228                    HttpTransport::new(&hosts.master, config.api_key.as_deref(), timeout_secs)?;
229                let replicas: Result<Vec<Arc<dyn Transport>>> = hosts
230                    .replicas
231                    .iter()
232                    .map(|url| {
233                        let t = HttpTransport::new(url, config.api_key.as_deref(), timeout_secs)?;
234                        Ok(Arc::new(t) as Arc<dyn Transport>)
235                    })
236                    .collect();
237                (
238                    Some(Arc::new(master) as Arc<dyn Transport>),
239                    replicas?,
240                    true,
241                )
242            } else {
243                (None, vec![], false)
244            };
245
246        let read_preference = config.read_preference.unwrap_or(ReadPreference::Replica);
247
248        Ok(Self {
249            transport,
250            protocol,
251            base_url,
252            master_transport,
253            replica_transports,
254            replica_index: std::sync::atomic::AtomicUsize::new(0),
255            read_preference,
256            is_replica_mode,
257            config,
258        })
259    }
260
261    /// Create a new client with default configuration.
262    pub fn new_default() -> Result<Self> {
263        Self::new(ClientConfig::default())
264    }
265
266    /// Create a client with a custom base URL.
267    pub fn new_with_url(base_url: &str) -> Result<Self> {
268        Self::new(ClientConfig {
269            base_url: Some(base_url.to_string()),
270            ..Default::default()
271        })
272    }
273
274    /// Create a client with a custom base URL + API key.
275    pub fn new_with_api_key(base_url: &str, api_key: &str) -> Result<Self> {
276        Self::new(ClientConfig {
277            base_url: Some(base_url.to_string()),
278            api_key: Some(api_key.to_string()),
279            ..Default::default()
280        })
281    }
282
283    /// Create a client from a full connection string
284    /// (`http(s)://host[:port]` or `umicp://host[:port]`).
285    pub fn from_connection_string(connection_string: &str, api_key: Option<&str>) -> Result<Self> {
286        Self::new(ClientConfig {
287            connection_string: Some(connection_string.to_string()),
288            api_key: api_key.map(|s| s.to_string()),
289            ..Default::default()
290        })
291    }
292
293    /// Returns the protocol the client is currently using.
294    pub fn protocol(&self) -> Protocol {
295        self.protocol
296    }
297
298    /// Get transport for write operations (always master).
299    #[allow(dead_code)]
300    pub(crate) fn get_write_transport(&self) -> &Arc<dyn Transport> {
301        if self.is_replica_mode {
302            self.master_transport.as_ref().unwrap_or(&self.transport)
303        } else {
304            &self.transport
305        }
306    }
307
308    /// Get transport for read operations based on the active read
309    /// preference (or the per-call override in `options`).
310    #[allow(dead_code)]
311    pub(crate) fn get_read_transport(&self, options: Option<&ReadOptions>) -> &Arc<dyn Transport> {
312        if !self.is_replica_mode {
313            return &self.transport;
314        }
315
316        let preference = options
317            .and_then(|o| o.read_preference)
318            .unwrap_or(self.read_preference);
319
320        match preference {
321            ReadPreference::Master => self.master_transport.as_ref().unwrap_or(&self.transport),
322            ReadPreference::Replica | ReadPreference::Nearest => {
323                if self.replica_transports.is_empty() {
324                    return self.master_transport.as_ref().unwrap_or(&self.transport);
325                }
326                let idx = self
327                    .replica_index
328                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
329                    % self.replica_transports.len();
330                &self.replica_transports[idx]
331            }
332        }
333    }
334
335    /// Execute a callback with master transport for read-your-writes
336    /// scenarios. All operations within the callback are routed to
337    /// master.
338    pub async fn with_master<F, Fut, T>(&self, callback: F) -> Result<T>
339    where
340        F: FnOnce(VectorizerClient) -> Fut,
341        Fut: std::future::Future<Output = Result<T>>,
342    {
343        let mut master_config = self.config.clone();
344        master_config.read_preference = Some(ReadPreference::Master);
345        let master_client = VectorizerClient::new(master_config)?;
346        callback(master_client).await
347    }
348
349    /// Construct a [`VectorizerClient`] directly from a custom
350    /// [`Transport`] implementation. **Test-only / advanced use.**
351    ///
352    /// The dispatcher fields (`master_transport`, `replica_transports`,
353    /// `is_replica_mode`) are all left empty — the client behaves as
354    /// a single-transport instance. Used by mock-based tests to swap
355    /// the real HTTP backend out for an in-memory one without
356    /// touching the per-surface modules.
357    ///
358    /// This entry point is the **RPC-readiness regression guard**
359    /// (phase 4 task 2.4): if any per-surface module accidentally
360    /// hard-codes `HttpTransport` or `reqwest::Client`, the
361    /// `MockTransport` integration test in
362    /// `tests/mock_transport_regression.rs` stops compiling. The
363    /// same `Transport` trait the [`crate::rpc`] backend will plug
364    /// into from `phase6_sdk-rust-rpc` is what mocks ride here.
365    pub fn with_transport(transport: Arc<dyn Transport>, base_url: impl Into<String>) -> Self {
366        let protocol = transport.protocol();
367        Self {
368            transport,
369            protocol,
370            base_url: base_url.into(),
371            master_transport: None,
372            replica_transports: Vec::new(),
373            replica_index: std::sync::atomic::AtomicUsize::new(0),
374            read_preference: ReadPreference::Master,
375            is_replica_mode: false,
376            config: ClientConfig::default(),
377        }
378    }
379
380    /// Internal helper: dispatch one HTTP-method-name call through
381    /// the active transport. Per-surface modules call this instead
382    /// of poking the `Transport` directly so future routing changes
383    /// (e.g. write-vs-read selection) land in one place.
384    pub(crate) async fn make_request(
385        &self,
386        method: &str,
387        endpoint: &str,
388        payload: Option<serde_json::Value>,
389    ) -> Result<String> {
390        match method {
391            "GET" => self.transport.get(endpoint).await,
392            "POST" => self.transport.post(endpoint, payload.as_ref()).await,
393            "PUT" => self.transport.put(endpoint, payload.as_ref()).await,
394            "DELETE" => self.transport.delete(endpoint).await,
395            "PATCH" => self.transport.patch(endpoint, payload.as_ref()).await,
396            _ => Err(VectorizerError::configuration(format!(
397                "Unsupported method: {method}"
398            ))),
399        }
400    }
401}