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::{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 /// Stream a `GET` response body into `sink`. Returns the number of
260 /// bytes written. Used for big payloads (manifests, audio clips)
261 /// where holding the whole body in memory would be wasteful.
262 pub async fn get_stream_to<W: AsyncWriteExt + Unpin>(
263 &self,
264 path: &str,
265 sink: &mut W,
266 ) -> Result<u64> {
267 let url = self.url(path);
268 let resp = self.inner.get(&url).send().await?;
269 let status = resp.status();
270 if !status.is_success() {
271 let body = resp.text().await.unwrap_or_default();
272 return Err(http_error(status.as_u16(), url, body));
273 }
274 let mut stream = resp.bytes_stream();
275 let mut written: u64 = 0;
276 while let Some(chunk) = stream.next().await {
277 let bytes = chunk?;
278 sink.write_all(&bytes).await?;
279 written += bytes.len() as u64;
280 }
281 sink.flush().await?;
282 Ok(written)
283 }
284}
285
286async fn decode<T: DeserializeOwned>(url: String, resp: reqwest::Response) -> Result<T> {
287 let status = resp.status();
288 let text = resp.text().await?;
289 if !status.is_success() {
290 return Err(http_error(status.as_u16(), url, text));
291 }
292 serde_json::from_str(&text).map_err(|source| Error::Decode { url, source })
293}
294
295async fn ensure_success(url: String, resp: reqwest::Response) -> Result<()> {
296 let status = resp.status();
297 if status.is_success() {
298 return Ok(());
299 }
300 let body = resp.text().await.unwrap_or_default();
301 Err(http_error(status.as_u16(), url, body))
302}
303
304/// Map an HTTP error response to the matching [`Error`] variant. 401
305/// gets its own [`Error::Unauthorized`] so consumers can render a
306/// tailored "sign in again" message; everything else stays as
307/// [`Error::Http`].
308fn http_error(status: u16, url: String, body: String) -> Error {
309 let body = truncate(&body, 500).to_string();
310 if status == 401 {
311 Error::Unauthorized { url, body }
312 } else {
313 Error::Http { status, url, body }
314 }
315}
316
317fn truncate(s: &str, n: usize) -> &str {
318 if s.len() > n {
319 // Walk back to the previous char boundary so we don't slice a
320 // multibyte UTF-8 sequence (the CLI's version of this used a
321 // raw byte slice, which is a panic waiting for a non-ASCII
322 // error body).
323 let mut end = n;
324 while end > 0 && !s.is_char_boundary(end) {
325 end -= 1;
326 }
327 &s[..end]
328 } else {
329 s
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn http_error_format_matches_cli_shape() {
339 // Regression guard: `Display` for `Error::Http` should format
340 // "{status} {url}: {body}" — matches what the CLI's old `decode`
341 // produced via `anyhow!`. Consumers (and grep-driven debugging)
342 // depend on the shape.
343 let e = Error::Http {
344 status: 500,
345 url: "https://platform.wavekat.com/api/me".into(),
346 body: "boom".into(),
347 };
348 let s = e.to_string();
349 assert!(s.contains("500"), "{s}");
350 assert!(s.contains("https://platform.wavekat.com/api/me"), "{s}");
351 assert!(s.contains("boom"), "{s}");
352 }
353
354 #[test]
355 fn http_error_splits_401_into_unauthorized() {
356 // 401 routes to the dedicated variant so consumers can match on
357 // it instead of inspecting `status == 401`.
358 let e = http_error(
359 401,
360 "https://platform.wavekat.com/api/me".into(),
361 "{\"error\":\"unauthenticated\"}".into(),
362 );
363 assert!(
364 matches!(e, Error::Unauthorized { .. }),
365 "expected Unauthorized, got {e:?}"
366 );
367 // Display still mentions 401 + url so logs stay greppable.
368 let s = e.to_string();
369 assert!(s.contains("401"), "{s}");
370 assert!(s.contains("https://platform.wavekat.com/api/me"), "{s}");
371 }
372
373 #[test]
374 fn http_error_keeps_non_401_in_http_variant() {
375 let e = http_error(
376 500,
377 "https://platform.wavekat.com/api/me".into(),
378 "boom".into(),
379 );
380 assert!(
381 matches!(e, Error::Http { status: 500, .. }),
382 "expected Http {{ status: 500 }}, got {e:?}"
383 );
384 }
385
386 #[test]
387 fn truncate_respects_char_boundaries() {
388 // Multi-byte char straddling the cap shouldn't panic.
389 let s = "a".repeat(498) + "é"; // 'é' is 2 bytes in UTF-8.
390 let t = truncate(&s, 499);
391 assert!(s.starts_with(t));
392 }
393}