Skip to main content

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}