Skip to main content

silicon_commit_client/
lib.rs

1//! Stateless client for Silicon Commit, the work manager for Carbons and Silicons.
2//!
3//! Two parts:
4//!
5//! * [`Client`]: the Commit HTTP API (`/api/v1`): todos, notes, notification rules,
6//!   projects, diaries, tasks, versions, email settings, bug reports, the Silicon
7//!   allow-list. Authenticate with a Silicon Accounts access token issued to Commit
8//!   ([`Client::with_bearer`]), or, from another app acting for an account, with a User
9//!   verification proof ([`Client::with_proof`]).
10//! * [`auth::AccountsAuth`]: sign-in for Commit's own tools at Silicon Accounts, with no
11//!   app secret: the device flow for Carbons, short-lived token exchange for Silicons,
12//!   refresh and revocation.
13//!
14//! Nothing is stored: callers own tokens and decide where they live. Keep a [`Mutation`]
15//! across retries of one logical write.
16//!
17//! ```no_run
18//! # async fn demo(access_token: String) -> Result<(), silicon_commit_client::Error> {
19//! use silicon_commit_client::{Client, Mutation};
20//! let commit = Client::new("https://api.commit.teamofsilicons.com")?.with_bearer(access_token);
21//! let mine = commit.list_todos(&[("view", "assigned_to_me")]).await?;
22//! let create = commit.with_mutation(Mutation::new());
23//! create
24//!     .create_todo(&serde_json::json!({"title": "Review the release", "assigned_to": "si:builder"}))
25//!     .await?;
26//! # Ok(()) }
27//! ```
28
29pub mod auth;
30mod error;
31
32pub use error::{ApiError, Error};
33/// Token wrappers used by [`auth::SignIn`] (tokens are redacted from `Debug` output).
34pub 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
44/// The production Commit API origin.
45pub const DEFAULT_API_URL: &str = "https://api.commit.teamofsilicons.com";
46/// The API contract this client speaks (`X-Commit-API-Version`).
47pub const CONTRACT_VERSION: u16 = 2;
48/// Largest response body read (8 MiB).
49const RESPONSE_LIMIT: usize = 8 * 1024 * 1024;
50
51/// A logical mutation: reuse the same value after an uncertain response so the API
52/// applies the write once (`Idempotency-Key`), optionally with `If-Match`.
53#[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    /// A new logical write with a random key.
67    pub fn new() -> Self {
68        Self {
69            key: Uuid::new_v4().to_string(),
70            version: None,
71        }
72    }
73
74    /// A logical write with your own key (8–255 visible ASCII characters).
75    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    /// Applies the write only if the resource is still at `version` (`If-Match`).
86    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    /// The idempotency key.
97    pub fn key(&self) -> &str {
98        &self.key
99    }
100}
101
102/// How requests authenticate.
103#[derive(Clone)]
104enum Credential {
105    Bearer(SecretString),
106    Proof(SecretString),
107}
108
109/// The Commit API client. Holds configuration and a connection pool, never sessions.
110#[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
136/// True for `localhost`, `*.localhost`, `127.0.0.0/8` and `::1`.
137pub 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    /// Accepts the API origin, or the origin followed by `/api/v1` or `/api/v1/`.
148    /// Plain `http://` is accepted only for this machine (`localhost`, `127.0.0.1`, `::1`).
149    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    /// Authenticates with a Silicon Accounts access token issued to Commit
203    /// (`Authorization: Bearer …`).
204    #[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    /// Authenticates as another app acting for an account, with a User verification proof
211    /// (`Authorization: Proof sap_…`) issued for Commit with the scopes of the actions you
212    /// call (each route's action id, e.g. `commit.todos.list`). Commit must allow your app
213    /// for those scopes.
214    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    /// Opts this client out of diagnostic telemetry (`X-Commit-Telemetry: off`).
227    #[must_use]
228    pub fn with_telemetry(mut self, enabled: bool) -> Self {
229        self.telemetry = enabled;
230        self
231    }
232
233    /// Names the interface in request diagnostics (`cli`, `browser`, `daemon` or
234    /// `rust-client`); no command arguments are sent.
235    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    /// Attaches retry/precondition inputs to writes made through this clone.
246    #[must_use]
247    pub fn with_mutation(mut self, mutation: Mutation) -> Self {
248        self.mutation = Some(mutation);
249        self
250    }
251
252    /// The `/api/v1/` base every request goes to.
253    pub fn base_url(&self) -> &Url {
254        &self.base
255    }
256
257    /// A fresh random idempotency key.
258    pub fn new_idempotency_key() -> String {
259        Mutation::new().key
260    }
261}
262
263/// Public and account endpoints.
264impl Client {
265    /// `GET /healthz` (public): the API process is alive.
266    pub async fn health(&self) -> Result<Value, Error> {
267        self.execute(Method::GET, &["healthz"], None, &[], Scope::Root)
268            .await
269    }
270
271    /// `GET /readyz` (public): the API can serve requests.
272    pub async fn ready(&self) -> Result<Value, Error> {
273        self.execute(Method::GET, &["readyz"], None, &[], Scope::Root)
274            .await
275    }
276
277    /// `GET /api/v1/version` (public): build metadata. Sends no credentials.
278    pub async fn version(&self) -> Result<Value, Error> {
279        self.execute(Method::GET, &["version"], None, &[], Scope::Public)
280            .await
281    }
282
283    /// `GET /api/v1/contracts`: the live compatibility matrix.
284    pub async fn contracts(&self) -> Result<Value, Error> {
285        self.get(&["contracts"], &[]).await
286    }
287
288    /// `GET /api/v1/accounts` (public): how to sign in to Commit (app id, Silicon Accounts
289    /// URLs, the scopes Commit honours). Sends no credentials.
290    pub async fn accounts(&self) -> Result<Value, Error> {
291        self.execute(Method::GET, &["accounts"], None, &[], Scope::Public)
292            .await
293    }
294
295    /// `GET /api/v1/me`: the caller as Commit sees it (uuid, id, kind, name, photo, shared
296    /// email, custodian or Silicons, and `via_app` for proofs).
297    pub async fn me(&self) -> Result<Value, Error> {
298        self.get(&["me"], &[]).await
299    }
300
301    /// `GET /api/v1/silicons/{silicon}/allowed-accounts`: the accounts a Silicon accepts todos
302    /// and project invitations from besides its custodian and the custodian's other Silicons
303    /// (read by the Silicon or its custodian).
304    pub async fn silicon_allowlist(&self, silicon: &str) -> Result<Value, Error> {
305        self.get(&["silicons", silicon, "allowed-accounts"], &[])
306            .await
307    }
308
309    /// `PUT /api/v1/silicons/{silicon}/allowed-accounts/{account}`: lets `account` reach
310    /// the Silicon.
311    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    /// `DELETE /api/v1/silicons/{silicon}/allowed-accounts/{account}`.
323    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    /// `GET /api/v1/email-settings`: the caller's email delivery preferences.
335    pub async fn email_settings(&self) -> Result<Value, Error> {
336        self.get(&["email-settings"], &[]).await
337    }
338
339    /// `PUT /api/v1/email-settings`: replaces the address and the subscribed events.
340    pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
341        self.write(Method::PUT, &["email-settings"], body).await
342    }
343
344    /// `POST /api/v1/reports`: a bug report for Commit's maintainers (keep the mutation
345    /// across retries).
346    pub async fn report(&self, body: &Value) -> Result<Value, Error> {
347        self.write(Method::POST, &["reports"], body).await
348    }
349}
350
351/// Todos, notes and notification rules.
352impl Client {
353    /// `GET /api/v1/todos` (`view`, `status`, `assigned_to`, `assigned_by`, `created_from`,
354    /// `created_to`, `limit`, `cursor`).
355    pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
356        self.get(&["todos"], query).await
357    }
358    /// `GET /api/v1/todos/{todo_id}`.
359    pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
360        self.get(&["todos", todo_id], &[]).await
361    }
362    /// `POST /api/v1/todos`.
363    pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
364        self.write(Method::POST, &["todos"], body).await
365    }
366    /// `PATCH /api/v1/todos/{todo_id}`.
367    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    /// `DELETE /api/v1/todos/{todo_id}`.
375    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    /// `GET /api/v1/todos/{todo_id}/notes`.
380    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    /// `POST /api/v1/todos/{todo_id}/notes`.
384    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    /// `GET /api/v1/todos/{todo_id}/notification-subscription`.
393    pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
394        self.get(&["todos", todo_id, "notification-subscription"], &[])
395            .await
396    }
397    /// `PUT /api/v1/todos/{todo_id}/notification-subscription`.
398    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    /// `GET /api/v1/notification-settings`: the caller's Silicon webhook settings.
411    pub async fn notification_settings(&self) -> Result<Value, Error> {
412        self.get(&["notification-settings"], &[]).await
413    }
414    /// `GET /api/v1/notification-settings?silicon=si:…`: a Silicon's settings, read by the
415    /// Silicon or its custodian.
416    pub async fn notification_settings_of(&self, silicon: &str) -> Result<Value, Error> {
417        self.get(&["notification-settings"], &[("silicon", silicon)])
418            .await
419    }
420    /// `PUT /api/v1/notification-settings`.
421    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    /// `PUT /api/v1/notification-settings?silicon=si:…`: replaces a Silicon's settings as
429    /// the Silicon or its custodian.
430    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
447/// Projects, diaries, tasks, entries and versions.
448impl Client {
449    /// `GET /api/v1/projects` (`status`, `silicon_id`, `limit`, `cursor`).
450    pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
451        self.get(&["projects"], query).await
452    }
453    /// `GET /api/v1/projects/{project_id}` (id or UID).
454    pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
455        self.get(&["projects", project_id], &[]).await
456    }
457    /// `POST /api/v1/projects`.
458    pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
459        self.write(Method::POST, &["projects"], body).await
460    }
461    /// `PATCH /api/v1/projects/{project_id}`.
462    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    /// `GET /api/v1/projects/{project_id}/diary`.
471    pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
472        self.get(&["projects", project_id, "diary"], &[]).await
473    }
474    /// `PUT /api/v1/projects/{project_id}/diary` (send `If-Match`).
475    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    /// `GET /api/v1/projects/{project_id}/tasks`.
484    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    /// `GET /api/v1/projects/{project_id}/entries`: blockers, updates and completion.
492    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    /// `POST /api/v1/projects/{project_id}/tasks`.
500    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    /// `PATCH /api/v1/projects/{project_id}/tasks/{task_id}`.
509    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    /// `POST /api/v1/projects/{project}/tasks/{task}/claim`: takes an unassigned task atomically.
523    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    /// `DELETE /api/v1/projects/{project}/tasks/{task}`: removes a task subtree and its linked todos.
532    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    /// `POST /api/v1/projects/{project_id}/blockers`.
543    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    /// `POST /api/v1/projects/{project_id}/updates`.
552    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    /// `POST /api/v1/projects/{project_id}/completion`.
561    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    /// `GET /api/v1/projects/{project}/versions` (`before`, `limit`): newest first.
570    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    /// `GET /api/v1/projects/{project}/versions/{version}`: one retained snapshot.
578    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/// Where a path lives and which credentials it gets.
588#[derive(Clone, Copy, PartialEq, Eq)]
589enum Scope {
590    /// `/api/v1/…` with the configured credential.
591    Api,
592    /// `/api/v1/…` without credentials.
593    Public,
594    /// `/…` at the origin (health checks), without credentials.
595    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}