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}