Skip to main content

silicon_dm_client/
lib.rs

1//! Stateless Silicon DM client. Callers own credentials, retry policy and durable cursors.
2//! No IAM application secrets, local files or backend-internal operations are required.
3pub mod models;
4pub mod relay;
5#[cfg(feature = "runtime")]
6pub mod runtime;
7pub use models::*;
8
9use reqwest::{Client as HttpClient, Method, RequestBuilder};
10use serde::{Serialize, de::DeserializeOwned};
11use serde_json::{Value, json};
12use std::time::Duration;
13use tokio::net::TcpStream;
14use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, tungstenite::client::IntoClientRequest};
15use url::Url;
16use uuid::Uuid;
17
18pub type Result<T> = std::result::Result<T, Error>;
19pub type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;
20
21/// HTTP failures preserve the response body, including current-draft conflict data.
22#[derive(Debug, thiserror::Error)]
23pub enum Error {
24    #[error("invalid client configuration: {0}")]
25    Configuration(String),
26    #[error("DM transport failed: {0}")]
27    Transport(#[from] reqwest::Error),
28    #[error("DM returned HTTP {status}: {code}: {message}")]
29    Api {
30        status: u16,
31        code: String,
32        message: String,
33        body: Box<Value>,
34        request_id: Option<String>,
35        retry_after: Option<String>,
36    },
37    #[error("invalid DM response: {0}")]
38    Decode(#[from] serde_json::Error),
39    #[error("WebSocket connection failed: {0}")]
40    WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
41}
42impl Error {
43    /// Retrying a mutation requires reusing its original idempotency key.
44    pub fn retryable(&self) -> bool {
45        matches!(self, Self::Transport(_) | Self::WebSocket(_))
46            || matches!(
47                self,
48                Self::Api {
49                    status: 408 | 429 | 500..=599,
50                    ..
51                }
52            )
53    }
54    pub fn unauthorized(&self) -> bool {
55        matches!(self, Self::Api { status: 401, .. })
56    }
57}
58
59/// Immutable connection configuration. Debug intentionally omits bearer/test secrets.
60#[derive(Clone)]
61pub struct Client {
62    http: HttpClient,
63    base: Url,
64    token: Option<String>,
65    organization: Option<String>,
66    test_key: Option<String>,
67    websocket_limit: usize,
68    testing_generation: Option<i64>,
69}
70impl Client {
71    /// Accepts an origin or an `/api/v1` base. HTTP is limited to loopback hosts.
72    pub fn new(base: impl AsRef<str>) -> Result<Self> {
73        let mut base =
74            Url::parse(base.as_ref()).map_err(|e| Error::Configuration(e.to_string()))?;
75        validate_endpoint(&base)?;
76        if base.query().is_some() || base.fragment().is_some() {
77            return Err(Error::Configuration(
78                "base URL must not have a query or fragment".into(),
79            ));
80        }
81        let path = base.path().trim_end_matches('/');
82        let path = if path.is_empty() {
83            "/api/v1/".to_owned()
84        } else {
85            format!("{path}/")
86        };
87        base.set_path(&path);
88        let http = HttpClient::builder()
89            .redirect(reqwest::redirect::Policy::none())
90            .timeout(Duration::from_secs(45))
91            .user_agent(concat!("silicon-dm-client/", env!("CARGO_PKG_VERSION")))
92            .build()?;
93        Ok(Self {
94            http,
95            base,
96            token: None,
97            organization: None,
98            test_key: None,
99            websocket_limit: 128 * 1024 * 1024,
100            testing_generation: None,
101        })
102    }
103    pub fn with_auth(mut self, token: impl Into<String>, organization: impl Into<String>) -> Self {
104        self.token = Some(token.into());
105        self.organization = Some(organization.into());
106        self
107    }
108    /// Mandatory IAM sandbox selection remains the backend's responsibility.
109    pub fn with_test_key(mut self, key: impl Into<String>) -> Result<Self> {
110        let key = key.into();
111        if key.len() != 32 || !key.bytes().all(|b| b.is_ascii_alphanumeric()) {
112            return Err(Error::Configuration(
113                "testing environment key must be 32 alphanumeric characters".into(),
114            ));
115        }
116        self.test_key = Some(key);
117        Ok(self)
118    }
119    pub fn without_test(mut self) -> Self {
120        self.test_key = None;
121        self.testing_generation = None;
122        self
123    }
124    /// Binds requests to a previously observed sandbox generation. A cleaned or
125    /// rotated environment rejects stale requests rather than applying them anew.
126    pub fn with_testing_generation(mut self, generation: i64) -> Result<Self> {
127        if generation < 1 {
128            return Err(Error::Configuration(
129                "testing generation must be positive".into(),
130            ));
131        }
132        self.testing_generation = Some(generation);
133        Ok(self)
134    }
135    /// Sets a bounded encoded WebSocket message/frame limit, up to 3 GiB.
136    /// The default 128 MiB accommodates DM's default API body ceiling.
137    pub fn with_websocket_limit(mut self, max_bytes: usize) -> Result<Self> {
138        if max_bytes == 0 || max_bytes as u64 > 3 * 1024 * 1024 * 1024 {
139            return Err(Error::Configuration(
140                "WebSocket limit must be between 1 byte and 3 GiB".into(),
141            ));
142        }
143        self.websocket_limit = max_bytes;
144        Ok(self)
145    }
146    fn endpoint(&self, path: &str) -> Result<Url> {
147        self.base
148            .join(path)
149            .map_err(|e| Error::Configuration(e.to_string()))
150    }
151    fn request(&self, method: Method, path: &str) -> Result<RequestBuilder> {
152        let mut request = self.http.request(method, self.endpoint(path)?);
153        if let Some(token) = &self.token {
154            request = request.bearer_auth(token);
155        }
156        if let Some(org) = &self.organization {
157            request = request.header("X-Org-ID", org);
158        }
159        if let Some(key) = &self.test_key {
160            request = request.header("X-Testing-Environment-Key", key);
161        }
162        if let Some(generation) = self.testing_generation {
163            request = request.header("X-Testing-Environment-Generation", generation);
164        }
165        Ok(request)
166    }
167    async fn json<T: DeserializeOwned>(&self, request: RequestBuilder) -> Result<T> {
168        let response = checked(request.send().await?).await?;
169        Ok(serde_json::from_slice(&response.bytes().await?)?)
170    }
171    async fn empty(&self, request: RequestBuilder) -> Result<()> {
172        checked(request.send().await?).await?;
173        Ok(())
174    }
175    pub async fn login(&self, slt: &str, key: &str) -> Result<Tokens> {
176        self.json(
177            self.request(Method::POST, "auth/login")?
178                .header("Idempotency-Key", key)
179                .json(&json!({"slt": slt})),
180        )
181        .await
182    }
183    pub async fn refresh(&self, refresh_token: &str, key: &str) -> Result<Tokens> {
184        self.json(
185            self.request(Method::POST, "auth/refresh")?
186                .header("Idempotency-Key", key)
187                .json(&json!({"refresh_token": refresh_token})),
188        )
189        .await
190    }
191    pub async fn logout(&self, token: &str, key: &str) -> Result<()> {
192        self.empty(
193            self.request(Method::POST, "auth/logout")?
194                .header("Idempotency-Key", key)
195                .json(&json!({"token":token})),
196        )
197        .await
198    }
199    /// Public IAM application information; does not require login.
200    pub async fn iam(&self) -> Result<IamInfo> {
201        self.json(self.request(Method::GET, "iam")?).await
202    }
203    pub async fn me(&self) -> Result<Identity> {
204        self.json(self.request(Method::GET, "auth/me")?).await
205    }
206    pub async fn conversations(&self, page: &PageRequest) -> Result<Page<Conversation>> {
207        self.json(self.request(Method::GET, "conversations")?.query(page))
208            .await
209    }
210    pub async fn create_conversation(
211        &self,
212        participants: &[String],
213        key: &str,
214    ) -> Result<Conversation> {
215        self.json(
216            self.request(Method::POST, "conversations")?
217                .header("Idempotency-Key", key)
218                .json(&json!({"participant_ids":participants})),
219        )
220        .await
221    }
222    pub async fn messages(
223        &self,
224        conversation: Uuid,
225        page: &PageRequest,
226        include_bundled: bool,
227    ) -> Result<Page<Message>> {
228        self.json(
229            self.request(
230                Method::GET,
231                &format!("conversations/{conversation}/messages"),
232            )?
233            .query(page)
234            .query(&[("include_bundled_members", include_bundled)]),
235        )
236        .await
237    }
238    pub async fn send_message(
239        &self,
240        conversation: Uuid,
241        message: &MessageCreate,
242        key: &str,
243    ) -> Result<Message> {
244        self.json(
245            self.request(
246                Method::POST,
247                &format!("conversations/{conversation}/messages"),
248            )?
249            .header("Idempotency-Key", key)
250            .json(message),
251        )
252        .await
253    }
254    pub async fn message(&self, conversation: Uuid, message: Uuid) -> Result<Message> {
255        self.json(self.request(
256            Method::GET,
257            &format!("conversations/{conversation}/messages/{message}"),
258        )?)
259        .await
260    }
261    pub async fn edit_message(
262        &self,
263        conversation: Uuid,
264        message: Uuid,
265        content: &MessageCreate,
266        version: i64,
267        key: &str,
268    ) -> Result<Message> {
269        self.json(
270            self.request(
271                Method::PATCH,
272                &format!("conversations/{conversation}/messages/{message}"),
273            )?
274            .header("If-Match", version)
275            .header("Idempotency-Key", key)
276            .json(content),
277        )
278        .await
279    }
280    pub async fn delete_message(
281        &self,
282        conversation: Uuid,
283        message: Uuid,
284        version: i64,
285        key: &str,
286    ) -> Result<Message> {
287        self.json(
288            self.request(
289                Method::DELETE,
290                &format!("conversations/{conversation}/messages/{message}"),
291            )?
292            .header("If-Match", version)
293            .header("Idempotency-Key", key),
294        )
295        .await
296    }
297    pub async fn record_receipt(
298        &self,
299        conversation: Uuid,
300        message: Uuid,
301        status: ReceiptStatus,
302        device: &str,
303    ) -> Result<Message> {
304        self.json(
305            self.request(
306                Method::POST,
307                &format!("conversations/{conversation}/messages/{message}/receipts"),
308            )?
309            .json(&json!({"status":status,"device_id":device})),
310        )
311        .await
312    }
313    pub async fn draft(&self, conversation: Uuid) -> Result<Draft> {
314        self.json(self.request(Method::GET, &format!("conversations/{conversation}/draft"))?)
315            .await
316    }
317    pub async fn put_draft(
318        &self,
319        conversation: Uuid,
320        draft: &DraftInput,
321        version: i64,
322    ) -> Result<Draft> {
323        self.json(
324            self.request(Method::PUT, &format!("conversations/{conversation}/draft"))?
325                .header("If-Match", version)
326                .json(draft),
327        )
328        .await
329    }
330    pub async fn delete_draft(&self, conversation: Uuid) -> Result<()> {
331        self.empty(self.request(
332            Method::DELETE,
333            &format!("conversations/{conversation}/draft"),
334        )?)
335        .await
336    }
337    pub async fn create_bundle(
338        &self,
339        conversation: Uuid,
340        bundle: &BundleCreate,
341        key: &str,
342    ) -> Result<Bundle> {
343        self.json(
344            self.request(
345                Method::POST,
346                &format!("conversations/{conversation}/bundles"),
347            )?
348            .header("Idempotency-Key", key)
349            .json(bundle),
350        )
351        .await
352    }
353    pub async fn bundle(&self, conversation: Uuid, bundle: Uuid) -> Result<Bundle> {
354        self.json(self.request(
355            Method::GET,
356            &format!("conversations/{conversation}/bundles/{bundle}"),
357        )?)
358        .await
359    }
360    pub async fn presence(&self, actor: &str) -> Result<Presence> {
361        let encoded: String = url::form_urlencoded::byte_serialize(actor.as_bytes()).collect();
362        self.json(self.request(Method::GET, &format!("presence/{encoded}"))?)
363            .await
364    }
365    pub async fn gifs(&self, kind: GifList<'_>) -> Result<Page<Gif>> {
366        let req = match kind {
367            GifList::Trending => self.request(Method::GET, "gifs/trending")?,
368            GifList::Recent => self.request(Method::GET, "gifs/recent")?,
369            GifList::Search(q) => self.request(Method::GET, "gifs/search")?.query(&[("q", q)]),
370        };
371        self.json(req).await
372    }
373    pub async fn create_test_environment(
374        &self,
375        input: &TestEnvironmentCreate,
376        key: &str,
377    ) -> Result<TestEnvironment> {
378        self.json(
379            self.request(Method::POST, "testing-environments")?
380                .header("Idempotency-Key", key)
381                .json(input),
382        )
383        .await
384    }
385    pub async fn test_environments(&self, include_deleted: bool) -> Result<Page<TestEnvironment>> {
386        self.json(
387            self.request(Method::GET, "testing-environments")?
388                .query(&[("include_deleted", include_deleted)]),
389        )
390        .await
391    }
392    pub async fn test_environment(&self, id: Uuid) -> Result<TestEnvironment> {
393        self.json(self.request(Method::GET, &format!("testing-environments/{id}"))?)
394            .await
395    }
396    pub async fn update_test_environment(
397        &self,
398        id: Uuid,
399        input: &TestEnvironmentUpdate,
400        key: &str,
401    ) -> Result<TestEnvironment> {
402        self.json(
403            self.request(Method::PATCH, &format!("testing-environments/{id}"))?
404                .header("Idempotency-Key", key)
405                .json(input),
406        )
407        .await
408    }
409    pub async fn test_environment_key(&self, id: Uuid) -> Result<TestEnvironmentKey> {
410        self.json(self.request(Method::GET, &format!("testing-environments/{id}/key"))?)
411            .await
412    }
413    pub async fn rotate_test_environment_key(
414        &self,
415        id: Uuid,
416        key: &str,
417    ) -> Result<TestEnvironmentKey> {
418        self.json(
419            self.request(
420                Method::POST,
421                &format!("testing-environments/{id}/rotate-key"),
422            )?
423            .header("Idempotency-Key", key),
424        )
425        .await
426    }
427    pub async fn restore_test_environment(&self, id: Uuid, key: &str) -> Result<TestEnvironment> {
428        self.json(
429            self.request(Method::POST, &format!("testing-environments/{id}/restore"))?
430                .header("Idempotency-Key", key),
431        )
432        .await
433    }
434    pub async fn clean_test_environment(&self, id: Uuid, key: &str) -> Result<()> {
435        self.empty(
436            self.request(Method::POST, &format!("testing-environments/{id}/clean"))?
437                .header("Idempotency-Key", key),
438        )
439        .await
440    }
441    pub async fn delete_test_environment(&self, id: Uuid, key: &str) -> Result<()> {
442        self.empty(
443            self.request(Method::DELETE, &format!("testing-environments/{id}"))?
444                .header("Idempotency-Key", key),
445        )
446        .await
447    }
448    /// Connects without reading/writing a cursor or automatically acknowledging messages.
449    pub async fn connect(&self, actors: &[String], device_id: &str) -> Result<Socket> {
450        self.connect_with_generation(actors, device_id, None).await
451    }
452    /// Supply the last observed sandbox generation; changed generations reset local cursors.
453    pub async fn connect_with_generation(
454        &self,
455        actors: &[String],
456        device_id: &str,
457        testing_generation: Option<i64>,
458    ) -> Result<Socket> {
459        let mut url = self.endpoint("ws")?;
460        url.set_scheme(if self.base.scheme() == "https" {
461            "wss"
462        } else {
463            "ws"
464        })
465        .map_err(|()| Error::Configuration("invalid WebSocket scheme".into()))?;
466        {
467            let mut query = url.query_pairs_mut();
468            query
469                .append_pair(
470                    "org_id",
471                    self.organization.as_deref().ok_or_else(|| {
472                        Error::Configuration("organization is required for WebSocket".into())
473                    })?,
474                )
475                .append_pair("device_id", device_id);
476            for actor in actors {
477                query.append_pair("actors", actor);
478            }
479        }
480        if let Some(generation) = testing_generation {
481            url.query_pairs_mut()
482                .append_pair("testing_generation", &generation.to_string());
483        }
484        let mut req = url.as_str().into_client_request()?;
485        let token = self
486            .token
487            .as_ref()
488            .ok_or_else(|| Error::Configuration("bearer token is required for WebSocket".into()))?;
489        req.headers_mut().insert(
490            "Authorization",
491            format!("Bearer {token}")
492                .parse()
493                .map_err(|_| Error::Configuration("invalid bearer header".into()))?,
494        );
495        if let Some(key) = &self.test_key {
496            req.headers_mut().insert(
497                "X-Testing-Environment-Key",
498                key.parse()
499                    .map_err(|_| Error::Configuration("invalid test header".into()))?,
500            );
501        }
502        let configuration = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
503            .max_message_size(Some(self.websocket_limit))
504            .max_frame_size(Some(self.websocket_limit))
505            .max_write_buffer_size(self.websocket_limit.saturating_add(128 * 1024 + 1));
506        let (socket, _) =
507            tokio_tungstenite::connect_async_with_config(req, Some(configuration), true).await?;
508        Ok(socket)
509    }
510}
511pub enum GifList<'a> {
512    Trending,
513    Search(&'a str),
514    Recent,
515}
516
517pub fn validate_endpoint(url: &Url) -> Result<()> {
518    let loopback = matches!(
519        url.host_str(),
520        Some("localhost" | "dm.localhost" | "127.0.0.1" | "[::1]" | "::1")
521    );
522    if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
523        return Err(Error::Configuration(
524            "use HTTPS, or HTTP on loopback for local development".into(),
525        ));
526    }
527    if !url.username().is_empty() || url.password().is_some() {
528        return Err(Error::Configuration(
529            "URL credentials are not supported".into(),
530        ));
531    }
532    Ok(())
533}
534async fn checked(response: reqwest::Response) -> Result<reqwest::Response> {
535    if response.status().is_success() {
536        return Ok(response);
537    }
538    let status = response.status().as_u16();
539    let request_id = response
540        .headers()
541        .get("x-request-id")
542        .and_then(|v| v.to_str().ok())
543        .map(str::to_owned);
544    let retry_after = response
545        .headers()
546        .get("retry-after")
547        .and_then(|v| v.to_str().ok())
548        .map(str::to_owned);
549    let body = response.json::<Value>().await.unwrap_or(Value::Null);
550    let code = body
551        .pointer("/error/code")
552        .and_then(Value::as_str)
553        .unwrap_or("http_error")
554        .to_owned();
555    let message = body
556        .pointer("/error/message")
557        .and_then(Value::as_str)
558        .unwrap_or("request failed; inspect response body")
559        .to_owned();
560    Err(Error::Api {
561        status,
562        code,
563        message,
564        body: Box::new(body),
565        request_id,
566        retry_after,
567    })
568}
569/// Available package release information. A linked Rust library cannot replace itself:
570/// applications must update their dependency and rebuild to load a newer library.
571#[derive(Clone, Debug, Serialize)]
572pub struct UpdateInfo {
573    pub package: String,
574    pub current_version: String,
575    pub latest_version: String,
576    pub rebuild_command: String,
577}
578pub async fn check_update() -> Result<UpdateInfo> {
579    let body = checked(
580        HttpClient::builder()
581            .timeout(Duration::from_secs(8))
582            .user_agent(concat!("silicon-dm-client/", env!("CARGO_PKG_VERSION")))
583            .build()?
584            .get("https://crates.io/api/v1/crates/silicon-dm-client")
585            .send()
586            .await?,
587    )
588    .await?
589    .json::<Value>()
590    .await?;
591    let latest = body
592        .pointer("/crate/max_stable_version")
593        .and_then(Value::as_str)
594        .ok_or_else(|| Error::Configuration("registry did not return a stable version".into()))?;
595    Ok(UpdateInfo {
596        package: "silicon-dm-client".into(),
597        current_version: env!("CARGO_PKG_VERSION").into(),
598        latest_version: latest.into(),
599        rebuild_command: "cargo update -p silicon-dm-client && cargo build --release".into(),
600    })
601}