Skip to main content

wavekat_platform_client/
client.rs

1//! `Client` — reqwest-backed bearer-auth HTTP against `platform.wavekat.com`.
2//!
3//! Ported from `wavekat-cli/src/client.rs`. Two intentional changes vs.
4//! the CLI:
5//!
6//!   1. Storage-agnostic constructor: `Client::new(base_url, token)`
7//!      instead of `Client::from_config()`. Reading auth.json belongs in
8//!      the consumer (see this crate's `CLAUDE.md`).
9//!   2. Typed errors via [`crate::Error`] instead of `anyhow::Result`.
10//!      Consumers that prefer `anyhow` can `?` straight through.
11//!
12//! Surface stays close to the CLI so the CLI's eventual migration is
13//! mechanical.
14
15use futures_util::StreamExt;
16use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
17use serde::de::DeserializeOwned;
18use serde::Serialize;
19use tokio::io::AsyncWriteExt;
20
21use crate::error::{classify_unauthorized, Error, Result};
22use crate::sign::{self, ReleaseCredential};
23use crate::token::Token;
24
25/// HTTP client with the bearer token baked into its default headers.
26///
27/// Cheap to clone (it's a thin wrapper around `reqwest::Client`, which is
28/// itself an `Arc` internally), so prefer cloning over re-building.
29#[derive(Clone)]
30pub struct Client {
31    inner: reqwest::Client,
32    base_url: String,
33}
34
35impl Client {
36    /// Build a client for the given platform base URL, authenticated with
37    /// `token`. The base URL's trailing slash (if any) is stripped.
38    pub fn new(base_url: impl Into<String>, token: Token) -> Result<Self> {
39        let mut headers = HeaderMap::new();
40        let value = format!("Bearer {}", token.as_str());
41        let header = HeaderValue::from_str(&value)
42            .map_err(|_| Error::BadRequest("token contained invalid bytes".into()))?;
43        headers.insert(AUTHORIZATION, header);
44
45        let inner = reqwest::Client::builder()
46            .default_headers(headers)
47            .user_agent(concat!(
48                "wavekat-platform-client/",
49                env!("CARGO_PKG_VERSION")
50            ))
51            .build()?;
52        Ok(Self {
53            inner,
54            base_url: base_url.into().trim_end_matches('/').to_string(),
55        })
56    }
57
58    /// Base URL the client was configured with, with any trailing slash
59    /// stripped. Useful for callers that want to print a clickable link
60    /// alongside an API result (`{base_url}/projects/…`).
61    pub fn base_url(&self) -> &str {
62        &self.base_url
63    }
64
65    fn url(&self, path: &str) -> String {
66        format!("{}{}", self.base_url, path)
67    }
68
69    /// `GET {path}` and decode the JSON response.
70    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
71        let url = self.url(path);
72        let resp = self.inner.get(&url).send().await?;
73        decode(url, resp).await
74    }
75
76    /// `GET {path}?query` and decode the JSON response. `query` is any
77    /// `serde::Serialize` — typically a `&[(K, V)]` or a struct.
78    pub async fn get_json_query<T: DeserializeOwned, Q: Serialize + ?Sized>(
79        &self,
80        path: &str,
81        query: &Q,
82    ) -> Result<T> {
83        let url = self.url(path);
84        let resp = self.inner.get(&url).query(query).send().await?;
85        decode(url, resp).await
86    }
87
88    /// `POST {path}` with `body` serialized as JSON, decode the JSON
89    /// response.
90    pub async fn post_json<T: DeserializeOwned, B: Serialize + ?Sized>(
91        &self,
92        path: &str,
93        body: &B,
94    ) -> Result<T> {
95        let url = self.url(path);
96        let resp = self.inner.post(&url).json(body).send().await?;
97        decode(url, resp).await
98    }
99
100    /// `POST {path}` with no body, expecting an empty/ignored response.
101    pub async fn post_empty(&self, path: &str) -> Result<()> {
102        let url = self.url(path);
103        let resp = self.inner.post(&url).send().await?;
104        ensure_success(url, resp).await
105    }
106
107    /// `POST {path}` with no body, decoding the JSON response. The CLI
108    /// uses this for `…/finalize` endpoints that take no body but return
109    /// the updated row.
110    pub async fn post_empty_returning_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
111        let url = self.url(path);
112        let resp = self.inner.post(&url).send().await?;
113        decode(url, resp).await
114    }
115
116    /// `DELETE {path}`.
117    pub async fn delete(&self, path: &str) -> Result<()> {
118        let url = self.url(path);
119        let resp = self.inner.delete(&url).send().await?;
120        ensure_success(url, resp).await
121    }
122
123    /// `PUT {path}` with `body` as `application/octet-stream`. Used by
124    /// the CLI's `models push` to ship bytes through the platform's
125    /// proxy upload route when R2 isn't directly reachable.
126    pub async fn put_proxy_bytes(&self, path: &str, body: Vec<u8>) -> Result<()> {
127        self.put_raw_bytes(path, "application/octet-stream", body)
128            .await
129    }
130
131    /// `PUT {path}` with `body` and a caller-chosen content type. The
132    /// bearer's auth header rides along (per the default-headers map),
133    /// so this is for routes on the platform itself — not for
134    /// presigned R2 PUTs. Voice recording bytes go through here.
135    pub async fn put_raw_bytes(&self, path: &str, content_type: &str, body: Vec<u8>) -> Result<()> {
136        let url = self.url(path);
137        let resp = self
138            .inner
139            .put(&url)
140            .header(reqwest::header::CONTENT_TYPE, content_type)
141            .body(body)
142            .send()
143            .await?;
144        ensure_success(url, resp).await
145    }
146
147    /// `PUT` raw bytes to a presigned URL. Deliberately uses a *fresh*
148    /// `reqwest::Client` (no auth headers) — adding `Authorization:
149    /// Bearer …` would make S3/R2 reject the request because it's not
150    /// part of the SigV4 query-string signature.
151    pub async fn put_presigned_bytes(presigned_url: &str, body: Vec<u8>) -> Result<()> {
152        let resp = reqwest::Client::new()
153            .put(presigned_url)
154            .body(body)
155            .send()
156            .await?;
157        ensure_success(presigned_url.to_string(), resp).await
158    }
159
160    /// `POST {base_url}{path}` with `body` as JSON against a public,
161    /// unauthenticated platform endpoint. Deliberately builds a *fresh*
162    /// `reqwest::Client` (like [`Client::put_presigned_bytes`]) so the
163    /// request carries no `Authorization` header: sending a bearer to a
164    /// route that doesn't expect one can trip surprising server-side
165    /// branches, and a token-less request is the honest shape for an
166    /// endpoint that runs before any sign-in.
167    ///
168    /// Used by callers that report something before a user has
169    /// authenticated — e.g. the anonymous first-run install heartbeat
170    /// (see [`Client::install_heartbeat`]). For authenticated writes use
171    /// [`Client::post_json`].
172    pub async fn post_public_json<T: DeserializeOwned, B: Serialize + ?Sized>(
173        base_url: &str,
174        path: &str,
175        body: &B,
176    ) -> Result<T> {
177        let base = base_url.trim_end_matches('/');
178        let url = format!("{}{}", base, path);
179        let resp = reqwest::Client::new().post(&url).json(body).send().await?;
180        decode(url, resp).await
181    }
182
183    /// `POST {base_url}{path}` with `body` as JSON against a public,
184    /// unauthenticated endpoint, **signed** with a release credential so
185    /// the platform can verify the request came from a genuine release.
186    ///
187    /// Like [`Client::post_public_json`] this builds a fresh, token-less
188    /// `reqwest::Client` (the endpoint runs before any sign-in), but it
189    /// additionally signs the request with the per-version key in `cred`
190    /// and forwards `cred`'s certificate so the platform can establish
191    /// trust from only the master public key, and reject stale replays —
192    /// see [`crate::sign`] (and [`ReleaseCredential`]) for the scheme.
193    ///
194    /// The exact JSON bytes serialized here are both what gets hashed into
195    /// the signature and what is sent as the body, so the platform's
196    /// body-hash check lines up byte-for-byte. The `X-WK-*` headers carry
197    /// the scheme version, timestamp, nonce, build version, per-version
198    /// public key, certificate, and request signature.
199    ///
200    /// General-purpose: any public endpoint that needs release
201    /// attestation uses this. The anonymous first-run install heartbeat
202    /// (see [`Client::install_heartbeat`]) is the first consumer.
203    pub async fn post_public_signed_json<T: DeserializeOwned, B: Serialize + ?Sized>(
204        base_url: &str,
205        path: &str,
206        body: &B,
207        cred: &ReleaseCredential,
208    ) -> Result<T> {
209        let base = base_url.trim_end_matches('/');
210        let url = format!("{}{}", base, path);
211        // Serialize once: sign the same bytes we send so the platform's
212        // body-hash matches exactly (a re-serialize could, in principle,
213        // reorder map keys and break the hash).
214        let body_bytes = serde_json::to_vec(body)
215            .map_err(|e| Error::BadRequest(format!("serializing signed request body: {e}")))?;
216        let rs = cred.sign_request("POST", path, &body_bytes)?;
217        let resp = reqwest::Client::new()
218            .post(&url)
219            .header(CONTENT_TYPE, "application/json")
220            .header(sign::HEADER_VERSION, sign::SIG_VERSION)
221            .header(sign::HEADER_TIMESTAMP, rs.timestamp)
222            .header(sign::HEADER_NONCE, rs.nonce)
223            .header(sign::HEADER_BUILD_VERSION, &cred.version)
224            .header(sign::HEADER_PUBKEY, &cred.public_key_hex)
225            .header(sign::HEADER_CERT, &cred.cert_hex)
226            .header(sign::HEADER_SIGNATURE, rs.signature_hex)
227            .body(body_bytes)
228            .send()
229            .await?;
230        decode(url, resp).await
231    }
232
233    /// `GET {base_url}{path}?{query}` against a public, unauthenticated
234    /// platform endpoint. Like [`Client::put_presigned_bytes`], builds
235    /// a fresh `reqwest::Client` so the request carries no
236    /// `Authorization` header — sending one to an endpoint that doesn't
237    /// expect it can trigger surprising server-side branches and
238    /// defeats edge-cache key uniformity.
239    ///
240    /// Used by callers that need to read public configuration before
241    /// any user has signed in (e.g. provider-preset lookups during
242    /// desktop-client onboarding). For authenticated reads use
243    /// [`Client::get_json`] or [`Client::get_json_query`].
244    pub async fn get_public_json<T: DeserializeOwned>(
245        base_url: &str,
246        path: &str,
247        query: &[(&str, &str)],
248    ) -> Result<T> {
249        let base = base_url.trim_end_matches('/');
250        let url = format!("{}{}", base, path);
251        let mut req = reqwest::Client::new().get(&url);
252        if !query.is_empty() {
253            req = req.query(query);
254        }
255        let resp = req.send().await?;
256        decode(url, resp).await
257    }
258
259    /// `GET {base_url}{path}` returning raw bytes against a public,
260    /// unauthenticated platform endpoint. Like [`Client::get_public_json`],
261    /// builds a fresh `reqwest::Client` with no `Authorization` header —
262    /// the endpoint runs before any sign-in.
263    ///
264    /// Used by callers that need to read public binary content before
265    /// authentication — e.g. system flow audio clips. For authenticated
266    /// binary reads use [`Client::get_bytes`].
267    pub async fn get_public_bytes(base_url: &str, path: &str) -> Result<Vec<u8>> {
268        let base = base_url.trim_end_matches('/');
269        let url = format!("{}{}", base, path);
270        let resp = reqwest::Client::new().get(&url).send().await?;
271        let status = resp.status();
272        if !status.is_success() {
273            let body = resp.text().await.unwrap_or_default();
274            return Err(http_error(status.as_u16(), url, body));
275        }
276        Ok(resp.bytes().await?.to_vec())
277    }
278
279    /// Stream a `GET` response body into `sink`. Returns the number of
280    /// bytes written. Used for big payloads (manifests, audio clips)
281    /// where holding the whole body in memory would be wasteful.
282    pub async fn get_stream_to<W: AsyncWriteExt + Unpin>(
283        &self,
284        path: &str,
285        sink: &mut W,
286    ) -> Result<u64> {
287        let url = self.url(path);
288        let resp = self.inner.get(&url).send().await?;
289        let status = resp.status();
290        if !status.is_success() {
291            let body = resp.text().await.unwrap_or_default();
292            return Err(http_error(status.as_u16(), url, body));
293        }
294        let mut stream = resp.bytes_stream();
295        let mut written: u64 = 0;
296        while let Some(chunk) = stream.next().await {
297            let bytes = chunk?;
298            sink.write_all(&bytes).await?;
299            written += bytes.len() as u64;
300        }
301        sink.flush().await?;
302        Ok(written)
303    }
304
305    /// `GET {path}` returning the raw response body in memory. For small
306    /// binary payloads a caller wants as a `Vec<u8>` — e.g. a frozen
307    /// flow-audio clip (tens of KB) to hand to an atomic on-disk writer.
308    /// For large streams prefer [`Client::get_stream_to`].
309    pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
310        let url = self.url(path);
311        let resp = self.inner.get(&url).send().await?;
312        let status = resp.status();
313        if !status.is_success() {
314            let body = resp.text().await.unwrap_or_default();
315            return Err(http_error(status.as_u16(), url, body));
316        }
317        Ok(resp.bytes().await?.to_vec())
318    }
319}
320
321async fn decode<T: DeserializeOwned>(url: String, resp: reqwest::Response) -> Result<T> {
322    let status = resp.status();
323    let text = resp.text().await?;
324    if !status.is_success() {
325        return Err(http_error(status.as_u16(), url, text));
326    }
327    serde_json::from_str(&text).map_err(|source| Error::Decode { url, source })
328}
329
330async fn ensure_success(url: String, resp: reqwest::Response) -> Result<()> {
331    let status = resp.status();
332    if status.is_success() {
333        return Ok(());
334    }
335    let body = resp.text().await.unwrap_or_default();
336    Err(http_error(status.as_u16(), url, body))
337}
338
339/// Map an HTTP error response to the matching [`Error`] variant. 401
340/// gets its own [`Error::Unauthorized`] so consumers can render a
341/// tailored "sign in again" message — or [`Error::ReauthRequired`] when
342/// the body says the credential is merely too old, which asks for a
343/// fresh sign-in rather than a dead session; everything else stays as
344/// [`Error::Http`].
345fn http_error(status: u16, url: String, body: String) -> Error {
346    let body = truncate(&body, 500).to_string();
347    if status == 401 {
348        classify_unauthorized(url, body)
349    } else {
350        Error::Http { status, url, body }
351    }
352}
353
354fn truncate(s: &str, n: usize) -> &str {
355    if s.len() > n {
356        // Walk back to the previous char boundary so we don't slice a
357        // multibyte UTF-8 sequence (the CLI's version of this used a
358        // raw byte slice, which is a panic waiting for a non-ASCII
359        // error body).
360        let mut end = n;
361        while end > 0 && !s.is_char_boundary(end) {
362            end -= 1;
363        }
364        &s[..end]
365    } else {
366        s
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    /// A one-shot HTTP/1.1 server on an ephemeral port. Returns its base
375    /// URL; the thread answers exactly one request with `status` and
376    /// `body`, then exits. `std::net` rather than `tokio::net` because the
377    /// dev-dependency carries no `net` feature — the same reason
378    /// `oauth.rs` binds its redirect listener synchronously.
379    fn one_shot_server(status: &str, body: &'static str) -> String {
380        use std::io::{Read, Write};
381        use std::net::TcpListener;
382
383        let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
384        let port = listener.local_addr().expect("local addr").port();
385        let status = status.to_string();
386        std::thread::spawn(move || {
387            let Ok((mut stream, _)) = listener.accept() else {
388                return;
389            };
390            // Drain just enough to let the client finish writing; we never
391            // parse the request — the test only cares about the response.
392            let mut buf = [0u8; 1024];
393            let _ = stream.read(&mut buf);
394            let response = format!(
395                "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
396                body.len()
397            );
398            let _ = stream.write_all(response.as_bytes());
399            let _ = stream.flush();
400        });
401        format!("http://127.0.0.1:{port}")
402    }
403
404    #[tokio::test]
405    async fn get_public_bytes_returns_the_body_on_success() {
406        // The happy path the system-flow clip fetch rides: no bearer
407        // header is sent, and the raw bytes come back untouched.
408        let base = one_shot_server("200 OK", "RIFF....WAVE");
409        let bytes =
410            Client::get_public_bytes(&base, "/api/voice/flows/system/f/versions/1/assets/a/bytes")
411                .await
412                .expect("public bytes");
413        assert_eq!(bytes, b"RIFF....WAVE");
414    }
415
416    #[tokio::test]
417    async fn get_public_bytes_surfaces_a_non_2xx_as_http_error() {
418        // The failure path a signed-out device hits when a clip has been
419        // unpublished: an `Error::Http` carrying the status and the body,
420        // never an empty Ok. Callers skip that clip and leave the flow
421        // unarmable rather than caching silence.
422        let base = one_shot_server("404 Not Found", "{\"error\":\"not found\"}");
423        let err = Client::get_public_bytes(
424            &base,
425            "/api/voice/flows/system/nope/versions/1/assets/a/bytes",
426        )
427        .await
428        .expect_err("404 must not decode as success");
429        match err {
430            Error::Http {
431                status, ref body, ..
432            } => {
433                assert_eq!(status, 404);
434                assert!(body.contains("not found"), "{body}");
435            }
436            other => panic!("expected Error::Http, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn http_error_format_matches_cli_shape() {
442        // Regression guard: `Display` for `Error::Http` should format
443        // "{status} {url}: {body}" — matches what the CLI's old `decode`
444        // produced via `anyhow!`. Consumers (and grep-driven debugging)
445        // depend on the shape.
446        let e = Error::Http {
447            status: 500,
448            url: "https://platform.wavekat.com/api/me".into(),
449            body: "boom".into(),
450        };
451        let s = e.to_string();
452        assert!(s.contains("500"), "{s}");
453        assert!(s.contains("https://platform.wavekat.com/api/me"), "{s}");
454        assert!(s.contains("boom"), "{s}");
455    }
456
457    #[test]
458    fn http_error_splits_401_into_unauthorized() {
459        // 401 routes to the dedicated variant so consumers can match on
460        // it instead of inspecting `status == 401`.
461        let e = http_error(
462            401,
463            "https://platform.wavekat.com/api/me".into(),
464            "{\"error\":\"unauthenticated\"}".into(),
465        );
466        assert!(
467            matches!(e, Error::Unauthorized { .. }),
468            "expected Unauthorized, got {e:?}"
469        );
470        // Display still mentions 401 + url so logs stay greppable.
471        let s = e.to_string();
472        assert!(s.contains("401"), "{s}");
473        assert!(s.contains("https://platform.wavekat.com/api/me"), "{s}");
474    }
475
476    #[test]
477    fn http_error_splits_a_stale_credential_out_of_unauthorized() {
478        // A 401 that names `reauth_required` is not a dead session: the
479        // remedy is signing in again and retrying the same call, so it
480        // must not collapse into Unauthorized on the way through.
481        let e = http_error(
482            401,
483            "https://platform.wavekat.com/api/me".into(),
484            "{\"error\":\"reauth_required\"}".into(),
485        );
486        assert!(
487            matches!(e, Error::ReauthRequired { .. }),
488            "expected ReauthRequired, got {e:?}"
489        );
490    }
491
492    #[test]
493    fn http_error_keeps_non_401_in_http_variant() {
494        let e = http_error(
495            500,
496            "https://platform.wavekat.com/api/me".into(),
497            "boom".into(),
498        );
499        assert!(
500            matches!(e, Error::Http { status: 500, .. }),
501            "expected Http {{ status: 500 }}, got {e:?}"
502        );
503    }
504
505    #[test]
506    fn truncate_respects_char_boundaries() {
507        // Multi-byte char straddling the cap shouldn't panic.
508        let s = "a".repeat(498) + "é"; // 'é' is 2 bytes in UTF-8.
509        let t = truncate(&s, 499);
510        assert!(s.starts_with(t));
511    }
512}