Skip to main content

silicon_commit_client/
lib.rs

1//! Stateless client for Silicon Commit's public API.
2//!
3//! Supply organization and credential context explicitly. Keep a [`Mutation`]
4//! across retries of a logical write. Session persistence belongs to callers.
5
6use 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/// crates.io metadata used by the optional hourly updater.
16#[derive(Clone, Debug, Deserialize)]
17pub struct RegistryRelease {
18    pub version: String,
19}
20
21/// Returns the latest published client version when the registry is reachable.
22/// This is deliberately separate from API requests and never updates a running
23/// process; callers decide whether and how to install a newer binary/package.
24pub async fn latest_release() -> Result<RegistryRelease, Error> {
25    latest_release_for("silicon-commit-client").await
26}
27
28/// Returns the latest published CLI version when the registry is reachable.
29/// The CLI package is separate from the client library and therefore has its
30/// own registry check.
31pub 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/// Errors preserve status and correlation but never include credentials or raw proxy bodies.
69#[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    /// Whether the current access token needs renewal.
92    pub fn is_unauthenticated(&self) -> bool {
93        matches!(
94            self,
95            Self::Api {
96                status: StatusCode::UNAUTHORIZED,
97                ..
98            }
99        )
100    }
101}
102
103/// A logical mutation; reuse this value after an uncertain response.
104#[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/// Explicit connection/credential configuration; no session state is written to disk.
143#[derive(Clone, Debug)]
144pub struct Client {
145    http: HttpClient,
146    base: Url,
147    bearer: Option<SecretString>,
148    telemetry: bool,
149    source: &'static str,
150    test_key: Option<SecretString>,
151    org_id: Option<String>,
152    mutation: Option<Mutation>,
153}
154
155/// Login tokens are serialized only when the caller explicitly saves a session.
156#[derive(Clone, Deserialize, Serialize)]
157pub struct SessionTokens {
158    pub access_token: String,
159    pub refresh_token: String,
160    pub token_type: Value,
161    pub expires_in: i64,
162    pub scope: String,
163    pub actor: Value,
164    pub org_id: Option<String>,
165}
166impl fmt::Debug for SessionTokens {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        f.debug_struct("SessionTokens")
169            .field("tokens", &"<redacted>")
170            .field("org_id", &self.org_id)
171            .finish()
172    }
173}
174
175impl Client {
176    /// Accepts a bare backend origin, /api/v1, or /api/v1/.
177    pub fn new(base_url: &str) -> Result<Self, Error> {
178        let mut base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
179        if !matches!(base.scheme(), "http" | "https")
180            || base.host_str().is_none()
181            || !base.username().is_empty()
182            || base.password().is_some()
183            || base.query().is_some()
184            || base.fragment().is_some()
185            || !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/")
186        {
187            return Err(Error::InvalidUrl);
188        }
189        base.set_path("/api/v1/");
190        let http = HttpClient::builder()
191            .redirect(reqwest::redirect::Policy::none())
192            .connect_timeout(Duration::from_secs(5))
193            .timeout(Duration::from_secs(30))
194            .user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
195            .build()
196            .map_err(transport)?;
197        Ok(Self {
198            http,
199            base,
200            bearer: None,
201            telemetry: true,
202            source: "rust-client",
203            test_key: None,
204            org_id: None,
205            mutation: None,
206        })
207    }
208    pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
209        self.bearer = Some(SecretString::from(token.into()));
210        self
211    }
212    pub fn with_test_key(mut self, key: impl Into<String>) -> Result<Self, Error> {
213        let key = key.into();
214        if !valid_testing_selector(&key) {
215            return Err(Error::Invalid(
216                "test selector must be an IAM test app_secret or a legacy 32-character Commit key",
217            ));
218        }
219        self.test_key = Some(SecretString::from(key));
220        Ok(self)
221    }
222    /// Selects an IAM sandbox using only the imported application's secret.
223    pub fn with_test_app_secret(self, secret: impl Into<String>) -> Result<Self, Error> {
224        self.with_test_key(secret)
225    }
226    /// Live metadata for the selected environment. Never returns a secret.
227    pub async fn contracts(&self) -> Result<Value, Error> {
228        self.get(&["contracts"], &[]).await
229    }
230    /// Opts this client instance out of diagnostic telemetry.
231    pub fn with_telemetry(mut self, enabled: bool) -> Self {
232        self.telemetry = enabled;
233        self
234    }
235    /// Identifies the interface in request diagnostics; no command arguments are sent.
236    pub fn with_source(mut self, source: &'static str) -> Result<Self, Error> {
237        if !matches!(source, "cli" | "browser" | "daemon" | "rust-client") {
238            return Err(Error::Invalid("unknown client source"));
239        }
240        self.source = source;
241        Ok(self)
242    }
243    /// Reads this identity's organization email preferences.
244    pub async fn email_settings(&self) -> Result<Value, Error> {
245        self.get(&["email-settings"], &[]).await
246    }
247    /// Replaces the organization email and event subscriptions.
248    pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
249        self.write(Method::PUT, &["email-settings"], body).await
250    }
251    /// Queues a bug report for Commit maintainers; preserve the mutation across retries.
252    pub async fn report(&self, body: &Value) -> Result<Value, Error> {
253        self.write(Method::POST, &["reports"], body).await
254    }
255    pub async fn testing_context(&self) -> Result<Value, Error> {
256        self.get(&["testing-context"], &[]).await
257    }
258    /// Atomically take an unassigned task.
259    pub async fn claim_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
260        self.write(
261            Method::POST,
262            &["projects", project, "tasks", task, "claim"],
263            &serde_json::json!({}),
264        )
265        .await
266    }
267    /// Remove a task subtree and its linked todos.
268    pub async fn delete_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
269        self.execute(
270            Method::DELETE,
271            &["projects", project, "tasks", task],
272            None,
273            &[],
274            false,
275        )
276        .await
277    }
278    /// Retained project version metadata, newest first.
279    pub async fn project_versions(
280        &self,
281        project: &str,
282        query: &[(&str, &str)],
283    ) -> Result<Value, Error> {
284        self.get(&["projects", project, "versions"], query).await
285    }
286    /// One complete retained project snapshot.
287    pub async fn project_version(&self, project: &str, version: i64) -> Result<Value, Error> {
288        self.get(
289            &["projects", project, "versions", &version.to_string()],
290            &[],
291        )
292        .await
293    }
294    pub fn with_org_id(mut self, org_id: impl Into<String>) -> Self {
295        self.org_id = Some(org_id.into());
296        self
297    }
298    /// Attaches retry/precondition inputs to requests made through this clone.
299    pub fn with_mutation(mut self, mutation: Mutation) -> Self {
300        self.mutation = Some(mutation);
301        self
302    }
303    pub fn base_url(&self) -> &Url {
304        &self.base
305    }
306    pub fn new_idempotency_key() -> String {
307        Mutation::new().key
308    }
309    pub async fn health(&self) -> Result<Value, Error> {
310        self.execute(Method::GET, &["healthz"], None, &[], true)
311            .await
312    }
313    pub async fn ready(&self) -> Result<Value, Error> {
314        self.execute(Method::GET, &["readyz"], None, &[], true)
315            .await
316    }
317    pub async fn version(&self) -> Result<Value, Error> {
318        // `/version` is intentionally public; do not disclose caller context.
319        let mut public = self.clone();
320        public.bearer = None;
321        public.org_id = None;
322        public.test_key = None;
323        public.get(&["version"], &[]).await
324    }
325    /// Public IAM application metadata. Caller credentials and test context are omitted.
326    pub async fn iam(&self) -> Result<Value, Error> {
327        let mut public = self.clone();
328        public.bearer = None;
329        public.org_id = None;
330        public.test_key = None;
331        public.get(&["iam"], &[]).await
332    }
333    /// Verify the current login against IAM and return its public actor identity.
334    /// Missing or rejected credentials return `authenticated: false`; transport,
335    /// permission, and server failures remain errors. No tokens are returned.
336    pub async fn login_status(&self) -> Result<Value, Error> {
337        if self
338            .bearer
339            .as_ref()
340            .is_none_or(|token| token.expose_secret().is_empty())
341        {
342            return Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}));
343        }
344        match self.get(&["auth", "status"], &[]).await {
345            Err(error) if error.is_unauthenticated() => {
346                Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}))
347            }
348            result => result,
349        }
350    }
351    pub async fn login_with_slt(&self, slt: &str) -> Result<SessionTokens, Error> {
352        decode(
353            self.write(
354                Method::POST,
355                &["auth", "login"],
356                &serde_json::json!({"slt":slt}),
357            )
358            .await?,
359        )
360    }
361    pub async fn refresh_session(&self, refresh_token: &str) -> Result<SessionTokens, Error> {
362        decode(
363            self.write(
364                Method::POST,
365                &["auth", "refresh"],
366                &serde_json::json!({"refresh_token":refresh_token}),
367            )
368            .await?,
369        )
370    }
371    pub async fn logout(&self, token: &str) -> Result<(), Error> {
372        self.write(
373            Method::POST,
374            &["auth", "logout"],
375            &serde_json::json!({"token":token}),
376        )
377        .await?;
378        Ok(())
379    }
380    /// GET /api/v1/todos.
381    pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
382        self.get(&["todos"], query).await
383    }
384    /// GET /api/v1/todos/{todo_id}.
385    pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
386        self.get(&["todos", todo_id], &[]).await
387    }
388    /// POST /api/v1/todos.
389    pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
390        self.write(Method::POST, &["todos"], body).await
391    }
392    /// PATCH /api/v1/todos/{todo_id}.
393    pub async fn update_todo<T: Serialize + ?Sized>(
394        &self,
395        todo_id: &str,
396        body: &T,
397    ) -> Result<Value, Error> {
398        self.write(Method::PATCH, &["todos", todo_id], body).await
399    }
400    /// DELETE /api/v1/todos/{todo_id}.
401    pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
402        self.execute(Method::DELETE, &["todos", todo_id], None, &[], false)
403            .await
404    }
405    /// GET /api/v1/todos/{todo_id}/notes.
406    pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
407        self.get(&["todos", todo_id, "notes"], query).await
408    }
409    /// POST /api/v1/todos/{todo_id}/notes.
410    pub async fn add_note<T: Serialize + ?Sized>(
411        &self,
412        todo_id: &str,
413        body: &T,
414    ) -> Result<Value, Error> {
415        self.write(Method::POST, &["todos", todo_id, "notes"], body)
416            .await
417    }
418    /// GET /api/v1/todos/{todo_id}/notification-subscription.
419    pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
420        self.get(&["todos", todo_id, "notification-subscription"], &[])
421            .await
422    }
423    /// PUT /api/v1/todos/{todo_id}/notification-subscription.
424    pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
425        &self,
426        todo_id: &str,
427        body: &T,
428    ) -> Result<Value, Error> {
429        self.write(
430            Method::PUT,
431            &["todos", todo_id, "notification-subscription"],
432            body,
433        )
434        .await
435    }
436    /// GET /api/v1/notification-settings.
437    pub async fn notification_settings(&self) -> Result<Value, Error> {
438        self.get(&["notification-settings"], &[]).await
439    }
440    /// PUT /api/v1/notification-settings.
441    pub async fn update_notification_settings<T: Serialize + ?Sized>(
442        &self,
443        body: &T,
444    ) -> Result<Value, Error> {
445        self.write(Method::PUT, &["notification-settings"], body)
446            .await
447    }
448    /// GET /api/v1/projects.
449    pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
450        self.get(&["projects"], query).await
451    }
452    /// GET /api/v1/projects/{project_id}.
453    pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
454        self.get(&["projects", project_id], &[]).await
455    }
456    /// POST /api/v1/projects.
457    pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
458        self.write(Method::POST, &["projects"], body).await
459    }
460    /// PATCH /api/v1/projects/{project_id}.
461    pub async fn update_project<T: Serialize + ?Sized>(
462        &self,
463        project_id: &str,
464        body: &T,
465    ) -> Result<Value, Error> {
466        self.write(Method::PATCH, &["projects", project_id], body)
467            .await
468    }
469    /// GET /api/v1/projects/{project_id}/diary.
470    pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
471        self.get(&["projects", project_id, "diary"], &[]).await
472    }
473    /// PUT /api/v1/projects/{project_id}/diary.
474    pub async fn replace_project_diary<T: Serialize + ?Sized>(
475        &self,
476        project_id: &str,
477        body: &T,
478    ) -> Result<Value, Error> {
479        self.write(Method::PUT, &["projects", project_id, "diary"], body)
480            .await
481    }
482    /// GET /api/v1/projects/{project_id}/tasks.
483    pub async fn project_tasks(
484        &self,
485        project_id: &str,
486        query: &[(&str, &str)],
487    ) -> Result<Value, Error> {
488        self.get(&["projects", project_id, "tasks"], query).await
489    }
490    /// GET /api/v1/projects/{project_id}/entries.
491    pub async fn project_entries(
492        &self,
493        project_id: &str,
494        query: &[(&str, &str)],
495    ) -> Result<Value, Error> {
496        self.get(&["projects", project_id, "entries"], query).await
497    }
498    /// POST /api/v1/projects/{project_id}/tasks.
499    pub async fn create_project_task<T: Serialize + ?Sized>(
500        &self,
501        project_id: &str,
502        body: &T,
503    ) -> Result<Value, Error> {
504        self.write(Method::POST, &["projects", project_id, "tasks"], body)
505            .await
506    }
507    /// PATCH /api/v1/projects/{project_id}/tasks/{task_id}.
508    pub async fn update_project_task<T: Serialize + ?Sized>(
509        &self,
510        project_id: &str,
511        task_id: &str,
512        body: &T,
513    ) -> Result<Value, Error> {
514        self.write(
515            Method::PATCH,
516            &["projects", project_id, "tasks", task_id],
517            body,
518        )
519        .await
520    }
521    /// POST /api/v1/projects/{project_id}/blockers.
522    pub async fn create_project_blocker<T: Serialize + ?Sized>(
523        &self,
524        project_id: &str,
525        body: &T,
526    ) -> Result<Value, Error> {
527        self.write(Method::POST, &["projects", project_id, "blockers"], body)
528            .await
529    }
530    /// POST /api/v1/projects/{project_id}/updates.
531    pub async fn create_project_update<T: Serialize + ?Sized>(
532        &self,
533        project_id: &str,
534        body: &T,
535    ) -> Result<Value, Error> {
536        self.write(Method::POST, &["projects", project_id, "updates"], body)
537            .await
538    }
539    /// POST /api/v1/projects/{project_id}/completion.
540    pub async fn complete_project<T: Serialize + ?Sized>(
541        &self,
542        project_id: &str,
543        body: &T,
544    ) -> Result<Value, Error> {
545        self.write(Method::POST, &["projects", project_id, "completion"], body)
546            .await
547    }
548    /// GET /api/v1/test-environments.
549    pub async fn list_test_environments(&self) -> Result<Value, Error> {
550        self.get(&["test-environments"], &[]).await
551    }
552    /// Legacy creation route, retained for source compatibility.
553    /// New creation returns `honeycomb_manages_testing_lifecycle`; create through Honeycomb.
554    pub async fn create_test_environment<T: Serialize + ?Sized>(
555        &self,
556        body: &T,
557    ) -> Result<Value, Error> {
558        self.write(Method::POST, &["test-environments"], body).await
559    }
560    /// GET /api/v1/test-environments/{id}/key.
561    pub async fn retrieve_test_environment_key(&self, id: &str) -> Result<Value, Error> {
562        self.get(&["test-environments", id, "key"], &[]).await
563    }
564    /// POST /api/v1/test-environments/{id}/rotate.
565    pub async fn rotate_test_environment(&self, id: &str) -> Result<Value, Error> {
566        self.execute(
567            Method::POST,
568            &["test-environments", id, "rotate"],
569            None,
570            &[],
571            false,
572        )
573        .await
574    }
575    /// DELETE /api/v1/test-environments/{id}.
576    pub async fn delete_test_environment(&self, id: &str) -> Result<Value, Error> {
577        self.execute(Method::DELETE, &["test-environments", id], None, &[], false)
578            .await
579    }
580    pub async fn restore_test_environment(&self, id: &str) -> Result<Value, Error> {
581        self.execute(
582            Method::POST,
583            &["test-environments", id, "restore"],
584            None,
585            &[],
586            false,
587        )
588        .await
589    }
590    pub async fn clean_test_environment(&self, id: &str) -> Result<Value, Error> {
591        self.execute(
592            Method::POST,
593            &["test-environments", id, "clean"],
594            None,
595            &[],
596            false,
597        )
598        .await
599    }
600    async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
601        self.execute(Method::GET, path, None, query, false).await
602    }
603    async fn write<T: Serialize + ?Sized>(
604        &self,
605        method: Method,
606        path: &[&str],
607        body: &T,
608    ) -> Result<Value, Error> {
609        let body = serde_json::to_vec(body).map_err(Error::Decode)?;
610        self.execute(method, path, Some(body), &[], false).await
611    }
612    fn url(&self, path: &[&str], root: bool) -> Result<Url, Error> {
613        if path
614            .iter()
615            .any(|p| p.is_empty() || matches!(*p, "." | ".."))
616        {
617            return Err(Error::Invalid(
618                "empty and dot resource identifiers are forbidden",
619            ));
620        }
621        let mut url = self.base.clone();
622        {
623            let mut segments = url.path_segments_mut().map_err(|_| Error::InvalidUrl)?;
624            if root {
625                segments.clear();
626            } else {
627                segments.pop_if_empty();
628            }
629            segments.extend(path);
630        }
631        Ok(url)
632    }
633    async fn execute(
634        &self,
635        method: Method,
636        path: &[&str],
637        body: Option<Vec<u8>>,
638        query: &[(&str, &str)],
639        root: bool,
640    ) -> Result<Value, Error> {
641        let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
642        let mut req = self
643            .http
644            .request(method, self.url(path, root)?)
645            .query(query)
646            .header("x-commit-supported-versions", "1")
647            .header("x-commit-client", self.source)
648            .header(
649                "x-commit-telemetry",
650                if self.telemetry { "on" } else { "off" },
651            );
652        if !root {
653            if let Some(token) = &self.bearer {
654                req = req.bearer_auth(token.expose_secret());
655            }
656            if let Some(org) = &self.org_id {
657                req = req.header("x-org-id", org);
658            }
659            if let Some(key) = &self.test_key {
660                let key = key.expose_secret();
661                if !valid_testing_selector(key) {
662                    return Err(Error::Invalid("invalid testing application secret"));
663                }
664                req = req.header("x-testing-environment-key", key);
665            }
666        }
667        if is_mutation {
668            let mutation = self.mutation.clone().unwrap_or_default();
669            req = req.header("idempotency-key", mutation.key);
670            if let Some(version) = mutation.version {
671                req = req.header("if-match", format!("\"{version}\""));
672            }
673        }
674        if let Some(body) = body {
675            req = req.header("content-type", "application/json").body(body);
676        }
677        let mut response = req.send().await.map_err(transport)?;
678        if response
679            .headers()
680            .get("x-commit-api-version")
681            .is_some_and(|v| v != "1")
682        {
683            return Err(Error::Invalid(
684                "server selected an unsupported API contract",
685            ));
686        }
687        let status = response.status();
688        let request_id = response
689            .headers()
690            .get("x-request-id")
691            .and_then(|v| v.to_str().ok())
692            .map(str::to_owned);
693        const LIMIT: usize = 8 * 1024 * 1024;
694        if response.content_length().is_some_and(|n| n > LIMIT as u64) {
695            return Err(Error::ResponseTooLarge);
696        }
697        let mut bytes = Vec::new();
698        while let Some(chunk) = response.chunk().await.map_err(transport)? {
699            if bytes.len() + chunk.len() > LIMIT {
700                return Err(Error::ResponseTooLarge);
701            }
702            bytes.extend_from_slice(&chunk);
703        }
704        if !status.is_success() {
705            let parsed: Option<Value> = serde_json::from_slice(&bytes).ok();
706            let code = parsed
707                .as_ref()
708                .and_then(|v| v.pointer("/error/code"))
709                .and_then(Value::as_str)
710                .filter(|s| {
711                    s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
712                })
713                .unwrap_or("http_error")
714                .to_owned();
715            return Err(Error::Api {
716                status,
717                code,
718                request_id,
719            });
720        }
721        if bytes.is_empty() {
722            return Ok(Value::Null);
723        }
724        serde_json::from_slice(&bytes).map_err(Error::Decode)
725    }
726}
727fn transport(error: reqwest::Error) -> Error {
728    Error::Transport(error.without_url())
729}
730fn decode<T: DeserializeOwned>(value: Value) -> Result<T, Error> {
731    serde_json::from_value(value).map_err(Error::Decode)
732}
733
734fn valid_testing_selector(key: &str) -> bool {
735    (key.len() == 32 && key.bytes().all(|b| b.is_ascii_alphanumeric()))
736        || (key.len() == 47
737            && key.starts_with("ask_")
738            && key[4..]
739                .bytes()
740                .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
741}