1pub 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#[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 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#[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 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 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 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 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 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 pub async fn connect(&self, actors: &[String], device_id: &str) -> Result<Socket> {
450 self.connect_with_generation(actors, device_id, None).await
451 }
452 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#[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}