1pub mod auth;
30mod error;
31
32pub use error::{ApiError, Error};
33pub use secrecy::{ExposeSecret, SecretString};
35
36use error::root_cause;
37use reqwest::{Client as HttpClient, Method};
38use serde::Serialize;
39use serde_json::Value;
40use std::{fmt, time::Duration};
41use url::Url;
42use uuid::Uuid;
43
44pub const DEFAULT_API_URL: &str = "https://api.commit.teamofsilicons.com";
46pub const CONTRACT_VERSION: u16 = 2;
48const RESPONSE_LIMIT: usize = 8 * 1024 * 1024;
50
51#[derive(Clone, Debug)]
54pub struct Mutation {
55 key: String,
56 version: Option<i64>,
57}
58
59impl Default for Mutation {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65impl Mutation {
66 pub fn new() -> Self {
68 Self {
69 key: Uuid::new_v4().to_string(),
70 version: None,
71 }
72 }
73
74 pub fn with_key(key: impl Into<String>) -> Result<Self, Error> {
76 let key = key.into();
77 if !(8..=255).contains(&key.len()) || !key.bytes().all(|b| b.is_ascii_graphic()) {
78 return Err(Error::Invalid(
79 "An idempotency key must be 8 to 255 visible ASCII characters (no spaces).".into(),
80 ));
81 }
82 Ok(Self { key, version: None })
83 }
84
85 pub fn if_match(mut self, version: i64) -> Result<Self, Error> {
87 if version < 0 {
88 return Err(Error::Invalid(format!(
89 "If-Match versions start at 0; {version} is negative."
90 )));
91 }
92 self.version = Some(version);
93 Ok(self)
94 }
95
96 pub fn key(&self) -> &str {
98 &self.key
99 }
100}
101
102#[derive(Clone)]
104enum Credential {
105 Bearer(SecretString),
106 Proof(SecretString),
107}
108
109#[derive(Clone)]
111pub struct Client {
112 http: HttpClient,
113 base: Url,
114 credential: Option<Credential>,
115 mutation: Option<Mutation>,
116}
117
118impl fmt::Debug for Client {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 let credential = match &self.credential {
121 None => "none",
122 Some(Credential::Bearer(_)) => "bearer <redacted>",
123 Some(Credential::Proof(_)) => "proof <redacted>",
124 };
125 f.debug_struct("Client")
126 .field("base_url", &self.base.as_str())
127 .field("credential", &credential)
128 .finish_non_exhaustive()
129 }
130}
131
132pub fn is_loopback(url: &Url) -> bool {
134 match url.host() {
135 Some(url::Host::Domain(domain)) => domain == "localhost" || domain.ends_with(".localhost"),
136 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
137 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
138 None => false,
139 }
140}
141
142impl Client {
143 pub fn new(base_url: &str) -> Result<Self, Error> {
146 let invalid = |reason| Error::InvalidUrl {
147 url: base_url.to_owned(),
148 reason,
149 };
150 let mut base =
151 Url::parse(base_url.trim()).map_err(|_| invalid("it is not an absolute URL"))?;
152 match base.scheme() {
153 "https" => {}
154 "http" if is_loopback(&base) => {}
155 "http" => {
156 return Err(invalid(
157 "plain http is only allowed for this machine (localhost, 127.0.0.1, ::1); use https",
158 ));
159 }
160 _ => {
161 return Err(invalid(
162 "only https (and http for this machine) are supported",
163 ));
164 }
165 }
166 if base.host_str().is_none() || !base.username().is_empty() || base.password().is_some() {
167 return Err(invalid("it must name a host and carry no credentials"));
168 }
169 if base.query().is_some() || base.fragment().is_some() {
170 return Err(invalid("it must not have a query string or fragment"));
171 }
172 if !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/") {
173 return Err(invalid("use the origin, or the origin followed by /api/v1"));
174 }
175 base.set_path("/api/v1/");
176 let http = HttpClient::builder()
177 .redirect(reqwest::redirect::Policy::none())
178 .connect_timeout(Duration::from_secs(5))
179 .timeout(Duration::from_secs(30))
180 .user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
181 .build()
182 .map_err(|e| {
183 Error::Invalid(format!(
184 "Could not create the HTTP client: {}.",
185 root_cause(&e)
186 ))
187 })?;
188 Ok(Self {
189 http,
190 base,
191 credential: None,
192 mutation: None,
193 })
194 }
195
196 #[must_use]
199 pub fn with_bearer(mut self, access_token: impl Into<String>) -> Self {
200 self.credential = Some(Credential::Bearer(SecretString::from(access_token.into())));
201 self
202 }
203
204 pub fn with_proof(mut self, proof: impl Into<String>) -> Result<Self, Error> {
209 let proof = proof.into();
210 let proof = proof.trim();
211 if proof.starts_with("sapr_") || !proof.starts_with("sap_") {
212 return Err(Error::Invalid(
213 "A User verification proof starts with `sap_` (a `sapr_…` value is the proof's refresh token, which is never sent to Commit).".into(),
214 ));
215 }
216 self.credential = Some(Credential::Proof(SecretString::from(proof.to_owned())));
217 Ok(self)
218 }
219
220 #[deprecated(
222 since = "0.6.0",
223 note = "Commit records no telemetry; this does nothing and can be removed"
224 )]
225 #[must_use]
226 pub fn with_telemetry(self, _enabled: bool) -> Self {
227 self
228 }
229
230 #[deprecated(
234 since = "0.6.0",
235 note = "Commit records no request diagnostics; this does nothing and can be removed"
236 )]
237 pub fn with_source(self, source: &'static str) -> Result<Self, Error> {
238 if !matches!(source, "cli" | "browser" | "daemon" | "rust-client") {
239 return Err(Error::Invalid(format!(
240 "`{source}` is not a client source; use cli, browser, daemon or rust-client."
241 )));
242 }
243 Ok(self)
244 }
245
246 #[must_use]
248 pub fn with_mutation(mut self, mutation: Mutation) -> Self {
249 self.mutation = Some(mutation);
250 self
251 }
252
253 pub fn base_url(&self) -> &Url {
255 &self.base
256 }
257
258 pub fn new_idempotency_key() -> String {
260 Mutation::new().key
261 }
262}
263
264impl Client {
266 pub async fn health(&self) -> Result<Value, Error> {
268 self.execute(Method::GET, &["healthz"], None, &[], Scope::Root)
269 .await
270 }
271
272 pub async fn ready(&self) -> Result<Value, Error> {
274 self.execute(Method::GET, &["readyz"], None, &[], Scope::Root)
275 .await
276 }
277
278 pub async fn version(&self) -> Result<Value, Error> {
280 self.execute(Method::GET, &["version"], None, &[], Scope::Public)
281 .await
282 }
283
284 pub async fn contracts(&self) -> Result<Value, Error> {
286 self.get(&["contracts"], &[]).await
287 }
288
289 pub async fn accounts(&self) -> Result<Value, Error> {
292 self.execute(Method::GET, &["accounts"], None, &[], Scope::Public)
293 .await
294 }
295
296 pub async fn me(&self) -> Result<Value, Error> {
299 self.get(&["me"], &[]).await
300 }
301
302 pub async fn silicon_allowlist(&self, silicon: &str) -> Result<Value, Error> {
306 self.get(&["silicons", silicon, "allowed-accounts"], &[])
307 .await
308 }
309
310 pub async fn allow_account(&self, silicon: &str, account: &str) -> Result<Value, Error> {
313 self.execute(
314 Method::PUT,
315 &["silicons", silicon, "allowed-accounts", account],
316 None,
317 &[],
318 Scope::Api,
319 )
320 .await
321 }
322
323 pub async fn disallow_account(&self, silicon: &str, account: &str) -> Result<Value, Error> {
325 self.execute(
326 Method::DELETE,
327 &["silicons", silicon, "allowed-accounts", account],
328 None,
329 &[],
330 Scope::Api,
331 )
332 .await
333 }
334
335 pub async fn email_settings(&self) -> Result<Value, Error> {
337 self.get(&["email-settings"], &[]).await
338 }
339
340 pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
342 self.write(Method::PUT, &["email-settings"], body).await
343 }
344
345 pub async fn report(&self, body: &Value) -> Result<Value, Error> {
348 self.write(Method::POST, &["reports"], body).await
349 }
350}
351
352impl Client {
354 pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
357 self.get(&["todos"], query).await
358 }
359 pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
361 self.get(&["todos", todo_id], &[]).await
362 }
363 pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
365 self.write(Method::POST, &["todos"], body).await
366 }
367 pub async fn update_todo<T: Serialize + ?Sized>(
369 &self,
370 todo_id: &str,
371 body: &T,
372 ) -> Result<Value, Error> {
373 self.write(Method::PATCH, &["todos", todo_id], body).await
374 }
375 pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
377 self.execute(Method::DELETE, &["todos", todo_id], None, &[], Scope::Api)
378 .await
379 }
380 pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
382 self.get(&["todos", todo_id, "notes"], query).await
383 }
384 pub async fn add_note<T: Serialize + ?Sized>(
386 &self,
387 todo_id: &str,
388 body: &T,
389 ) -> Result<Value, Error> {
390 self.write(Method::POST, &["todos", todo_id, "notes"], body)
391 .await
392 }
393 pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
395 self.get(&["todos", todo_id, "notification-subscription"], &[])
396 .await
397 }
398 pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
400 &self,
401 todo_id: &str,
402 body: &T,
403 ) -> Result<Value, Error> {
404 self.write(
405 Method::PUT,
406 &["todos", todo_id, "notification-subscription"],
407 body,
408 )
409 .await
410 }
411 pub async fn notification_settings(&self) -> Result<Value, Error> {
413 self.get(&["notification-settings"], &[]).await
414 }
415 pub async fn notification_settings_of(&self, silicon: &str) -> Result<Value, Error> {
418 self.get(&["notification-settings"], &[("silicon", silicon)])
419 .await
420 }
421 pub async fn update_notification_settings<T: Serialize + ?Sized>(
423 &self,
424 body: &T,
425 ) -> Result<Value, Error> {
426 self.write(Method::PUT, &["notification-settings"], body)
427 .await
428 }
429 pub async fn update_notification_settings_of<T: Serialize + ?Sized>(
432 &self,
433 silicon: &str,
434 body: &T,
435 ) -> Result<Value, Error> {
436 let body = serde_json::to_vec(body).map_err(Error::Decode)?;
437 self.execute(
438 Method::PUT,
439 &["notification-settings"],
440 Some(body),
441 &[("silicon", silicon)],
442 Scope::Api,
443 )
444 .await
445 }
446}
447
448impl Client {
450 pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
452 self.get(&["projects"], query).await
453 }
454 pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
456 self.get(&["projects", project_id], &[]).await
457 }
458 pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
460 self.write(Method::POST, &["projects"], body).await
461 }
462 pub async fn update_project<T: Serialize + ?Sized>(
464 &self,
465 project_id: &str,
466 body: &T,
467 ) -> Result<Value, Error> {
468 self.write(Method::PATCH, &["projects", project_id], body)
469 .await
470 }
471 pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
473 self.get(&["projects", project_id, "diary"], &[]).await
474 }
475 pub async fn replace_project_diary<T: Serialize + ?Sized>(
477 &self,
478 project_id: &str,
479 body: &T,
480 ) -> Result<Value, Error> {
481 self.write(Method::PUT, &["projects", project_id, "diary"], body)
482 .await
483 }
484 pub async fn project_tasks(
486 &self,
487 project_id: &str,
488 query: &[(&str, &str)],
489 ) -> Result<Value, Error> {
490 self.get(&["projects", project_id, "tasks"], query).await
491 }
492 pub async fn project_entries(
494 &self,
495 project_id: &str,
496 query: &[(&str, &str)],
497 ) -> Result<Value, Error> {
498 self.get(&["projects", project_id, "entries"], query).await
499 }
500 pub async fn create_project_task<T: Serialize + ?Sized>(
502 &self,
503 project_id: &str,
504 body: &T,
505 ) -> Result<Value, Error> {
506 self.write(Method::POST, &["projects", project_id, "tasks"], body)
507 .await
508 }
509 pub async fn update_project_task<T: Serialize + ?Sized>(
511 &self,
512 project_id: &str,
513 task_id: &str,
514 body: &T,
515 ) -> Result<Value, Error> {
516 self.write(
517 Method::PATCH,
518 &["projects", project_id, "tasks", task_id],
519 body,
520 )
521 .await
522 }
523 pub async fn claim_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
525 self.write(
526 Method::POST,
527 &["projects", project, "tasks", task, "claim"],
528 &serde_json::json!({}),
529 )
530 .await
531 }
532 pub async fn delete_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
534 self.execute(
535 Method::DELETE,
536 &["projects", project, "tasks", task],
537 None,
538 &[],
539 Scope::Api,
540 )
541 .await
542 }
543 pub async fn create_project_blocker<T: Serialize + ?Sized>(
545 &self,
546 project_id: &str,
547 body: &T,
548 ) -> Result<Value, Error> {
549 self.write(Method::POST, &["projects", project_id, "blockers"], body)
550 .await
551 }
552 pub async fn create_project_update<T: Serialize + ?Sized>(
554 &self,
555 project_id: &str,
556 body: &T,
557 ) -> Result<Value, Error> {
558 self.write(Method::POST, &["projects", project_id, "updates"], body)
559 .await
560 }
561 pub async fn complete_project<T: Serialize + ?Sized>(
563 &self,
564 project_id: &str,
565 body: &T,
566 ) -> Result<Value, Error> {
567 self.write(Method::POST, &["projects", project_id, "completion"], body)
568 .await
569 }
570 pub async fn project_versions(
572 &self,
573 project: &str,
574 query: &[(&str, &str)],
575 ) -> Result<Value, Error> {
576 self.get(&["projects", project, "versions"], query).await
577 }
578 pub async fn project_version(&self, project: &str, version: i64) -> Result<Value, Error> {
580 self.get(
581 &["projects", project, "versions", &version.to_string()],
582 &[],
583 )
584 .await
585 }
586}
587
588#[derive(Clone, Copy, PartialEq, Eq)]
590enum Scope {
591 Api,
593 Public,
595 Root,
597}
598
599impl Client {
600 async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
601 self.execute(Method::GET, path, None, query, Scope::Api)
602 .await
603 }
604
605 async fn write<T: Serialize + ?Sized>(
606 &self,
607 method: Method,
608 path: &[&str],
609 body: &T,
610 ) -> Result<Value, Error> {
611 let body = serde_json::to_vec(body).map_err(Error::Decode)?;
612 self.execute(method, path, Some(body), &[], Scope::Api)
613 .await
614 }
615
616 fn url(&self, path: &[&str], scope: Scope) -> Result<Url, Error> {
617 if path
618 .iter()
619 .any(|p| p.is_empty() || matches!(*p, "." | ".."))
620 {
621 return Err(Error::Invalid(
622 "Resource identifiers cannot be empty, `.` or `..`.".into(),
623 ));
624 }
625 let mut url = self.base.clone();
626 {
627 let mut segments = url.path_segments_mut().map_err(|()| Error::InvalidUrl {
628 url: self.base.to_string(),
629 reason: "it cannot carry a path",
630 })?;
631 if scope == Scope::Root {
632 segments.clear();
633 } else {
634 segments.pop_if_empty();
635 }
636 segments.extend(path);
637 }
638 Ok(url)
639 }
640
641 fn transport(&self, error: reqwest::Error) -> Error {
642 let error = error.without_url();
643 Error::Transport {
644 base: self.base.origin().ascii_serialization(),
645 cause: root_cause(&error),
646 source: error,
647 }
648 }
649
650 async fn execute(
651 &self,
652 method: Method,
653 path: &[&str],
654 body: Option<Vec<u8>>,
655 query: &[(&str, &str)],
656 scope: Scope,
657 ) -> Result<Value, Error> {
658 let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
659 let mut request = self
660 .http
661 .request(method, self.url(path, scope)?)
662 .query(query)
663 .header("x-commit-supported-versions", CONTRACT_VERSION.to_string())
664 .header("x-commit-telemetry", "off");
666 if scope == Scope::Api {
667 match &self.credential {
668 Some(Credential::Bearer(token)) => {
669 request = request.bearer_auth(token.expose_secret())
670 }
671 Some(Credential::Proof(proof)) => {
672 request =
673 request.header("authorization", format!("Proof {}", proof.expose_secret()));
674 }
675 None => {}
676 }
677 }
678 if is_mutation {
679 let mutation = self.mutation.clone().unwrap_or_default();
680 request = request.header("idempotency-key", mutation.key);
681 if let Some(version) = mutation.version {
682 request = request.header("if-match", format!("\"{version}\""));
683 }
684 }
685 if let Some(body) = body {
686 request = request
687 .header("content-type", "application/json")
688 .body(body);
689 }
690 let mut response = request.send().await.map_err(|e| self.transport(e))?;
691 if let Some(served) = response.headers().get("x-commit-api-version")
692 && served.as_bytes() != CONTRACT_VERSION.to_string().as_bytes()
693 {
694 return Err(Error::UnsupportedContract {
695 served: String::from_utf8_lossy(served.as_bytes()).into_owned(),
696 });
697 }
698 let status = response.status();
699 let headers = response.headers().clone();
700 if response
701 .content_length()
702 .is_some_and(|n| n > RESPONSE_LIMIT as u64)
703 {
704 return Err(Error::ResponseTooLarge);
705 }
706 let mut bytes = Vec::new();
707 while let Some(chunk) = response.chunk().await.map_err(|e| self.transport(e))? {
708 if bytes.len() + chunk.len() > RESPONSE_LIMIT {
709 return Err(Error::ResponseTooLarge);
710 }
711 bytes.extend_from_slice(&chunk);
712 }
713 if !status.is_success() {
714 return Err(ApiError::from_response(status, &headers, &bytes).into());
715 }
716 if bytes.is_empty() {
717 return Ok(Value::Null);
718 }
719 serde_json::from_slice(&bytes).map_err(Error::Decode)
720 }
721}