vectorizer_sdk/rpc/client.rs
1//! `RpcClient`: connect, hello, call, ping, close.
2//!
3//! The transport is Thunder's ([`thunder::Client`]): one TCP connection per
4//! `RpcClient`, a background reader that demultiplexes responses by frame id
5//! so concurrent in-flight calls don't block each other, lazy reconnect, and
6//! typed errors. What lives here is Vectorizer's shape on top of it — the
7//! `vectorizer://` protocol config, the HELLO payload/response types, and the
8//! error mapping the typed wrappers in [`super::commands`] consume.
9//!
10//! Auth is **per-connection sticky** per wire spec § 4, and Thunder carries
11//! credentials in the connection handshake (`AUTH`) rather than in a command.
12//! [`RpcClient::hello`] therefore re-dials when its payload carries a token or
13//! an API key, so the credentials reach the session that later commands run
14//! under; the HELLO command itself still runs, because the server answers it
15//! with the capability list and auth flags this client surfaces.
16
17use std::sync::Arc;
18use std::time::Duration;
19
20use parking_lot::Mutex;
21
22use super::types::VectorizerValue;
23
24/// Vectorizer's slot in the 15500-range binary-transport convention shared
25/// with Synap; the default when a `vectorizer://host` URL omits the port
26/// (wire spec § 12).
27pub const DEFAULT_RPC_PORT: u16 = 15503;
28
29/// Frame-body cap, matching the server's listener so neither end rejects a
30/// frame the other is willing to send.
31const MAX_FRAME_BYTES: usize = 512 * 1024 * 1024;
32
33/// How Vectorizer uses the Thunder wire — the client half of the server's
34/// `vectorizer_config()`: `vectorizer` scheme, `AUTH`-command handshake, no
35/// HELLO negotiation (the `HELLO` *command* is Vectorizer's own), RESP3-style
36/// error prefixes.
37///
38/// Declared here rather than imported from the server so the SDK depends only
39/// on registry crates — `cargo publish` rejects path dependencies.
40pub fn protocol_config() -> thunder::Config {
41 use thunder::wire::config::{ErrorConvention, Handshake, HelloStyle, PushPolicy};
42 thunder::Config::standard()
43 .scheme("vectorizer")
44 .port(DEFAULT_RPC_PORT)
45 .handshake(Handshake::AuthCommand)
46 .hello_style(HelloStyle::NotUsed)
47 .push(PushPolicy::Reserved)
48 .error_codes(ErrorConvention::Resp3Prefixes)
49 .max_frame_bytes(MAX_FRAME_BYTES)
50}
51
52/// Errors the [`RpcClient`] can return.
53#[derive(Debug, thiserror::Error)]
54pub enum RpcClientError {
55 /// Transport-level failure: dial, write, or the connection dying while
56 /// the call was pending.
57 #[error("connection error: {0}")]
58 Connection(String),
59
60 /// Server returned `Result::Err(message)` for the call.
61 #[error("server error: {0}")]
62 Server(String),
63
64 /// The server refused the session's credentials — `NOAUTH` (no `AUTH`
65 /// sent, or HELLO issued without credentials against an auth-enabled
66 /// server), `WRONGPASS`, or `NOPERM` for an admin-only command.
67 #[error("not authenticated: {0}")]
68 NotAuthenticated(String),
69
70 /// The connect or per-call timeout elapsed.
71 #[error("timed out")]
72 Timeout,
73
74 /// The peer sent a malformed or oversized frame; the connection is
75 /// poisoned and the next call re-dials.
76 #[error("protocol error: {0}")]
77 Protocol(String),
78}
79
80impl From<thunder::ClientError> for RpcClientError {
81 fn from(err: thunder::ClientError) -> Self {
82 use thunder::ClientError;
83 match err {
84 ClientError::Auth { message } => Self::NotAuthenticated(message),
85 // The raw server string, verbatim — including any `[code]`
86 // prefix the server put in front of it.
87 ClientError::Server { message, .. } => Self::Server(message),
88 ClientError::Connection { message } => Self::Connection(message),
89 ClientError::Timeout => Self::Timeout,
90 ClientError::FrameTooLarge { message } | ClientError::Decode { message } => {
91 Self::Protocol(message)
92 }
93 }
94 }
95}
96
97/// Result type alias.
98pub type Result<T> = std::result::Result<T, RpcClientError>;
99
100/// HELLO request payload.
101///
102/// At least one of `token` / `api_key` should be populated when the server has
103/// auth enabled: those credentials travel in the connection handshake, so
104/// passing them to [`RpcClient::hello`] is what authenticates the session.
105/// When the server runs in single-user mode (`auth.enabled: false`) the
106/// listener is open, credentials are accepted-but-ignored, and the connection
107/// runs as the implicit local admin.
108#[derive(Debug, Clone, Default)]
109pub struct HelloPayload {
110 /// Bearer JWT (same shape REST `/auth/login` returns).
111 pub token: Option<String>,
112 /// API key.
113 pub api_key: Option<String>,
114 /// User-Agent-style identifier surfaced in server-side tracing.
115 pub client_name: Option<String>,
116 /// Wire spec protocol version; defaults to 1.
117 pub version: i64,
118}
119
120impl HelloPayload {
121 /// Build a minimal HELLO payload identifying the client by name.
122 /// No credentials — works against a server running in single-user
123 /// mode (`auth.enabled: false`).
124 pub fn new(client_name: impl Into<String>) -> Self {
125 Self {
126 client_name: Some(client_name.into()),
127 version: 1,
128 ..Default::default()
129 }
130 }
131
132 /// Attach a JWT bearer token. Replaces any previously set
133 /// token/api_key.
134 pub fn with_token(mut self, token: impl Into<String>) -> Self {
135 self.token = Some(token.into());
136 self.api_key = None;
137 self
138 }
139
140 /// Attach an API key. Replaces any previously set token/api_key.
141 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
142 self.api_key = Some(api_key.into());
143 self.token = None;
144 self
145 }
146
147 /// The credentials this payload carries, if any.
148 fn credentials(&self) -> Option<thunder::client::Credentials> {
149 if let Some(token) = &self.token {
150 return Some(thunder::client::Credentials::Token(token.clone()));
151 }
152 self.api_key
153 .as_ref()
154 .map(|key| thunder::client::Credentials::ApiKey(key.clone()))
155 }
156
157 fn into_value(self) -> VectorizerValue {
158 let mut pairs = vec![(
159 VectorizerValue::Str("version".into()),
160 VectorizerValue::Int(self.version),
161 )];
162 if let Some(token) = self.token {
163 pairs.push((
164 VectorizerValue::Str("token".into()),
165 VectorizerValue::Str(token),
166 ));
167 }
168 if let Some(api_key) = self.api_key {
169 pairs.push((
170 VectorizerValue::Str("api_key".into()),
171 VectorizerValue::Str(api_key),
172 ));
173 }
174 if let Some(name) = self.client_name {
175 pairs.push((
176 VectorizerValue::Str("client_name".into()),
177 VectorizerValue::Str(name),
178 ));
179 }
180 VectorizerValue::Map(pairs)
181 }
182}
183
184/// What the server returns for a successful `HELLO`.
185#[derive(Debug, Clone)]
186pub struct HelloResponse {
187 /// Server crate version, e.g. `"3.6.0"`.
188 pub server_version: String,
189 /// Wire spec protocol version, currently always `1`.
190 pub protocol_version: i64,
191 /// `true` when the server accepted the supplied credentials (or
192 /// when auth is globally disabled).
193 pub authenticated: bool,
194 /// `true` when the authenticated principal carries `Role::Admin`.
195 pub admin: bool,
196 /// Capability names this connection can call.
197 pub capabilities: Vec<String>,
198}
199
200impl HelloResponse {
201 fn parse(value: &VectorizerValue) -> Self {
202 let server_version = value
203 .map_get("server_version")
204 .and_then(|v| v.as_str())
205 .map(str::to_owned)
206 .unwrap_or_default();
207 let protocol_version = value
208 .map_get("protocol_version")
209 .and_then(|v| v.as_int())
210 .unwrap_or(0);
211 let authenticated = value
212 .map_get("authenticated")
213 .and_then(|v| v.as_bool())
214 .unwrap_or(false);
215 let admin = value
216 .map_get("admin")
217 .and_then(|v| v.as_bool())
218 .unwrap_or(false);
219 let capabilities = value
220 .map_get("capabilities")
221 .and_then(|v| v.as_array())
222 .map(|arr| {
223 arr.iter()
224 .filter_map(|v| v.as_str().map(str::to_owned))
225 .collect()
226 })
227 .unwrap_or_default();
228 Self {
229 server_version,
230 protocol_version,
231 authenticated,
232 admin,
233 capabilities,
234 }
235 }
236}
237
238/// One connection to a Vectorizer RPC server.
239pub struct RpcClient {
240 /// `vectorizer://host:port`, kept for re-dialing with credentials.
241 endpoint: String,
242 /// Credentials + timeouts the current connection was dialed with.
243 client_config: Mutex<thunder::ClientConfig>,
244 /// The live multiplexed connection.
245 client: Mutex<Arc<thunder::Client>>,
246 /// Serializes re-dials so two concurrent HELLOs can't race a swap.
247 redial: tokio::sync::Mutex<()>,
248}
249
250impl RpcClient {
251 /// Convenience: parse a `vectorizer://host[:port]` URL and dial.
252 ///
253 /// Accepts every form documented at
254 /// [`crate::rpc::endpoint::parse_endpoint`]:
255 ///
256 /// - `vectorizer://host:port` → RPC on the given port.
257 /// - `vectorizer://host` → RPC on the default port 15503.
258 /// - `host:port` (no scheme) → RPC.
259 /// - `http(s)://...` → returns [`RpcClientError::Connection`] with a
260 /// clear message asking the caller to use the HTTP client
261 /// instead. The SDK ships the `http` Cargo feature for that
262 /// path; an `http://` URL is not a transport an RPC client can
263 /// speak.
264 pub async fn connect_url(url: &str) -> Result<Self> {
265 use super::endpoint::{Endpoint, parse_endpoint};
266 match parse_endpoint(url).map_err(|e| RpcClientError::Connection(e.to_string()))? {
267 Endpoint::Rpc { host, port } => Self::connect(format!("{host}:{port}")).await,
268 Endpoint::Rest { url } => Err(RpcClientError::Connection(format!(
269 "RpcClient cannot dial REST URL '{url}'; \
270 use the HTTP client (`vectorizer_sdk::VectorizerClient`) instead, \
271 or pass a `vectorizer://` URL"
272 ))),
273 }
274 }
275
276 /// Dial `addr` — `host:port`, or any form [`thunder::parse_endpoint`]
277 /// accepts. Does NOT authenticate: pass credentials to [`Self::hello`],
278 /// which re-dials with them in the handshake.
279 pub async fn connect(addr: impl AsRef<str>) -> Result<Self> {
280 let endpoint = addr.as_ref().to_owned();
281 let client_config = thunder::ClientConfig::new()
282 .client_name(concat!("vectorizer-sdk-rust/", env!("CARGO_PKG_VERSION")));
283 let client = Self::dial(&endpoint, client_config.clone()).await?;
284 Ok(Self {
285 endpoint,
286 client_config: Mutex::new(client_config),
287 client: Mutex::new(client),
288 redial: tokio::sync::Mutex::new(()),
289 })
290 }
291
292 /// Per-call and connect timeout for this connection. Re-dials so the
293 /// new timeouts apply to the live connection as well as later ones.
294 pub async fn with_timeout(&self, timeout: Duration) -> Result<()> {
295 let config = {
296 let current = self.client_config.lock().clone();
297 current.connect_timeout(timeout).call_timeout(timeout)
298 };
299 self.replace_connection(config).await
300 }
301
302 async fn dial(endpoint: &str, config: thunder::ClientConfig) -> Result<Arc<thunder::Client>> {
303 thunder::Client::connect_with(endpoint, protocol_config(), config)
304 .await
305 .map(Arc::new)
306 .map_err(RpcClientError::from)
307 }
308
309 /// Dial a fresh connection with `config` and swap it in, dropping the
310 /// previous one. Serialized by `redial` so concurrent callers can't
311 /// interleave swaps.
312 async fn replace_connection(&self, config: thunder::ClientConfig) -> Result<()> {
313 let _guard = self.redial.lock().await;
314 let fresh = Self::dial(&self.endpoint, config.clone()).await?;
315 *self.client_config.lock() = config;
316 *self.client.lock() = fresh;
317 Ok(())
318 }
319
320 fn client(&self) -> Arc<thunder::Client> {
321 Arc::clone(&self.client.lock())
322 }
323
324 /// Issue the `HELLO` handshake and return the server's capability list
325 /// and auth flags.
326 ///
327 /// When `payload` carries a token or an API key, the connection is
328 /// re-dialed so those credentials travel in Thunder's `AUTH` handshake —
329 /// that is what authenticates the session every later command runs under.
330 /// A credential-free payload reuses the existing connection.
331 pub async fn hello(&self, payload: HelloPayload) -> Result<HelloResponse> {
332 if let Some(credentials) = payload.credentials() {
333 let mut config = self.client_config.lock().clone();
334 config.credentials = Some(credentials);
335 if let Some(name) = &payload.client_name {
336 config = config.client_name(name.clone());
337 }
338 self.replace_connection(config).await?;
339 }
340 let result = self.call("HELLO", vec![payload.into_value()]).await?;
341 Ok(HelloResponse::parse(&result))
342 }
343
344 /// Health check. `PING` is auth-exempt, so this works before HELLO; the
345 /// typed wrapper still validates the response shape.
346 pub async fn ping(&self) -> Result<String> {
347 let result = self.call("PING", vec![]).await?;
348 result
349 .as_str()
350 .map(str::to_owned)
351 .ok_or_else(|| RpcClientError::Server("PING returned non-string payload".into()))
352 }
353
354 /// Generic call dispatcher. Most callers should use a typed
355 /// wrapper from [`crate::rpc::commands`] instead.
356 ///
357 /// Concurrent calls multiplex over the one connection; the server gates
358 /// un-authenticated sessions, surfacing
359 /// [`RpcClientError::NotAuthenticated`].
360 pub async fn call(
361 &self,
362 command: impl Into<String>,
363 args: Vec<VectorizerValue>,
364 ) -> Result<VectorizerValue> {
365 self.client()
366 .call(command.into(), args)
367 .await
368 .map_err(RpcClientError::from)
369 }
370
371 /// Returns `true` once the connection's handshake authenticated. Always
372 /// `false` against an open (single-user) server, which authenticates
373 /// nobody because it gates nothing.
374 pub fn is_authenticated(&self) -> bool {
375 self.client().is_authenticated()
376 }
377
378 /// Close the connection. In-flight calls receive
379 /// [`RpcClientError::Connection`].
380 pub async fn close(self) {
381 self.client().close().await;
382 }
383}