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};
17use serde::de::DeserializeOwned;
18use serde::Serialize;
19use tokio::io::AsyncWriteExt;
20
21use crate::error::{Error, Result};
22use crate::token::Token;
23
24/// HTTP client with the bearer token baked into its default headers.
25///
26/// Cheap to clone (it's a thin wrapper around `reqwest::Client`, which is
27/// itself an `Arc` internally), so prefer cloning over re-building.
28#[derive(Clone)]
29pub struct Client {
30    inner: reqwest::Client,
31    base_url: String,
32}
33
34impl Client {
35    /// Build a client for the given platform base URL, authenticated with
36    /// `token`. The base URL's trailing slash (if any) is stripped.
37    pub fn new(base_url: impl Into<String>, token: Token) -> Result<Self> {
38        let mut headers = HeaderMap::new();
39        let value = format!("Bearer {}", token.as_str());
40        let header = HeaderValue::from_str(&value)
41            .map_err(|_| Error::BadRequest("token contained invalid bytes".into()))?;
42        headers.insert(AUTHORIZATION, header);
43
44        let inner = reqwest::Client::builder()
45            .default_headers(headers)
46            .user_agent(concat!(
47                "wavekat-platform-client/",
48                env!("CARGO_PKG_VERSION")
49            ))
50            .build()?;
51        Ok(Self {
52            inner,
53            base_url: base_url.into().trim_end_matches('/').to_string(),
54        })
55    }
56
57    /// Base URL the client was configured with, with any trailing slash
58    /// stripped. Useful for callers that want to print a clickable link
59    /// alongside an API result (`{base_url}/projects/…`).
60    pub fn base_url(&self) -> &str {
61        &self.base_url
62    }
63
64    fn url(&self, path: &str) -> String {
65        format!("{}{}", self.base_url, path)
66    }
67
68    /// `GET {path}` and decode the JSON response.
69    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
70        let url = self.url(path);
71        let resp = self.inner.get(&url).send().await?;
72        decode(url, resp).await
73    }
74
75    /// `GET {path}?query` and decode the JSON response. `query` is any
76    /// `serde::Serialize` — typically a `&[(K, V)]` or a struct.
77    pub async fn get_json_query<T: DeserializeOwned, Q: Serialize + ?Sized>(
78        &self,
79        path: &str,
80        query: &Q,
81    ) -> Result<T> {
82        let url = self.url(path);
83        let resp = self.inner.get(&url).query(query).send().await?;
84        decode(url, resp).await
85    }
86
87    /// `POST {path}` with `body` serialized as JSON, decode the JSON
88    /// response.
89    pub async fn post_json<T: DeserializeOwned, B: Serialize + ?Sized>(
90        &self,
91        path: &str,
92        body: &B,
93    ) -> Result<T> {
94        let url = self.url(path);
95        let resp = self.inner.post(&url).json(body).send().await?;
96        decode(url, resp).await
97    }
98
99    /// `POST {path}` with no body, expecting an empty/ignored response.
100    pub async fn post_empty(&self, path: &str) -> Result<()> {
101        let url = self.url(path);
102        let resp = self.inner.post(&url).send().await?;
103        ensure_success(url, resp).await
104    }
105
106    /// `POST {path}` with no body, decoding the JSON response. The CLI
107    /// uses this for `…/finalize` endpoints that take no body but return
108    /// the updated row.
109    pub async fn post_empty_returning_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
110        let url = self.url(path);
111        let resp = self.inner.post(&url).send().await?;
112        decode(url, resp).await
113    }
114
115    /// `DELETE {path}`.
116    pub async fn delete(&self, path: &str) -> Result<()> {
117        let url = self.url(path);
118        let resp = self.inner.delete(&url).send().await?;
119        ensure_success(url, resp).await
120    }
121
122    /// `PUT {path}` with `body` as `application/octet-stream`. Used by
123    /// the CLI's `models push` to ship bytes through the platform's
124    /// proxy upload route when R2 isn't directly reachable.
125    pub async fn put_proxy_bytes(&self, path: &str, body: Vec<u8>) -> Result<()> {
126        self.put_raw_bytes(path, "application/octet-stream", body)
127            .await
128    }
129
130    /// `PUT {path}` with `body` and a caller-chosen content type. The
131    /// bearer's auth header rides along (per the default-headers map),
132    /// so this is for routes on the platform itself — not for
133    /// presigned R2 PUTs. Voice recording bytes go through here.
134    pub async fn put_raw_bytes(&self, path: &str, content_type: &str, body: Vec<u8>) -> Result<()> {
135        let url = self.url(path);
136        let resp = self
137            .inner
138            .put(&url)
139            .header(reqwest::header::CONTENT_TYPE, content_type)
140            .body(body)
141            .send()
142            .await?;
143        ensure_success(url, resp).await
144    }
145
146    /// `PUT` raw bytes to a presigned URL. Deliberately uses a *fresh*
147    /// `reqwest::Client` (no auth headers) — adding `Authorization:
148    /// Bearer …` would make S3/R2 reject the request because it's not
149    /// part of the SigV4 query-string signature.
150    pub async fn put_presigned_bytes(presigned_url: &str, body: Vec<u8>) -> Result<()> {
151        let resp = reqwest::Client::new()
152            .put(presigned_url)
153            .body(body)
154            .send()
155            .await?;
156        ensure_success(presigned_url.to_string(), resp).await
157    }
158
159    /// `GET {base_url}{path}?{query}` against a public, unauthenticated
160    /// platform endpoint. Like [`Client::put_presigned_bytes`], builds
161    /// a fresh `reqwest::Client` so the request carries no
162    /// `Authorization` header — sending one to an endpoint that doesn't
163    /// expect it can trigger surprising server-side branches and
164    /// defeats edge-cache key uniformity.
165    ///
166    /// Used by callers that need to read public configuration before
167    /// any user has signed in (e.g. provider-preset lookups during
168    /// desktop-client onboarding). For authenticated reads use
169    /// [`Client::get_json`] or [`Client::get_json_query`].
170    pub async fn get_public_json<T: DeserializeOwned>(
171        base_url: &str,
172        path: &str,
173        query: &[(&str, &str)],
174    ) -> Result<T> {
175        let base = base_url.trim_end_matches('/');
176        let url = format!("{}{}", base, path);
177        let mut req = reqwest::Client::new().get(&url);
178        if !query.is_empty() {
179            req = req.query(query);
180        }
181        let resp = req.send().await?;
182        decode(url, resp).await
183    }
184
185    /// Stream a `GET` response body into `sink`. Returns the number of
186    /// bytes written. Used for big payloads (manifests, audio clips)
187    /// where holding the whole body in memory would be wasteful.
188    pub async fn get_stream_to<W: AsyncWriteExt + Unpin>(
189        &self,
190        path: &str,
191        sink: &mut W,
192    ) -> Result<u64> {
193        let url = self.url(path);
194        let resp = self.inner.get(&url).send().await?;
195        let status = resp.status();
196        if !status.is_success() {
197            let body = resp.text().await.unwrap_or_default();
198            return Err(http_error(status.as_u16(), url, body));
199        }
200        let mut stream = resp.bytes_stream();
201        let mut written: u64 = 0;
202        while let Some(chunk) = stream.next().await {
203            let bytes = chunk?;
204            sink.write_all(&bytes).await?;
205            written += bytes.len() as u64;
206        }
207        sink.flush().await?;
208        Ok(written)
209    }
210}
211
212async fn decode<T: DeserializeOwned>(url: String, resp: reqwest::Response) -> Result<T> {
213    let status = resp.status();
214    let text = resp.text().await?;
215    if !status.is_success() {
216        return Err(http_error(status.as_u16(), url, text));
217    }
218    serde_json::from_str(&text).map_err(|source| Error::Decode { url, source })
219}
220
221async fn ensure_success(url: String, resp: reqwest::Response) -> Result<()> {
222    let status = resp.status();
223    if status.is_success() {
224        return Ok(());
225    }
226    let body = resp.text().await.unwrap_or_default();
227    Err(http_error(status.as_u16(), url, body))
228}
229
230/// Map an HTTP error response to the matching [`Error`] variant. 401
231/// gets its own [`Error::Unauthorized`] so consumers can render a
232/// tailored "sign in again" message; everything else stays as
233/// [`Error::Http`].
234fn http_error(status: u16, url: String, body: String) -> Error {
235    let body = truncate(&body, 500).to_string();
236    if status == 401 {
237        Error::Unauthorized { url, body }
238    } else {
239        Error::Http { status, url, body }
240    }
241}
242
243fn truncate(s: &str, n: usize) -> &str {
244    if s.len() > n {
245        // Walk back to the previous char boundary so we don't slice a
246        // multibyte UTF-8 sequence (the CLI's version of this used a
247        // raw byte slice, which is a panic waiting for a non-ASCII
248        // error body).
249        let mut end = n;
250        while end > 0 && !s.is_char_boundary(end) {
251            end -= 1;
252        }
253        &s[..end]
254    } else {
255        s
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn http_error_format_matches_cli_shape() {
265        // Regression guard: `Display` for `Error::Http` should format
266        // "{status} {url}: {body}" — matches what the CLI's old `decode`
267        // produced via `anyhow!`. Consumers (and grep-driven debugging)
268        // depend on the shape.
269        let e = Error::Http {
270            status: 500,
271            url: "https://platform.wavekat.com/api/me".into(),
272            body: "boom".into(),
273        };
274        let s = e.to_string();
275        assert!(s.contains("500"), "{s}");
276        assert!(s.contains("https://platform.wavekat.com/api/me"), "{s}");
277        assert!(s.contains("boom"), "{s}");
278    }
279
280    #[test]
281    fn http_error_splits_401_into_unauthorized() {
282        // 401 routes to the dedicated variant so consumers can match on
283        // it instead of inspecting `status == 401`.
284        let e = http_error(
285            401,
286            "https://platform.wavekat.com/api/me".into(),
287            "{\"error\":\"unauthenticated\"}".into(),
288        );
289        assert!(
290            matches!(e, Error::Unauthorized { .. }),
291            "expected Unauthorized, got {e:?}"
292        );
293        // Display still mentions 401 + url so logs stay greppable.
294        let s = e.to_string();
295        assert!(s.contains("401"), "{s}");
296        assert!(s.contains("https://platform.wavekat.com/api/me"), "{s}");
297    }
298
299    #[test]
300    fn http_error_keeps_non_401_in_http_variant() {
301        let e = http_error(
302            500,
303            "https://platform.wavekat.com/api/me".into(),
304            "boom".into(),
305        );
306        assert!(
307            matches!(e, Error::Http { status: 500, .. }),
308            "expected Http {{ status: 500 }}, got {e:?}"
309        );
310    }
311
312    #[test]
313    fn truncate_respects_char_boundaries() {
314        // Multi-byte char straddling the cap shouldn't panic.
315        let s = "a".repeat(498) + "é"; // 'é' is 2 bytes in UTF-8.
316        let t = truncate(&s, 499);
317        assert!(s.starts_with(t));
318    }
319}