Skip to main content

haystack_client/
client.rs

1use std::collections::HashSet;
2
3use crate::error::ClientError;
4use crate::transport::Transport;
5use crate::transport::http::HttpTransport;
6use crate::transport::ws::WsTransport;
7use haystack_core::data::{HCol, HDict, HGrid};
8use haystack_core::kinds::{HRef, Kind, Number};
9
10/// A client for communicating with a Haystack HTTP API server.
11///
12/// Provides typed methods for all standard Haystack ops (about, read, hisRead,
13/// etc.) as well as a generic `call` method for custom ops.
14///
15/// Supports both HTTP and WebSocket transports.
16pub struct HaystackClient<T: Transport> {
17    transport: T,
18}
19
20impl HaystackClient<HttpTransport> {
21    /// Connect to a Haystack server via HTTP, performing SCRAM authentication.
22    ///
23    /// # Arguments
24    /// * `url` - The server API root (e.g. `http://localhost:8080/api`)
25    /// * `username` - The username to authenticate as
26    /// * `password` - The user's plaintext password
27    pub async fn connect(url: &str, username: &str, password: &str) -> Result<Self, ClientError> {
28        let client = reqwest::Client::new();
29        let auth_token = crate::auth::authenticate(&client, url, username, password).await?;
30        let transport = HttpTransport::new(url, auth_token);
31        Ok(Self { transport })
32    }
33
34    /// Connect to a Haystack server via HTTP with mutual TLS (mTLS) client
35    /// certificate authentication, then perform SCRAM authentication.
36    ///
37    /// Builds a custom `reqwest::Client` configured with the provided TLS
38    /// identity (client certificate + key) and optional CA certificate, then
39    /// runs the standard SCRAM handshake over that client.
40    ///
41    /// # Arguments
42    /// * `url` - The server API root (e.g. `https://localhost:8443/api`)
43    /// * `username` - The username to authenticate as
44    /// * `password` - The user's plaintext password
45    /// * `tls` - The mTLS configuration (cert, key, optional CA)
46    pub async fn connect_with_tls(
47        url: &str,
48        username: &str,
49        password: &str,
50        tls: &crate::tls::TlsConfig,
51    ) -> Result<Self, ClientError> {
52        // Combine cert + key into a single PEM buffer for reqwest::Identity
53        let mut combined_pem = tls.client_cert_pem.clone();
54        combined_pem.extend_from_slice(&tls.client_key_pem);
55
56        let identity = reqwest::Identity::from_pem(&combined_pem)
57            .map_err(|e| ClientError::Connection(format!("invalid client certificate: {e}")))?;
58
59        let mut builder = reqwest::Client::builder().identity(identity);
60
61        if let Some(ref ca) = tls.ca_cert_pem {
62            let cert = reqwest::Certificate::from_pem(ca)
63                .map_err(|e| ClientError::Connection(format!("invalid CA certificate: {e}")))?;
64            builder = builder.add_root_certificate(cert);
65        }
66
67        let client = builder
68            .build()
69            .map_err(|e| ClientError::Connection(format!("TLS client build failed: {e}")))?;
70
71        let auth_token = crate::auth::authenticate(&client, url, username, password).await?;
72        let transport = HttpTransport::new(url, auth_token);
73        Ok(Self { transport })
74    }
75}
76
77impl HaystackClient<WsTransport> {
78    /// Connect to a Haystack server via WebSocket.
79    ///
80    /// Performs SCRAM authentication over HTTP first to obtain an auth token,
81    /// then establishes a WebSocket connection using that token.
82    ///
83    /// # Arguments
84    /// * `url` - The server API root for HTTP auth (e.g. `http://localhost:8080/api`)
85    /// * `ws_url` - The WebSocket URL (e.g. `ws://localhost:8080/api/ws`)
86    /// * `username` - The username to authenticate as
87    /// * `password` - The user's plaintext password
88    pub async fn connect_ws(
89        url: &str,
90        ws_url: &str,
91        username: &str,
92        password: &str,
93    ) -> Result<Self, ClientError> {
94        // Authenticate via HTTP first to get the token
95        let client = reqwest::Client::new();
96        let auth_token = crate::auth::authenticate(&client, url, username, password).await?;
97
98        // Connect WebSocket with the token
99        let transport = WsTransport::connect(ws_url, &auth_token).await?;
100        Ok(Self { transport })
101    }
102}
103
104impl<T: Transport> HaystackClient<T> {
105    /// Create a client with an already-configured transport.
106    pub fn from_transport(transport: T) -> Self {
107        Self { transport }
108    }
109
110    /// Call a raw Haystack op with a request grid.
111    pub async fn call(&self, op: &str, req: &HGrid) -> Result<HGrid, ClientError> {
112        self.transport.call(op, req).await
113    }
114
115    // -----------------------------------------------------------------------
116    // Standard ops
117    // -----------------------------------------------------------------------
118
119    /// Call the `about` op. Returns server information.
120    pub async fn about(&self) -> Result<HGrid, ClientError> {
121        self.call("about", &HGrid::new()).await
122    }
123
124    /// Call the `ops` op. Returns the list of operations supported by the server.
125    pub async fn ops(&self) -> Result<HGrid, ClientError> {
126        self.call("ops", &HGrid::new()).await
127    }
128
129    /// Call the `formats` op. Returns the list of MIME formats supported by the server.
130    pub async fn formats(&self) -> Result<HGrid, ClientError> {
131        self.call("formats", &HGrid::new()).await
132    }
133
134    /// Call the `libs` op. Returns the library modules installed on the server.
135    pub async fn libs(&self) -> Result<HGrid, ClientError> {
136        self.call("libs", &HGrid::new()).await
137    }
138
139    /// Call the `read` op with a filter expression and optional limit.
140    pub async fn read(&self, filter: &str, limit: Option<usize>) -> Result<HGrid, ClientError> {
141        let mut row = HDict::new();
142        row.set("filter", Kind::Str(filter.to_string()));
143        if let Some(lim) = limit {
144            row.set("limit", Kind::Number(Number::unitless(lim as f64)));
145        }
146        let cols = if limit.is_some() {
147            vec![HCol::new("filter"), HCol::new("limit")]
148        } else {
149            vec![HCol::new("filter")]
150        };
151        let grid = HGrid::from_parts(HDict::new(), cols, vec![row]);
152        self.call("read", &grid).await
153    }
154
155    /// Call the `read` op with a list of entity ids.
156    pub async fn read_by_ids(&self, ids: &[&str]) -> Result<HGrid, ClientError> {
157        let rows: Vec<HDict> = ids
158            .iter()
159            .map(|id| {
160                let mut d = HDict::new();
161                d.set("id", Kind::Ref(HRef::from_val(*id)));
162                d
163            })
164            .collect();
165        let grid = HGrid::from_parts(HDict::new(), vec![HCol::new("id")], rows);
166        self.call("read", &grid).await
167    }
168
169    /// Call the `nav` op. If `nav_id` is `None`, returns the root navigation tree.
170    pub async fn nav(&self, nav_id: Option<&str>) -> Result<HGrid, ClientError> {
171        let mut row = HDict::new();
172        if let Some(id) = nav_id {
173            row.set("navId", Kind::Str(id.to_string()));
174        }
175        let grid = HGrid::from_parts(HDict::new(), vec![HCol::new("navId")], vec![row]);
176        self.call("nav", &grid).await
177    }
178
179    /// Call the `defs` op with an optional filter.
180    pub async fn defs(&self, filter: Option<&str>) -> Result<HGrid, ClientError> {
181        let mut row = HDict::new();
182        if let Some(f) = filter {
183            row.set("filter", Kind::Str(f.to_string()));
184        }
185        let grid = HGrid::from_parts(HDict::new(), vec![HCol::new("filter")], vec![row]);
186        self.call("defs", &grid).await
187    }
188
189    /// Call the `watchSub` op to subscribe to a set of entity ids.
190    ///
191    /// `lease` is an optional lease duration (e.g. `"1min"`).
192    pub async fn watch_sub(&self, ids: &[&str], lease: Option<&str>) -> Result<HGrid, ClientError> {
193        let rows: Vec<HDict> = ids
194            .iter()
195            .map(|id| {
196                let mut d = HDict::new();
197                d.set("id", Kind::Ref(HRef::from_val(*id)));
198                d
199            })
200            .collect();
201        let mut meta = HDict::new();
202        if let Some(l) = lease {
203            meta.set("lease", Kind::Str(l.to_string()));
204        }
205        let grid = HGrid::from_parts(meta, vec![HCol::new("id")], rows);
206        self.call("watchSub", &grid).await
207    }
208
209    /// Call the `watchPoll` op to poll a watch for changes.
210    pub async fn watch_poll(&self, watch_id: &str) -> Result<HGrid, ClientError> {
211        let mut meta = HDict::new();
212        meta.set("watchId", Kind::Str(watch_id.to_string()));
213        let grid = HGrid::from_parts(meta, vec![], vec![]);
214        self.call("watchPoll", &grid).await
215    }
216
217    /// Call the `watchUnsub` op to unsubscribe from a watch.
218    pub async fn watch_unsub(&self, watch_id: &str, ids: &[&str]) -> Result<HGrid, ClientError> {
219        let rows: Vec<HDict> = ids
220            .iter()
221            .map(|id| {
222                let mut d = HDict::new();
223                d.set("id", Kind::Ref(HRef::from_val(*id)));
224                d
225            })
226            .collect();
227        let mut meta = HDict::new();
228        meta.set("watchId", Kind::Str(watch_id.to_string()));
229        let grid = HGrid::from_parts(meta, vec![HCol::new("id")], rows);
230        self.call("watchUnsub", &grid).await
231    }
232
233    /// Call the `pointWrite` op to write a value to a writable point.
234    pub async fn point_write(&self, id: &str, level: u8, val: Kind) -> Result<HGrid, ClientError> {
235        let mut row = HDict::new();
236        row.set("id", Kind::Ref(HRef::from_val(id)));
237        row.set("level", Kind::Number(Number::unitless(level as f64)));
238        row.set("val", val);
239        let grid = HGrid::from_parts(
240            HDict::new(),
241            vec![HCol::new("id"), HCol::new("level"), HCol::new("val")],
242            vec![row],
243        );
244        self.call("pointWrite", &grid).await
245    }
246
247    /// Call the `hisRead` op to read historical data for a point.
248    ///
249    /// `range` is a Haystack date range string (e.g. `"today"`, `"yesterday"`,
250    /// `"2024-01-01,2024-01-31"`).
251    pub async fn his_read(&self, id: &str, range: &str) -> Result<HGrid, ClientError> {
252        let mut row = HDict::new();
253        row.set("id", Kind::Ref(HRef::from_val(id)));
254        row.set("range", Kind::Str(range.to_string()));
255        let grid = HGrid::from_parts(
256            HDict::new(),
257            vec![HCol::new("id"), HCol::new("range")],
258            vec![row],
259        );
260        self.call("hisRead", &grid).await
261    }
262
263    /// Call the `hisWrite` op to write historical data for a point.
264    ///
265    /// `items` should be dicts with `ts` and `val` tags.
266    pub async fn his_write(&self, id: &str, items: Vec<HDict>) -> Result<HGrid, ClientError> {
267        let mut meta = HDict::new();
268        meta.set("id", Kind::Ref(HRef::from_val(id)));
269        let grid = HGrid::from_parts(meta, vec![HCol::new("ts"), HCol::new("val")], items);
270        self.call("hisWrite", &grid).await
271    }
272
273    /// Call the `invokeAction` op to invoke an action on an entity.
274    pub async fn invoke_action(
275        &self,
276        id: &str,
277        action: &str,
278        args: HDict,
279    ) -> Result<HGrid, ClientError> {
280        let mut row = args;
281        row.set("id", Kind::Ref(HRef::from_val(id)));
282        row.set("action", Kind::Str(action.to_string()));
283        let grid = HGrid::from_parts(
284            HDict::new(),
285            vec![HCol::new("id"), HCol::new("action")],
286            vec![row],
287        );
288        self.call("invokeAction", &grid).await
289    }
290
291    /// Call the `close` op to close the current server session.
292    ///
293    /// This is distinct from [`close`](Self::close) which shuts down the
294    /// underlying transport connection.
295    pub async fn close_session(&self) -> Result<HGrid, ClientError> {
296        self.call("close", &HGrid::new()).await
297    }
298
299    // -----------------------------------------------------------------------
300    // Library & spec management ops
301    // -----------------------------------------------------------------------
302
303    /// List all specs, optionally filtered by library name.
304    pub async fn specs(&self, lib: Option<&str>) -> Result<HGrid, ClientError> {
305        let mut row = HDict::new();
306        if let Some(lib_name) = lib {
307            row.set("lib", Kind::Str(lib_name.to_string()));
308        }
309        let grid = HGrid::from_parts(HDict::new(), vec![HCol::new("lib")], vec![row]);
310        self.call("specs", &grid).await
311    }
312
313    /// Get a single spec by qualified name.
314    pub async fn spec(&self, qname: &str) -> Result<HGrid, ClientError> {
315        let mut row = HDict::new();
316        row.set("qname", Kind::Str(qname.to_string()));
317        let grid = HGrid::from_parts(HDict::new(), vec![HCol::new("qname")], vec![row]);
318        self.call("spec", &grid).await
319    }
320
321    /// Load a Xeto library from source text.
322    pub async fn load_lib(&self, name: &str, source: &str) -> Result<HGrid, ClientError> {
323        let mut row = HDict::new();
324        row.set("name", Kind::Str(name.to_string()));
325        row.set("source", Kind::Str(source.to_string()));
326        let grid = HGrid::from_parts(
327            HDict::new(),
328            vec![HCol::new("name"), HCol::new("source")],
329            vec![row],
330        );
331        self.call("loadLib", &grid).await
332    }
333
334    /// Unload a library by name.
335    pub async fn unload_lib(&self, name: &str) -> Result<HGrid, ClientError> {
336        let mut row = HDict::new();
337        row.set("name", Kind::Str(name.to_string()));
338        let grid = HGrid::from_parts(HDict::new(), vec![HCol::new("name")], vec![row]);
339        self.call("unloadLib", &grid).await
340    }
341
342    /// Export a library to Xeto source text.
343    pub async fn export_lib(&self, name: &str) -> Result<HGrid, ClientError> {
344        let mut row = HDict::new();
345        row.set("name", Kind::Str(name.to_string()));
346        let grid = HGrid::from_parts(HDict::new(), vec![HCol::new("name")], vec![row]);
347        self.call("exportLib", &grid).await
348    }
349
350    /// Validate entities against the server's ontology.
351    pub async fn validate(&self, entities: Vec<HDict>) -> Result<HGrid, ClientError> {
352        // Build column set from all entities
353        let mut col_names: Vec<String> = Vec::new();
354        let mut seen = HashSet::new();
355        for entity in &entities {
356            for name in entity.tag_names() {
357                if seen.insert(name.to_string()) {
358                    col_names.push(name.to_string());
359                }
360            }
361        }
362        col_names.sort();
363        let cols: Vec<HCol> = col_names.iter().map(|n| HCol::new(n.as_str())).collect();
364        let grid = HGrid::from_parts(HDict::new(), cols, entities);
365        self.call("validate", &grid).await
366    }
367
368    /// Close the transport connection.
369    pub async fn close(&self) -> Result<(), ClientError> {
370        self.transport.close().await
371    }
372}