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