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