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    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
132/// True for `localhost`, `*.localhost`, `127.0.0.0/8` and `::1`.
133pub 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    /// Accepts the API origin, or the origin followed by `/api/v1` or `/api/v1/`.
144    /// Plain `http://` is accepted only for this machine (`localhost`, `127.0.0.1`, `::1`).
145    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    /// Authenticates with a Silicon Accounts access token issued to Commit
197    /// (`Authorization: Bearer …`).
198    #[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    /// Authenticates as another app acting for an account, with a User verification proof
205    /// (`Authorization: Proof sap_…`) issued for Commit with the scopes of the actions you
206    /// call (each route's action id, e.g. `commit.todos.list`). Commit must allow your app
207    /// for those scopes.
208    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    /// Does nothing. Commit records no telemetry, so there is nothing to turn on or off.
221    #[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    /// Does nothing. The source only named the interface in request diagnostics, which
231    /// Commit no longer records. A value other than `cli`, `browser`, `daemon` or
232    /// `rust-client` is still refused.
233    #[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    /// Attaches retry/precondition inputs to writes made through this clone.
247    #[must_use]
248    pub fn with_mutation(mut self, mutation: Mutation) -> Self {
249        self.mutation = Some(mutation);
250        self
251    }
252
253    /// The `/api/v1/` base every request goes to.
254    pub fn base_url(&self) -> &Url {
255        &self.base
256    }
257
258    /// A fresh random idempotency key.
259    pub fn new_idempotency_key() -> String {
260        Mutation::new().key
261    }
262}
263
264/// Public and account endpoints.
265impl Client {
266    /// `GET /healthz` (public): the API process is alive.
267    pub async fn health(&self) -> Result<Value, Error> {
268        self.execute(Method::GET, &["healthz"], None, &[], Scope::Root)
269            .await
270    }
271
272    /// `GET /readyz` (public): the API can serve requests.
273    pub async fn ready(&self) -> Result<Value, Error> {
274        self.execute(Method::GET, &["readyz"], None, &[], Scope::Root)
275            .await
276    }
277
278    /// `GET /api/v1/version` (public): build metadata. Sends no credentials.
279    pub async fn version(&self) -> Result<Value, Error> {
280        self.execute(Method::GET, &["version"], None, &[], Scope::Public)
281            .await
282    }
283
284    /// `GET /api/v1/contracts`: the live compatibility matrix.
285    pub async fn contracts(&self) -> Result<Value, Error> {
286        self.get(&["contracts"], &[]).await
287    }
288
289    /// `GET /api/v1/accounts` (public): how to sign in to Commit (app id, Silicon Accounts
290    /// URLs, the scopes Commit honours). Sends no credentials.
291    pub async fn accounts(&self) -> Result<Value, Error> {
292        self.execute(Method::GET, &["accounts"], None, &[], Scope::Public)
293            .await
294    }
295
296    /// `GET /api/v1/me`: the caller as Commit sees it (uuid, id, kind, name, photo, shared
297    /// email, custodian or Silicons, and `via_app` for proofs).
298    pub async fn me(&self) -> Result<Value, Error> {
299        self.get(&["me"], &[]).await
300    }
301
302    /// `GET /api/v1/silicons/{silicon}/allowed-accounts`: the accounts a Silicon accepts todos
303    /// and project invitations from besides its custodian and the custodian's other Silicons
304    /// (read by the Silicon or its custodian).
305    pub async fn silicon_allowlist(&self, silicon: &str) -> Result<Value, Error> {
306        self.get(&["silicons", silicon, "allowed-accounts"], &[])
307            .await
308    }
309
310    /// `PUT /api/v1/silicons/{silicon}/allowed-accounts/{account}`: lets `account` reach
311    /// the Silicon.
312    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    /// `DELETE /api/v1/silicons/{silicon}/allowed-accounts/{account}`.
324    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    /// `GET /api/v1/email-settings`: the caller's email delivery preferences.
336    pub async fn email_settings(&self) -> Result<Value, Error> {
337        self.get(&["email-settings"], &[]).await
338    }
339
340    /// `PUT /api/v1/email-settings`: replaces the address and the subscribed events.
341    pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
342        self.write(Method::PUT, &["email-settings"], body).await
343    }
344
345    /// `POST /api/v1/reports`: a bug report for Commit's maintainers (keep the mutation
346    /// across retries).
347    pub async fn report(&self, body: &Value) -> Result<Value, Error> {
348        self.write(Method::POST, &["reports"], body).await
349    }
350}
351
352/// Todos, notes and notification rules.
353impl Client {
354    /// `GET /api/v1/todos` (`view`, `status`, `assigned_to`, `assigned_by`, `created_from`,
355    /// `created_to`, `limit`, `cursor`).
356    pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
357        self.get(&["todos"], query).await
358    }
359    /// `GET /api/v1/todos/{todo_id}`.
360    pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
361        self.get(&["todos", todo_id], &[]).await
362    }
363    /// `POST /api/v1/todos`.
364    pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
365        self.write(Method::POST, &["todos"], body).await
366    }
367    /// `PATCH /api/v1/todos/{todo_id}`.
368    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    /// `DELETE /api/v1/todos/{todo_id}`.
376    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    /// `GET /api/v1/todos/{todo_id}/notes`.
381    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    /// `POST /api/v1/todos/{todo_id}/notes`.
385    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    /// `GET /api/v1/todos/{todo_id}/notification-subscription`.
394    pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
395        self.get(&["todos", todo_id, "notification-subscription"], &[])
396            .await
397    }
398    /// `PUT /api/v1/todos/{todo_id}/notification-subscription`.
399    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    /// `GET /api/v1/notification-settings`: the caller's Silicon webhook settings.
412    pub async fn notification_settings(&self) -> Result<Value, Error> {
413        self.get(&["notification-settings"], &[]).await
414    }
415    /// `GET /api/v1/notification-settings?silicon=si:…`: a Silicon's settings, read by the
416    /// Silicon or its custodian.
417    pub async fn notification_settings_of(&self, silicon: &str) -> Result<Value, Error> {
418        self.get(&["notification-settings"], &[("silicon", silicon)])
419            .await
420    }
421    /// `PUT /api/v1/notification-settings`.
422    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    /// `PUT /api/v1/notification-settings?silicon=si:…`: replaces a Silicon's settings as
430    /// the Silicon or its custodian.
431    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
448/// Projects, diaries, tasks, entries and versions.
449impl Client {
450    /// `GET /api/v1/projects` (`status`, `silicon_id`, `limit`, `cursor`).
451    pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
452        self.get(&["projects"], query).await
453    }
454    /// `GET /api/v1/projects/{project_id}` (id or UID).
455    pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
456        self.get(&["projects", project_id], &[]).await
457    }
458    /// `POST /api/v1/projects`.
459    pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
460        self.write(Method::POST, &["projects"], body).await
461    }
462    /// `PATCH /api/v1/projects/{project_id}`.
463    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    /// `GET /api/v1/projects/{project_id}/diary`.
472    pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
473        self.get(&["projects", project_id, "diary"], &[]).await
474    }
475    /// `PUT /api/v1/projects/{project_id}/diary` (send `If-Match`).
476    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    /// `GET /api/v1/projects/{project_id}/tasks`.
485    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    /// `GET /api/v1/projects/{project_id}/entries`: blockers, updates and completion.
493    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    /// `POST /api/v1/projects/{project_id}/tasks`.
501    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    /// `PATCH /api/v1/projects/{project_id}/tasks/{task_id}`.
510    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    /// `POST /api/v1/projects/{project}/tasks/{task}/claim`: takes an unassigned task atomically.
524    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    /// `DELETE /api/v1/projects/{project}/tasks/{task}`: removes a task subtree and its linked todos.
533    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    /// `POST /api/v1/projects/{project_id}/blockers`.
544    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    /// `POST /api/v1/projects/{project_id}/updates`.
553    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    /// `POST /api/v1/projects/{project_id}/completion`.
562    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    /// `GET /api/v1/projects/{project}/versions` (`before`, `limit`): newest first.
571    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    /// `GET /api/v1/projects/{project}/versions/{version}`: one retained snapshot.
579    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/// Where a path lives and which credentials it gets.
589#[derive(Clone, Copy, PartialEq, Eq)]
590enum Scope {
591    /// `/api/v1/…` with the configured credential.
592    Api,
593    /// `/api/v1/…` without credentials.
594    Public,
595    /// `/…` at the origin (health checks), without credentials.
596    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            // Commit records no telemetry. Deployments older than 0.6.0 did unless asked not to.
665            .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}