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 telemetry: bool,
116 source: &'static str,
117 mutation: Option<Mutation>,
118}
119
120impl fmt::Debug for Client {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 let credential = match &self.credential {
123 None => "none",
124 Some(Credential::Bearer(_)) => "bearer <redacted>",
125 Some(Credential::Proof(_)) => "proof <redacted>",
126 };
127 f.debug_struct("Client")
128 .field("base_url", &self.base.as_str())
129 .field("credential", &credential)
130 .field("telemetry", &self.telemetry)
131 .field("source", &self.source)
132 .finish_non_exhaustive()
133 }
134}
135
136pub fn is_loopback(url: &Url) -> bool {
138 match url.host() {
139 Some(url::Host::Domain(domain)) => domain == "localhost" || domain.ends_with(".localhost"),
140 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
141 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
142 None => false,
143 }
144}
145
146impl Client {
147 pub fn new(base_url: &str) -> Result<Self, Error> {
150 let invalid = |reason| Error::InvalidUrl {
151 url: base_url.to_owned(),
152 reason,
153 };
154 let mut base =
155 Url::parse(base_url.trim()).map_err(|_| invalid("it is not an absolute URL"))?;
156 match base.scheme() {
157 "https" => {}
158 "http" if is_loopback(&base) => {}
159 "http" => {
160 return Err(invalid(
161 "plain http is only allowed for this machine (localhost, 127.0.0.1, ::1); use https",
162 ));
163 }
164 _ => {
165 return Err(invalid(
166 "only https (and http for this machine) are supported",
167 ));
168 }
169 }
170 if base.host_str().is_none() || !base.username().is_empty() || base.password().is_some() {
171 return Err(invalid("it must name a host and carry no credentials"));
172 }
173 if base.query().is_some() || base.fragment().is_some() {
174 return Err(invalid("it must not have a query string or fragment"));
175 }
176 if !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/") {
177 return Err(invalid("use the origin, or the origin followed by /api/v1"));
178 }
179 base.set_path("/api/v1/");
180 let http = HttpClient::builder()
181 .redirect(reqwest::redirect::Policy::none())
182 .connect_timeout(Duration::from_secs(5))
183 .timeout(Duration::from_secs(30))
184 .user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
185 .build()
186 .map_err(|e| {
187 Error::Invalid(format!(
188 "Could not create the HTTP client: {}.",
189 root_cause(&e)
190 ))
191 })?;
192 Ok(Self {
193 http,
194 base,
195 credential: None,
196 telemetry: true,
197 source: "rust-client",
198 mutation: None,
199 })
200 }
201
202 #[must_use]
205 pub fn with_bearer(mut self, access_token: impl Into<String>) -> Self {
206 self.credential = Some(Credential::Bearer(SecretString::from(access_token.into())));
207 self
208 }
209
210 pub fn with_proof(mut self, proof: impl Into<String>) -> Result<Self, Error> {
215 let proof = proof.into();
216 let proof = proof.trim();
217 if proof.starts_with("sapr_") || !proof.starts_with("sap_") {
218 return Err(Error::Invalid(
219 "A User verification proof starts with `sap_` (a `sapr_…` value is the proof's refresh token, which is never sent to Commit).".into(),
220 ));
221 }
222 self.credential = Some(Credential::Proof(SecretString::from(proof.to_owned())));
223 Ok(self)
224 }
225
226 #[must_use]
228 pub fn with_telemetry(mut self, enabled: bool) -> Self {
229 self.telemetry = enabled;
230 self
231 }
232
233 pub fn with_source(mut self, source: &'static str) -> Result<Self, Error> {
236 if !matches!(source, "cli" | "browser" | "daemon" | "rust-client") {
237 return Err(Error::Invalid(format!(
238 "`{source}` is not a client source; use cli, browser, daemon or rust-client."
239 )));
240 }
241 self.source = source;
242 Ok(self)
243 }
244
245 #[must_use]
247 pub fn with_mutation(mut self, mutation: Mutation) -> Self {
248 self.mutation = Some(mutation);
249 self
250 }
251
252 pub fn base_url(&self) -> &Url {
254 &self.base
255 }
256
257 pub fn new_idempotency_key() -> String {
259 Mutation::new().key
260 }
261}
262
263impl Client {
265 pub async fn health(&self) -> Result<Value, Error> {
267 self.execute(Method::GET, &["healthz"], None, &[], Scope::Root)
268 .await
269 }
270
271 pub async fn ready(&self) -> Result<Value, Error> {
273 self.execute(Method::GET, &["readyz"], None, &[], Scope::Root)
274 .await
275 }
276
277 pub async fn version(&self) -> Result<Value, Error> {
279 self.execute(Method::GET, &["version"], None, &[], Scope::Public)
280 .await
281 }
282
283 pub async fn contracts(&self) -> Result<Value, Error> {
285 self.get(&["contracts"], &[]).await
286 }
287
288 pub async fn accounts(&self) -> Result<Value, Error> {
291 self.execute(Method::GET, &["accounts"], None, &[], Scope::Public)
292 .await
293 }
294
295 pub async fn me(&self) -> Result<Value, Error> {
298 self.get(&["me"], &[]).await
299 }
300
301 pub async fn silicon_allowlist(&self, silicon: &str) -> Result<Value, Error> {
305 self.get(&["silicons", silicon, "allowed-accounts"], &[])
306 .await
307 }
308
309 pub async fn allow_account(&self, silicon: &str, account: &str) -> Result<Value, Error> {
312 self.execute(
313 Method::PUT,
314 &["silicons", silicon, "allowed-accounts", account],
315 None,
316 &[],
317 Scope::Api,
318 )
319 .await
320 }
321
322 pub async fn disallow_account(&self, silicon: &str, account: &str) -> Result<Value, Error> {
324 self.execute(
325 Method::DELETE,
326 &["silicons", silicon, "allowed-accounts", account],
327 None,
328 &[],
329 Scope::Api,
330 )
331 .await
332 }
333
334 pub async fn email_settings(&self) -> Result<Value, Error> {
336 self.get(&["email-settings"], &[]).await
337 }
338
339 pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
341 self.write(Method::PUT, &["email-settings"], body).await
342 }
343
344 pub async fn report(&self, body: &Value) -> Result<Value, Error> {
347 self.write(Method::POST, &["reports"], body).await
348 }
349}
350
351impl Client {
353 pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
356 self.get(&["todos"], query).await
357 }
358 pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
360 self.get(&["todos", todo_id], &[]).await
361 }
362 pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
364 self.write(Method::POST, &["todos"], body).await
365 }
366 pub async fn update_todo<T: Serialize + ?Sized>(
368 &self,
369 todo_id: &str,
370 body: &T,
371 ) -> Result<Value, Error> {
372 self.write(Method::PATCH, &["todos", todo_id], body).await
373 }
374 pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
376 self.execute(Method::DELETE, &["todos", todo_id], None, &[], Scope::Api)
377 .await
378 }
379 pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
381 self.get(&["todos", todo_id, "notes"], query).await
382 }
383 pub async fn add_note<T: Serialize + ?Sized>(
385 &self,
386 todo_id: &str,
387 body: &T,
388 ) -> Result<Value, Error> {
389 self.write(Method::POST, &["todos", todo_id, "notes"], body)
390 .await
391 }
392 pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
394 self.get(&["todos", todo_id, "notification-subscription"], &[])
395 .await
396 }
397 pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
399 &self,
400 todo_id: &str,
401 body: &T,
402 ) -> Result<Value, Error> {
403 self.write(
404 Method::PUT,
405 &["todos", todo_id, "notification-subscription"],
406 body,
407 )
408 .await
409 }
410 pub async fn notification_settings(&self) -> Result<Value, Error> {
412 self.get(&["notification-settings"], &[]).await
413 }
414 pub async fn notification_settings_of(&self, silicon: &str) -> Result<Value, Error> {
417 self.get(&["notification-settings"], &[("silicon", silicon)])
418 .await
419 }
420 pub async fn update_notification_settings<T: Serialize + ?Sized>(
422 &self,
423 body: &T,
424 ) -> Result<Value, Error> {
425 self.write(Method::PUT, &["notification-settings"], body)
426 .await
427 }
428 pub async fn update_notification_settings_of<T: Serialize + ?Sized>(
431 &self,
432 silicon: &str,
433 body: &T,
434 ) -> Result<Value, Error> {
435 let body = serde_json::to_vec(body).map_err(Error::Decode)?;
436 self.execute(
437 Method::PUT,
438 &["notification-settings"],
439 Some(body),
440 &[("silicon", silicon)],
441 Scope::Api,
442 )
443 .await
444 }
445}
446
447impl Client {
449 pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
451 self.get(&["projects"], query).await
452 }
453 pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
455 self.get(&["projects", project_id], &[]).await
456 }
457 pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
459 self.write(Method::POST, &["projects"], body).await
460 }
461 pub async fn update_project<T: Serialize + ?Sized>(
463 &self,
464 project_id: &str,
465 body: &T,
466 ) -> Result<Value, Error> {
467 self.write(Method::PATCH, &["projects", project_id], body)
468 .await
469 }
470 pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
472 self.get(&["projects", project_id, "diary"], &[]).await
473 }
474 pub async fn replace_project_diary<T: Serialize + ?Sized>(
476 &self,
477 project_id: &str,
478 body: &T,
479 ) -> Result<Value, Error> {
480 self.write(Method::PUT, &["projects", project_id, "diary"], body)
481 .await
482 }
483 pub async fn project_tasks(
485 &self,
486 project_id: &str,
487 query: &[(&str, &str)],
488 ) -> Result<Value, Error> {
489 self.get(&["projects", project_id, "tasks"], query).await
490 }
491 pub async fn project_entries(
493 &self,
494 project_id: &str,
495 query: &[(&str, &str)],
496 ) -> Result<Value, Error> {
497 self.get(&["projects", project_id, "entries"], query).await
498 }
499 pub async fn create_project_task<T: Serialize + ?Sized>(
501 &self,
502 project_id: &str,
503 body: &T,
504 ) -> Result<Value, Error> {
505 self.write(Method::POST, &["projects", project_id, "tasks"], body)
506 .await
507 }
508 pub async fn update_project_task<T: Serialize + ?Sized>(
510 &self,
511 project_id: &str,
512 task_id: &str,
513 body: &T,
514 ) -> Result<Value, Error> {
515 self.write(
516 Method::PATCH,
517 &["projects", project_id, "tasks", task_id],
518 body,
519 )
520 .await
521 }
522 pub async fn claim_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
524 self.write(
525 Method::POST,
526 &["projects", project, "tasks", task, "claim"],
527 &serde_json::json!({}),
528 )
529 .await
530 }
531 pub async fn delete_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
533 self.execute(
534 Method::DELETE,
535 &["projects", project, "tasks", task],
536 None,
537 &[],
538 Scope::Api,
539 )
540 .await
541 }
542 pub async fn create_project_blocker<T: Serialize + ?Sized>(
544 &self,
545 project_id: &str,
546 body: &T,
547 ) -> Result<Value, Error> {
548 self.write(Method::POST, &["projects", project_id, "blockers"], body)
549 .await
550 }
551 pub async fn create_project_update<T: Serialize + ?Sized>(
553 &self,
554 project_id: &str,
555 body: &T,
556 ) -> Result<Value, Error> {
557 self.write(Method::POST, &["projects", project_id, "updates"], body)
558 .await
559 }
560 pub async fn complete_project<T: Serialize + ?Sized>(
562 &self,
563 project_id: &str,
564 body: &T,
565 ) -> Result<Value, Error> {
566 self.write(Method::POST, &["projects", project_id, "completion"], body)
567 .await
568 }
569 pub async fn project_versions(
571 &self,
572 project: &str,
573 query: &[(&str, &str)],
574 ) -> Result<Value, Error> {
575 self.get(&["projects", project, "versions"], query).await
576 }
577 pub async fn project_version(&self, project: &str, version: i64) -> Result<Value, Error> {
579 self.get(
580 &["projects", project, "versions", &version.to_string()],
581 &[],
582 )
583 .await
584 }
585}
586
587#[derive(Clone, Copy, PartialEq, Eq)]
589enum Scope {
590 Api,
592 Public,
594 Root,
596}
597
598impl Client {
599 async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
600 self.execute(Method::GET, path, None, query, Scope::Api)
601 .await
602 }
603
604 async fn write<T: Serialize + ?Sized>(
605 &self,
606 method: Method,
607 path: &[&str],
608 body: &T,
609 ) -> Result<Value, Error> {
610 let body = serde_json::to_vec(body).map_err(Error::Decode)?;
611 self.execute(method, path, Some(body), &[], Scope::Api)
612 .await
613 }
614
615 fn url(&self, path: &[&str], scope: Scope) -> Result<Url, Error> {
616 if path
617 .iter()
618 .any(|p| p.is_empty() || matches!(*p, "." | ".."))
619 {
620 return Err(Error::Invalid(
621 "Resource identifiers cannot be empty, `.` or `..`.".into(),
622 ));
623 }
624 let mut url = self.base.clone();
625 {
626 let mut segments = url.path_segments_mut().map_err(|()| Error::InvalidUrl {
627 url: self.base.to_string(),
628 reason: "it cannot carry a path",
629 })?;
630 if scope == Scope::Root {
631 segments.clear();
632 } else {
633 segments.pop_if_empty();
634 }
635 segments.extend(path);
636 }
637 Ok(url)
638 }
639
640 fn transport(&self, error: reqwest::Error) -> Error {
641 let error = error.without_url();
642 Error::Transport {
643 base: self.base.origin().ascii_serialization(),
644 cause: root_cause(&error),
645 source: error,
646 }
647 }
648
649 async fn execute(
650 &self,
651 method: Method,
652 path: &[&str],
653 body: Option<Vec<u8>>,
654 query: &[(&str, &str)],
655 scope: Scope,
656 ) -> Result<Value, Error> {
657 let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
658 let mut request = self
659 .http
660 .request(method, self.url(path, scope)?)
661 .query(query)
662 .header("x-commit-supported-versions", CONTRACT_VERSION.to_string())
663 .header("x-commit-client", self.source)
664 .header(
665 "x-commit-telemetry",
666 if self.telemetry { "on" } else { "off" },
667 );
668 if scope == Scope::Api {
669 match &self.credential {
670 Some(Credential::Bearer(token)) => {
671 request = request.bearer_auth(token.expose_secret())
672 }
673 Some(Credential::Proof(proof)) => {
674 request =
675 request.header("authorization", format!("Proof {}", proof.expose_secret()));
676 }
677 None => {}
678 }
679 }
680 if is_mutation {
681 let mutation = self.mutation.clone().unwrap_or_default();
682 request = request.header("idempotency-key", mutation.key);
683 if let Some(version) = mutation.version {
684 request = request.header("if-match", format!("\"{version}\""));
685 }
686 }
687 if let Some(body) = body {
688 request = request
689 .header("content-type", "application/json")
690 .body(body);
691 }
692 let mut response = request.send().await.map_err(|e| self.transport(e))?;
693 if let Some(served) = response.headers().get("x-commit-api-version")
694 && served.as_bytes() != CONTRACT_VERSION.to_string().as_bytes()
695 {
696 return Err(Error::UnsupportedContract {
697 served: String::from_utf8_lossy(served.as_bytes()).into_owned(),
698 });
699 }
700 let status = response.status();
701 let headers = response.headers().clone();
702 if response
703 .content_length()
704 .is_some_and(|n| n > RESPONSE_LIMIT as u64)
705 {
706 return Err(Error::ResponseTooLarge);
707 }
708 let mut bytes = Vec::new();
709 while let Some(chunk) = response.chunk().await.map_err(|e| self.transport(e))? {
710 if bytes.len() + chunk.len() > RESPONSE_LIMIT {
711 return Err(Error::ResponseTooLarge);
712 }
713 bytes.extend_from_slice(&chunk);
714 }
715 if !status.is_success() {
716 return Err(ApiError::from_response(status, &headers, &bytes).into());
717 }
718 if bytes.is_empty() {
719 return Ok(Value::Null);
720 }
721 serde_json::from_slice(&bytes).map_err(Error::Decode)
722 }
723}