Skip to main content

heyo_sdk/
client.rs

1//! HTTP transport. Wraps `reqwest::Client` with bearer auth, timeouts, and
2//! HTTP-error translation matching `sdk-ts/src/client.ts`.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
8use reqwest::{Method, Response, StatusCode};
9use serde::de::DeserializeOwned;
10use serde::Serialize;
11use url::Url;
12
13use crate::errors::HeyoError;
14
15const DEFAULT_BASE_URL: &str = "https://server.heyo.computer";
16const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
17
18/// Default base URL for a local heyvm API (the `heyvmd` daemon's `--api-port`).
19/// Used by [`HeyoClient::local`] so desktop apps can drive a same-machine
20/// sandbox without the cloud in the data path.
21pub const DEFAULT_LOCAL_BASE_URL: &str = "http://127.0.0.1:34099";
22
23/// Construction options for [`HeyoClient`]. Mirrors `HeyoClientOptions` in
24/// `sdk-ts/src/client.ts`.
25#[derive(Debug, Default, Clone)]
26pub struct HeyoClientOptions {
27    /// Bearer token. Falls back to `HEYO_API_KEY` env var when `None`.
28    pub api_key: Option<String>,
29    /// Cloud base URL. Default: `https://server.heyo.computer`.
30    pub base_url: Option<String>,
31    /// Per-request timeout. Default: 60 seconds.
32    pub timeout: Option<Duration>,
33}
34
35/// Optional per-request knobs.
36#[derive(Debug, Default, Clone)]
37pub struct RequestOptions {
38    pub timeout: Option<Duration>,
39    /// Query string parameters appended verbatim.
40    pub query: Vec<(String, String)>,
41}
42
43#[derive(Clone)]
44pub struct HeyoClient {
45    inner: Arc<Inner>,
46}
47
48struct Inner {
49    /// Bearer token, or `None` for an unauthenticated (e.g. local) client.
50    api_key: Option<String>,
51    base_url: String,
52    http: reqwest::Client,
53    default_timeout: Duration,
54    /// Keeps the iroh P2P tunnel task alive for the client's lifetime when the
55    /// client was built via [`HeyoClient::connect_p2p`]. Aborted on Drop so the
56    /// tunnel tears down when the last clone of the client goes away.
57    _tunnel: Option<TunnelGuard>,
58}
59
60/// Owns the background task pumping the iroh tunnel. Dropping it aborts the
61/// task (and so closes the local TCP listener the requests were pointed at).
62struct TunnelGuard(tokio::task::JoinHandle<()>);
63
64impl Drop for TunnelGuard {
65    fn drop(&mut self) {
66        self.0.abort();
67    }
68}
69
70impl HeyoClient {
71    pub fn new(opts: HeyoClientOptions) -> Result<Self, HeyoError> {
72        // api_key is optional: a cloud client without one will simply get 401s,
73        // while a local client (custom base_url pointed at a heyvm daemon with
74        // no JWT_SECRET) needs no auth at all. We therefore no longer fail
75        // construction when the key is absent — auth is enforced server-side.
76        let api_key = opts
77            .api_key
78            .or_else(|| std::env::var("HEYO_API_KEY").ok())
79            .filter(|k| !k.is_empty());
80        let base_url = opts
81            .base_url
82            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
83            .trim_end_matches('/')
84            .to_string();
85        let http = reqwest::Client::builder()
86            .build()
87            .map_err(|e| HeyoError::Connection(e.to_string()))?;
88        Ok(Self {
89            inner: Arc::new(Inner {
90                api_key,
91                base_url,
92                http,
93                default_timeout: opts.timeout.unwrap_or(DEFAULT_TIMEOUT),
94                _tunnel: None,
95            }),
96        })
97    }
98
99    /// Build a client targeting a local heyvm API at [`DEFAULT_LOCAL_BASE_URL`]
100    /// (`http://127.0.0.1:34099`). No auth is sent — a same-machine daemon runs
101    /// without `JWT_SECRET` and skips authentication. Use [`HeyoClient::local_at`]
102    /// for a non-default address. The local API understands the same cloud HTTP
103    /// dialect via its compatibility routes, so the full SDK surface works.
104    pub fn local() -> Result<Self, HeyoError> {
105        Self::local_at(DEFAULT_LOCAL_BASE_URL)
106    }
107
108    /// Like [`HeyoClient::local`] but targets an explicit base URL — e.g. a port
109    /// some other component bound for you (an iroh tunnel, an SSH forward).
110    pub fn local_at(base_url: impl Into<String>) -> Result<Self, HeyoError> {
111        Self::new(HeyoClientOptions {
112            base_url: Some(base_url.into()),
113            api_key: None,
114            timeout: None,
115        })
116    }
117
118    /// Connect directly to a remote heyvm daemon over iroh P2P, bypassing the
119    /// cloud data path. `ticket` is the daemon's `connection_url` (fetch it from
120    /// the cloud via `GET /me/daemons/{id}/connection-ticket`); `relay` is an
121    /// optional iroh relay override for NAT traversal. A background task pumps
122    /// the tunnel for the client's lifetime and is torn down when the client
123    /// (and all its clones) drop.
124    ///
125    /// The client's `base_url` is set to the local TCP port the tunnel binds, so
126    /// every subsequent SDK call rides the P2P link. `api_key` is forwarded as
127    /// the bearer to the daemon (which may run with auth enabled); pass `None`
128    /// when the daemon is unauthenticated.
129    pub async fn connect_p2p(
130        ticket: &str,
131        relay: Option<&str>,
132        api_key: Option<String>,
133    ) -> Result<Self, HeyoError> {
134        let proxy = crate::proxy::Client::connect(ticket, 0, relay)
135            .await
136            .map_err(|e| HeyoError::Connection(format!("iroh P2P connect failed: {e}")))?;
137        let local = proxy
138            .local_addr()
139            .map_err(|e| HeyoError::Connection(format!("tunnel local_addr: {e}")))?;
140        let base_url = format!("http://{}", local);
141        let handle = tokio::spawn(async move {
142            // The tunnel runs until the listener errors or the task is aborted
143            // on Drop. A failure here just means later requests get a
144            // connection error, which surfaces to the caller naturally.
145            let _ = proxy.run().await;
146        });
147        let http = reqwest::Client::builder()
148            .build()
149            .map_err(|e| HeyoError::Connection(e.to_string()))?;
150        Ok(Self {
151            inner: Arc::new(Inner {
152                api_key: api_key.filter(|k| !k.is_empty()),
153                base_url,
154                http,
155                default_timeout: DEFAULT_TIMEOUT,
156                _tunnel: Some(TunnelGuard(handle)),
157            }),
158        })
159    }
160
161    pub fn base_url(&self) -> &str {
162        &self.inner.base_url
163    }
164
165    #[allow(dead_code)]
166    pub(crate) fn api_key(&self) -> Option<&str> {
167        self.inner.api_key.as_deref()
168    }
169
170    /// Issue a request and deserialize the JSON response.
171    pub async fn request<T: DeserializeOwned>(
172        &self,
173        method: Method,
174        path: &str,
175        body: Option<&(impl Serialize + ?Sized)>,
176        opts: RequestOptions,
177    ) -> Result<T, HeyoError> {
178        let bytes = self.request_bytes(method, path, body, opts).await?;
179        if bytes.is_empty() {
180            // Caller asked for T but the server returned no body. Try to
181            // deserialize "null" so types like `()` (via serde_json::Value)
182            // or Option<T> still work.
183            return serde_json::from_slice::<T>(b"null").map_err(|e| {
184                HeyoError::api(0, format!("empty response body could not be parsed: {}", e))
185            });
186        }
187        serde_json::from_slice::<T>(&bytes)
188            .map_err(|e| HeyoError::api(0, format!("invalid JSON response: {}", e)))
189    }
190
191    /// Like `request` but returns the raw response bytes (used by binary
192    /// endpoints like `/sqlite-databases/:id/file`).
193    pub async fn request_bytes(
194        &self,
195        method: Method,
196        path: &str,
197        body: Option<&(impl Serialize + ?Sized)>,
198        opts: RequestOptions,
199    ) -> Result<Vec<u8>, HeyoError> {
200        let response = self.raw_request(method, path, body, opts).await?;
201        self.consume_response(response, path).await
202    }
203
204    /// Issue the request and return the raw `reqwest::Response`. Use this
205    /// when you need response headers (e.g. checkout's
206    /// `X-Heyo-Data-Version`) or want to stream the body.
207    pub async fn raw_request(
208        &self,
209        method: Method,
210        path: &str,
211        body: Option<&(impl Serialize + ?Sized)>,
212        opts: RequestOptions,
213    ) -> Result<Response, HeyoError> {
214        let url = self.build_url(path, &opts.query)?;
215        let mut headers = HeaderMap::new();
216        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
217        if let Some(key) = &self.inner.api_key {
218            let auth = format!("Bearer {}", key);
219            headers.insert(
220                AUTHORIZATION,
221                HeaderValue::from_str(&auth)
222                    .map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
223            );
224        }
225        let mut builder = self
226            .inner
227            .http
228            .request(method, url)
229            .headers(headers)
230            .timeout(opts.timeout.unwrap_or(self.inner.default_timeout));
231        if let Some(body) = body {
232            builder = builder
233                .header(CONTENT_TYPE, "application/json")
234                .json(body);
235        }
236        builder
237            .send()
238            .await
239            .map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
240    }
241
242    /// POST raw bytes (Content-Type: application/octet-stream by default) and
243    /// return the raw response. Used by `Database::checkin`.
244    pub async fn put_bytes(
245        &self,
246        path: &str,
247        body: Vec<u8>,
248        content_type: &str,
249        opts: RequestOptions,
250    ) -> Result<Response, HeyoError> {
251        let url = self.build_url(path, &opts.query)?;
252        let mut headers = HeaderMap::new();
253        if let Some(key) = &self.inner.api_key {
254            let auth = format!("Bearer {}", key);
255            headers.insert(
256                AUTHORIZATION,
257                HeaderValue::from_str(&auth)
258                    .map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
259            );
260        }
261        headers.insert(
262            CONTENT_TYPE,
263            HeaderValue::from_str(content_type)
264                .map_err(|e| HeyoError::api(0, format!("invalid content-type: {}", e)))?,
265        );
266        self.inner
267            .http
268            .request(Method::PUT, url)
269            .headers(headers)
270            .timeout(opts.timeout.unwrap_or(self.inner.default_timeout))
271            .body(body)
272            .send()
273            .await
274            .map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
275    }
276
277    /// Build the WS URL for a given path. Scheme is swapped (`http`→`ws`,
278    /// `https`→`wss`).
279    pub(crate) fn ws_url(&self, path: &str) -> Result<String, HeyoError> {
280        let http_url = self.build_url(path, &[])?;
281        let mut parsed = Url::parse(&http_url)
282            .map_err(|e| HeyoError::Connection(format!("bad URL {}: {}", http_url, e)))?;
283        let scheme = match parsed.scheme() {
284            "https" => "wss",
285            "http" => "ws",
286            other => return Err(HeyoError::Connection(format!("unsupported scheme {}", other))),
287        };
288        parsed
289            .set_scheme(scheme)
290            .map_err(|_| HeyoError::Connection("could not swap to ws scheme".into()))?;
291        Ok(parsed.to_string())
292    }
293
294    pub(crate) fn ws_authorization(&self) -> String {
295        // Empty when unauthenticated (local daemon with no JWT_SECRET ignores
296        // the header). Cloud clients always carry a key.
297        match &self.inner.api_key {
298            Some(key) => format!("Bearer {}", key),
299            None => String::new(),
300        }
301    }
302
303    fn build_url(&self, path: &str, query: &[(String, String)]) -> Result<String, HeyoError> {
304        let clean = if path.starts_with('/') {
305            path.to_string()
306        } else {
307            format!("/{}", path)
308        };
309        let mut url = Url::parse(&format!("{}{}", self.inner.base_url, clean))
310            .map_err(|e| HeyoError::api(0, format!("bad URL {}{}: {}", self.inner.base_url, clean, e)))?;
311        if !query.is_empty() {
312            let mut pairs = url.query_pairs_mut();
313            for (k, v) in query {
314                pairs.append_pair(k, v);
315            }
316        }
317        Ok(url.to_string())
318    }
319
320    async fn consume_response(
321        &self,
322        response: Response,
323        path: &str,
324    ) -> Result<Vec<u8>, HeyoError> {
325        let status = response.status();
326        let bytes = response
327            .bytes()
328            .await
329            .map_err(|e| HeyoError::api(0, format!("read body for {}: {}", path, e)))?;
330        if status.is_success() {
331            if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
332                return Ok(Vec::new());
333            }
334            return Ok(bytes.to_vec());
335        }
336
337        let mut message = format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
338        let mut parsed_body: Option<serde_json::Value> = None;
339        if !bytes.is_empty() {
340            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
341                if let Some(m) = v.get("message").and_then(|x| x.as_str()) {
342                    message = m.to_string();
343                } else if let Some(e) = v.get("error").and_then(|x| x.as_str()) {
344                    message = e.to_string();
345                }
346                parsed_body = Some(v);
347            } else if let Ok(text) = std::str::from_utf8(&bytes) {
348                message = text.to_string();
349            }
350        }
351
352        let with_path = format!("{} (calling {})", message, path);
353        Err(match status {
354            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => HeyoError::Authentication,
355            StatusCode::NOT_FOUND => HeyoError::NotFound(with_path),
356            StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
357                HeyoError::InvalidArgument(with_path)
358            }
359            _ => HeyoError::api_with_body(status.as_u16(), with_path, parsed_body),
360        })
361    }
362}
363
364impl std::fmt::Debug for HeyoClient {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        f.debug_struct("HeyoClient")
367            .field("base_url", &self.inner.base_url)
368            .field("default_timeout", &self.inner.default_timeout)
369            .finish_non_exhaustive()
370    }
371}