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}