1use reqwest::{Client as HttpClient, Method, StatusCode};
7use secrecy::{ExposeSecret as _, SecretString};
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9use serde_json::Value;
10use std::{fmt, time::Duration};
11use thiserror::Error;
12use url::Url;
13use uuid::Uuid;
14
15#[derive(Clone, Debug, Deserialize)]
17pub struct RegistryRelease {
18 pub version: String,
19}
20
21pub async fn latest_release() -> Result<RegistryRelease, Error> {
25 latest_release_for("silicon-commit-client").await
26}
27
28pub async fn latest_cli_release() -> Result<RegistryRelease, Error> {
32 latest_release_for("silicon-commit-cli").await
33}
34
35async fn latest_release_for(crate_name: &str) -> Result<RegistryRelease, Error> {
36 let http = HttpClient::builder()
37 .redirect(reqwest::redirect::Policy::none())
38 .timeout(Duration::from_secs(5))
39 .build()
40 .map_err(transport)?;
41 let response = http
42 .get(format!("https://crates.io/api/v1/crates/{crate_name}"))
43 .header(
44 "user-agent",
45 format!("{crate_name}/{}", env!("CARGO_PKG_VERSION")),
46 )
47 .send()
48 .await
49 .map_err(transport)?;
50 if !response.status().is_success() {
51 return Err(Error::Api {
52 status: response.status(),
53 code: "registry_unavailable".into(),
54 request_id: None,
55 });
56 }
57 let body = response.bytes().await.map_err(transport)?;
58 let value: Value = serde_json::from_slice(&body).map_err(Error::Decode)?;
59 let version = value
60 .pointer("/crate/newest")
61 .and_then(Value::as_str)
62 .ok_or(Error::Invalid("registry response missing newest version"))?;
63 Ok(RegistryRelease {
64 version: version.to_owned(),
65 })
66}
67
68#[derive(Debug, Error)]
70pub enum Error {
71 #[error(
72 "invalid API URL: use an HTTP(S) origin or /api/v1/ URL without credentials, query, or fragment"
73 )]
74 InvalidUrl,
75 #[error("invalid request: {0}")]
76 Invalid(&'static str),
77 #[error("request transport failed")]
78 Transport(#[source] reqwest::Error),
79 #[error("HTTP {status}: {code} (request ID: {request_id:?})")]
80 Api {
81 status: StatusCode,
82 code: String,
83 request_id: Option<String>,
84 },
85 #[error("response exceeds the 8 MiB limit")]
86 ResponseTooLarge,
87 #[error("response did not match the expected JSON contract")]
88 Decode(#[source] serde_json::Error),
89}
90impl Error {
91 pub fn is_unauthenticated(&self) -> bool {
93 matches!(
94 self,
95 Self::Api {
96 status: StatusCode::UNAUTHORIZED,
97 ..
98 }
99 )
100 }
101}
102
103#[derive(Clone, Debug)]
105pub struct Mutation {
106 key: String,
107 version: Option<i64>,
108}
109impl Default for Mutation {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114impl Mutation {
115 pub fn new() -> Self {
116 Self {
117 key: Uuid::new_v4().to_string(),
118 version: None,
119 }
120 }
121 pub fn with_key(key: impl Into<String>) -> Result<Self, Error> {
122 let key = key.into();
123 if !(8..=255).contains(&key.len()) || !key.bytes().all(|b| b.is_ascii_graphic()) {
124 return Err(Error::Invalid(
125 "idempotency key must be 8–255 visible ASCII characters",
126 ));
127 }
128 Ok(Self { key, version: None })
129 }
130 pub fn if_match(mut self, version: i64) -> Result<Self, Error> {
131 if version < 0 {
132 return Err(Error::Invalid("version cannot be negative"));
133 }
134 self.version = Some(version);
135 Ok(self)
136 }
137 pub fn key(&self) -> &str {
138 &self.key
139 }
140}
141
142#[derive(Clone, Debug)]
144pub struct Client {
145 http: HttpClient,
146 base: Url,
147 bearer: Option<SecretString>,
148 test_key: Option<SecretString>,
149 org_id: Option<String>,
150 mutation: Option<Mutation>,
151}
152
153#[derive(Clone, Deserialize, Serialize)]
155pub struct SessionTokens {
156 pub access_token: String,
157 pub refresh_token: String,
158 pub token_type: Value,
159 pub expires_in: i64,
160 pub scope: String,
161 pub actor: Value,
162 pub org_id: Option<String>,
163}
164impl fmt::Debug for SessionTokens {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 f.debug_struct("SessionTokens")
167 .field("tokens", &"<redacted>")
168 .field("org_id", &self.org_id)
169 .finish()
170 }
171}
172
173impl Client {
174 pub fn new(base_url: &str) -> Result<Self, Error> {
176 let mut base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
177 if !matches!(base.scheme(), "http" | "https")
178 || base.host_str().is_none()
179 || !base.username().is_empty()
180 || base.password().is_some()
181 || base.query().is_some()
182 || base.fragment().is_some()
183 || !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/")
184 {
185 return Err(Error::InvalidUrl);
186 }
187 base.set_path("/api/v1/");
188 let http = HttpClient::builder()
189 .redirect(reqwest::redirect::Policy::none())
190 .connect_timeout(Duration::from_secs(5))
191 .timeout(Duration::from_secs(30))
192 .user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
193 .build()
194 .map_err(transport)?;
195 Ok(Self {
196 http,
197 base,
198 bearer: None,
199 test_key: None,
200 org_id: None,
201 mutation: None,
202 })
203 }
204 pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
205 self.bearer = Some(SecretString::from(token.into()));
206 self
207 }
208 pub fn with_test_key(mut self, key: impl Into<String>) -> Result<Self, Error> {
209 let key = key.into();
210 if key.len() != 32 || !key.bytes().all(|b| b.is_ascii_alphanumeric()) {
211 return Err(Error::Invalid(
212 "test-environment key must be exactly 32 alphanumeric characters",
213 ));
214 }
215 self.test_key = Some(SecretString::from(key));
216 Ok(self)
217 }
218 pub fn with_org_id(mut self, org_id: impl Into<String>) -> Self {
219 self.org_id = Some(org_id.into());
220 self
221 }
222 pub fn with_mutation(mut self, mutation: Mutation) -> Self {
224 self.mutation = Some(mutation);
225 self
226 }
227 pub fn base_url(&self) -> &Url {
228 &self.base
229 }
230 pub fn new_idempotency_key() -> String {
231 Mutation::new().key
232 }
233 pub async fn health(&self) -> Result<Value, Error> {
234 self.execute(Method::GET, &["healthz"], None, &[], true)
235 .await
236 }
237 pub async fn ready(&self) -> Result<Value, Error> {
238 self.execute(Method::GET, &["readyz"], None, &[], true)
239 .await
240 }
241 pub async fn version(&self) -> Result<Value, Error> {
242 let mut public = self.clone();
244 public.bearer = None;
245 public.org_id = None;
246 public.test_key = None;
247 public.get(&["version"], &[]).await
248 }
249 pub async fn iam(&self) -> Result<Value, Error> {
251 let mut public = self.clone();
252 public.bearer = None;
253 public.org_id = None;
254 public.test_key = None;
255 public.get(&["iam"], &[]).await
256 }
257 pub async fn login_status(&self) -> Result<Value, Error> {
261 if self
262 .bearer
263 .as_ref()
264 .is_none_or(|token| token.expose_secret().is_empty())
265 {
266 return Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}));
267 }
268 match self.get(&["auth", "status"], &[]).await {
269 Err(error) if error.is_unauthenticated() => {
270 Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}))
271 }
272 result => result,
273 }
274 }
275 pub async fn login_with_slt(&self, slt: &str) -> Result<SessionTokens, Error> {
276 decode(
277 self.write(
278 Method::POST,
279 &["auth", "login"],
280 &serde_json::json!({"slt":slt}),
281 )
282 .await?,
283 )
284 }
285 pub async fn refresh_session(&self, refresh_token: &str) -> Result<SessionTokens, Error> {
286 decode(
287 self.write(
288 Method::POST,
289 &["auth", "refresh"],
290 &serde_json::json!({"refresh_token":refresh_token}),
291 )
292 .await?,
293 )
294 }
295 pub async fn logout(&self, token: &str) -> Result<(), Error> {
296 self.write(
297 Method::POST,
298 &["auth", "logout"],
299 &serde_json::json!({"token":token}),
300 )
301 .await?;
302 Ok(())
303 }
304 pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
306 self.get(&["todos"], query).await
307 }
308 pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
310 self.get(&["todos", todo_id], &[]).await
311 }
312 pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
314 self.write(Method::POST, &["todos"], body).await
315 }
316 pub async fn update_todo<T: Serialize + ?Sized>(
318 &self,
319 todo_id: &str,
320 body: &T,
321 ) -> Result<Value, Error> {
322 self.write(Method::PATCH, &["todos", todo_id], body).await
323 }
324 pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
326 self.execute(Method::DELETE, &["todos", todo_id], None, &[], false)
327 .await
328 }
329 pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
331 self.get(&["todos", todo_id, "notes"], query).await
332 }
333 pub async fn add_note<T: Serialize + ?Sized>(
335 &self,
336 todo_id: &str,
337 body: &T,
338 ) -> Result<Value, Error> {
339 self.write(Method::POST, &["todos", todo_id, "notes"], body)
340 .await
341 }
342 pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
344 self.get(&["todos", todo_id, "notification-subscription"], &[])
345 .await
346 }
347 pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
349 &self,
350 todo_id: &str,
351 body: &T,
352 ) -> Result<Value, Error> {
353 self.write(
354 Method::PUT,
355 &["todos", todo_id, "notification-subscription"],
356 body,
357 )
358 .await
359 }
360 pub async fn notification_settings(&self) -> Result<Value, Error> {
362 self.get(&["notification-settings"], &[]).await
363 }
364 pub async fn update_notification_settings<T: Serialize + ?Sized>(
366 &self,
367 body: &T,
368 ) -> Result<Value, Error> {
369 self.write(Method::PUT, &["notification-settings"], body)
370 .await
371 }
372 pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
374 self.get(&["projects"], query).await
375 }
376 pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
378 self.get(&["projects", project_id], &[]).await
379 }
380 pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
382 self.write(Method::POST, &["projects"], body).await
383 }
384 pub async fn update_project<T: Serialize + ?Sized>(
386 &self,
387 project_id: &str,
388 body: &T,
389 ) -> Result<Value, Error> {
390 self.write(Method::PATCH, &["projects", project_id], body)
391 .await
392 }
393 pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
395 self.get(&["projects", project_id, "diary"], &[]).await
396 }
397 pub async fn replace_project_diary<T: Serialize + ?Sized>(
399 &self,
400 project_id: &str,
401 body: &T,
402 ) -> Result<Value, Error> {
403 self.write(Method::PUT, &["projects", project_id, "diary"], body)
404 .await
405 }
406 pub async fn project_tasks(
408 &self,
409 project_id: &str,
410 query: &[(&str, &str)],
411 ) -> Result<Value, Error> {
412 self.get(&["projects", project_id, "tasks"], query).await
413 }
414 pub async fn project_entries(
416 &self,
417 project_id: &str,
418 query: &[(&str, &str)],
419 ) -> Result<Value, Error> {
420 self.get(&["projects", project_id, "entries"], query).await
421 }
422 pub async fn create_project_task<T: Serialize + ?Sized>(
424 &self,
425 project_id: &str,
426 body: &T,
427 ) -> Result<Value, Error> {
428 self.write(Method::POST, &["projects", project_id, "tasks"], body)
429 .await
430 }
431 pub async fn update_project_task<T: Serialize + ?Sized>(
433 &self,
434 project_id: &str,
435 task_id: &str,
436 body: &T,
437 ) -> Result<Value, Error> {
438 self.write(
439 Method::PATCH,
440 &["projects", project_id, "tasks", task_id],
441 body,
442 )
443 .await
444 }
445 pub async fn create_project_blocker<T: Serialize + ?Sized>(
447 &self,
448 project_id: &str,
449 body: &T,
450 ) -> Result<Value, Error> {
451 self.write(Method::POST, &["projects", project_id, "blockers"], body)
452 .await
453 }
454 pub async fn create_project_update<T: Serialize + ?Sized>(
456 &self,
457 project_id: &str,
458 body: &T,
459 ) -> Result<Value, Error> {
460 self.write(Method::POST, &["projects", project_id, "updates"], body)
461 .await
462 }
463 pub async fn complete_project<T: Serialize + ?Sized>(
465 &self,
466 project_id: &str,
467 body: &T,
468 ) -> Result<Value, Error> {
469 self.write(Method::POST, &["projects", project_id, "completion"], body)
470 .await
471 }
472 pub async fn list_test_environments(&self) -> Result<Value, Error> {
474 self.get(&["test-environments"], &[]).await
475 }
476 pub async fn create_test_environment<T: Serialize + ?Sized>(
478 &self,
479 body: &T,
480 ) -> Result<Value, Error> {
481 self.write(Method::POST, &["test-environments"], body).await
482 }
483 pub async fn retrieve_test_environment_key(&self, id: &str) -> Result<Value, Error> {
485 self.get(&["test-environments", id, "key"], &[]).await
486 }
487 pub async fn rotate_test_environment(&self, id: &str) -> Result<Value, Error> {
489 self.execute(
490 Method::POST,
491 &["test-environments", id, "rotate"],
492 None,
493 &[],
494 false,
495 )
496 .await
497 }
498 pub async fn delete_test_environment(&self, id: &str) -> Result<Value, Error> {
500 self.execute(Method::DELETE, &["test-environments", id], None, &[], false)
501 .await
502 }
503 pub async fn restore_test_environment(&self, id: &str) -> Result<Value, Error> {
504 self.execute(
505 Method::POST,
506 &["test-environments", id, "restore"],
507 None,
508 &[],
509 false,
510 )
511 .await
512 }
513 pub async fn clean_test_environment(&self, id: &str) -> Result<Value, Error> {
514 self.execute(
515 Method::POST,
516 &["test-environments", id, "clean"],
517 None,
518 &[],
519 false,
520 )
521 .await
522 }
523 async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
524 self.execute(Method::GET, path, None, query, false).await
525 }
526 async fn write<T: Serialize + ?Sized>(
527 &self,
528 method: Method,
529 path: &[&str],
530 body: &T,
531 ) -> Result<Value, Error> {
532 let body = serde_json::to_vec(body).map_err(Error::Decode)?;
533 self.execute(method, path, Some(body), &[], false).await
534 }
535 fn url(&self, path: &[&str], root: bool) -> Result<Url, Error> {
536 if path
537 .iter()
538 .any(|p| p.is_empty() || matches!(*p, "." | ".."))
539 {
540 return Err(Error::Invalid(
541 "empty and dot resource identifiers are forbidden",
542 ));
543 }
544 let mut url = self.base.clone();
545 {
546 let mut segments = url.path_segments_mut().map_err(|_| Error::InvalidUrl)?;
547 if root {
548 segments.clear();
549 } else {
550 segments.pop_if_empty();
551 }
552 segments.extend(path);
553 }
554 Ok(url)
555 }
556 async fn execute(
557 &self,
558 method: Method,
559 path: &[&str],
560 body: Option<Vec<u8>>,
561 query: &[(&str, &str)],
562 root: bool,
563 ) -> Result<Value, Error> {
564 let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
565 let mut req = self
566 .http
567 .request(method, self.url(path, root)?)
568 .query(query);
569 if !root {
570 if let Some(token) = &self.bearer {
571 req = req.bearer_auth(token.expose_secret());
572 }
573 if let Some(org) = &self.org_id {
574 req = req.header("x-org-id", org);
575 }
576 if let Some(key) = &self.test_key {
577 let key = key.expose_secret();
578 if key.len() != 32 || !key.bytes().all(|b| b.is_ascii_alphanumeric()) {
579 return Err(Error::Invalid(
580 "testing key must be exactly 32 alphanumeric characters",
581 ));
582 }
583 req = req.header("x-testing-environment-key", key);
584 }
585 }
586 if is_mutation {
587 let mutation = self.mutation.clone().unwrap_or_default();
588 req = req.header("idempotency-key", mutation.key);
589 if let Some(version) = mutation.version {
590 req = req.header("if-match", format!("\"{version}\""));
591 }
592 }
593 if let Some(body) = body {
594 req = req.header("content-type", "application/json").body(body);
595 }
596 let mut response = req.send().await.map_err(transport)?;
597 let status = response.status();
598 let request_id = response
599 .headers()
600 .get("x-request-id")
601 .and_then(|v| v.to_str().ok())
602 .map(str::to_owned);
603 const LIMIT: usize = 8 * 1024 * 1024;
604 if response.content_length().is_some_and(|n| n > LIMIT as u64) {
605 return Err(Error::ResponseTooLarge);
606 }
607 let mut bytes = Vec::new();
608 while let Some(chunk) = response.chunk().await.map_err(transport)? {
609 if bytes.len() + chunk.len() > LIMIT {
610 return Err(Error::ResponseTooLarge);
611 }
612 bytes.extend_from_slice(&chunk);
613 }
614 if !status.is_success() {
615 let parsed: Option<Value> = serde_json::from_slice(&bytes).ok();
616 let code = parsed
617 .as_ref()
618 .and_then(|v| v.pointer("/error/code"))
619 .and_then(Value::as_str)
620 .filter(|s| {
621 s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
622 })
623 .unwrap_or("http_error")
624 .to_owned();
625 return Err(Error::Api {
626 status,
627 code,
628 request_id,
629 });
630 }
631 if bytes.is_empty() {
632 return Ok(Value::Null);
633 }
634 serde_json::from_slice(&bytes).map_err(Error::Decode)
635 }
636}
637fn transport(error: reqwest::Error) -> Error {
638 Error::Transport(error.without_url())
639}
640fn decode<T: DeserializeOwned>(value: Value) -> Result<T, Error> {
641 serde_json::from_value(value).map_err(Error::Decode)
642}