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}