Skip to main content

taskfast_client/
client.rs

1// SPDX-License-Identifier: MIT
2//! Authenticated TaskFast client.
3//!
4//! [`TaskFastClient`] wraps the progenitor-generated [`crate::api::Client`]
5//! with the cross-cutting concerns the generated code deliberately omits:
6//!
7//!   1. **Auth** — injects `X-API-Key` on every request via reqwest's
8//!      `default_headers`, so every generated method call carries it without
9//!      per-call plumbing.
10//!   2. **Typed errors** — translates [`crate::api::Error<()>`] into
11//!      [`crate::errors::Error`] by reading the response body of an
12//!      `UnexpectedResponse` and classifying by status code. The normalizer
13//!      strips non-2xx response declarations from the spec (see
14//!      `xtask::normalize_spec`), so every TaskFast failure arrives here as
15//!      `UnexpectedResponse` — we are the single funnel.
16//!   3. **Retry** — [`TaskFastClient::call_with_retry`] runs a closure
17//!      under the configured [`RetryPolicy`], honoring 429 `Retry-After`
18//!      and retrying 5xx with exponential backoff.
19//!
20//! Direct access to the generated client is available via [`TaskFastClient::inner`]
21//! so callers can invoke `client.inner().get_agent_profile().await` and then
22//! pipe the result through [`map_api_error`] or the retry wrapper as they prefer.
23
24use std::collections::HashMap;
25use std::future::Future;
26use std::time::Duration;
27
28use progenitor_client::ClientInfo;
29use reqwest::header::{HeaderMap, HeaderValue};
30use serde::{Deserialize, Serialize};
31use tokio::sync::OnceCell;
32
33use crate::api;
34use crate::errors::{Error, Result};
35use crate::retry::{with_backoff, RetryPolicy};
36
37/// Default connect timeout for the underlying reqwest::Client.
38const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
39/// Default total request timeout (connect + body read).
40const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
41/// Hard cap on any single response body we read (16 MiB). TaskFast
42/// responses are JSON envelopes — the largest realistic payload is a
43/// task list page, ~KBs. A 16-MiB ceiling gives generous headroom
44/// while preventing a compromised (or buggy) server from memory-
45/// exhausting the CLI with a gigabyte body.
46const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
47
48/// Authenticated TaskFast API client.
49pub struct TaskFastClient {
50    inner: api::Client,
51    policy: RetryPolicy,
52    /// Per-client cache of the public `GET /config/network` payload.
53    /// Fetched lazily on first access; immutable per deployment, so a
54    /// process-lifetime cache is sufficient.
55    network_cfg: OnceCell<NetworkConfigResponse>,
56}
57
58impl TaskFastClient {
59    /// Construct a client from an API key. The key is sent as `X-API-Key`
60    /// on every request; the underlying reqwest::Client marks the header
61    /// sensitive so it won't appear in debug traces.
62    ///
63    /// `base_url` is the API host (e.g. `https://api.taskfast.app`). The
64    /// generated client appends the unprefixed path keys from the spec.
65    /// Callers pass hosts, not versioned paths; a trailing `/` is tolerated.
66    pub fn from_api_key(base_url: &str, api_key: &str) -> Result<Self> {
67        let mut value = HeaderValue::from_str(api_key)
68            .map_err(|_| Error::Auth("api key contains invalid header bytes".into()))?;
69        value.set_sensitive(true);
70        let mut headers = HeaderMap::new();
71        headers.insert("X-API-Key", value);
72
73        // F5: pin redirect policy to `none`. reqwest's default follows up
74        // to 10 redirects. `Authorization` is stripped cross-host by
75        // reqwest, but `X-API-Key` is a custom header so it would be
76        // replayed verbatim to whatever 3xx `Location` the server points
77        // at — exfiltrating the PAT on a single malicious redirect. The
78        // real API never 3xx's, so refusing all redirects is safe: a 302
79        // from `api.taskfast.app` is itself the anomaly we want to catch.
80        let http = reqwest::ClientBuilder::new()
81            .default_headers(headers)
82            .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
83            .timeout(DEFAULT_REQUEST_TIMEOUT)
84            .redirect(reqwest::redirect::Policy::none())
85            .build()?;
86
87        let resolved = base_url.trim_end_matches('/').to_owned();
88
89        Ok(Self {
90            inner: api::Client::new_with_client(&resolved, http),
91            policy: RetryPolicy::default(),
92            network_cfg: OnceCell::new(),
93        })
94    }
95
96    /// Underlying reqwest::Client, pre-configured with the `X-API-Key` header.
97    /// Lets callers build requests outside the progenitor-generated surface
98    /// (e.g. to forward a JSON-RPC body to `POST /rpc/{network}` via the
99    /// same connection pool + default headers).
100    pub fn http_client(&self) -> reqwest::Client {
101        self.inner.client().clone()
102    }
103
104    /// Override the retry policy (default: [`RetryPolicy::default`]).
105    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
106        self.policy = policy;
107        self
108    }
109
110    /// Direct access to the generated typed client. Use to invoke endpoint
111    /// methods; combine with [`map_api_error`] to surface typed errors.
112    pub fn inner(&self) -> &api::Client {
113        &self.inner
114    }
115
116    /// The retry policy in effect for [`Self::call_with_retry`].
117    pub fn retry_policy(&self) -> RetryPolicy {
118        self.policy
119    }
120
121    /// Run `op` under the configured retry policy. `op` receives the attempt
122    /// number (1-indexed) and should produce a typed [`Result`]. Typical use:
123    ///
124    /// ```ignore
125    /// let profile = client.call_with_retry(|_| async {
126    ///     client.inner().get_agent_profile().await
127    ///         .map(|r| r.into_inner())
128    ///         .map_err(map_api_error).await // cannot await inside map_err
129    /// }).await?;
130    /// ```
131    ///
132    /// The callsite ergonomics bite is that [`map_api_error`] is async (it
133    /// reads the response body). Callers should match the Result themselves:
134    ///
135    /// ```ignore
136    /// client.call_with_retry(|_| async {
137    ///     match client.inner().get_agent_profile().await {
138    ///         Ok(ok) => Ok(ok.into_inner()),
139    ///         Err(e) => Err(map_api_error(e).await),
140    ///     }
141    /// }).await
142    /// ```
143    pub async fn call_with_retry<T, F, Fut>(&self, op: F) -> Result<T>
144    where
145        F: FnMut(u32) -> Fut,
146        Fut: Future<Output = Result<T>>,
147    {
148        with_backoff(self.policy, op).await
149    }
150
151    /// `GET /users/me` — fetch the human owner's display name + email.
152    ///
153    /// Hand-rolled because the endpoint is not yet in `spec/openapi.yaml`
154    /// (server-side work in flight). 404 is promoted to `Ok(None)` so
155    /// callers can gracefully fall back when running against an older
156    /// server deployment; every other non-2xx is surfaced via the shared
157    /// `classify_response` funnel.
158    pub async fn get_user_profile(&self) -> Result<Option<UserProfile>> {
159        let url = format!("{}/users/me", self.inner.baseurl());
160        let resp = self.inner.client().get(url).send().await?;
161        let status = resp.status();
162        if status.is_success() {
163            let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
164            return Ok(Some(serde_json::from_slice::<UserProfile>(&bytes)?));
165        }
166        if status == reqwest::StatusCode::NOT_FOUND {
167            return Ok(None);
168        }
169        Err(classify_response(resp).await)
170    }
171
172    /// Upload a file artifact to `POST /tasks/{task_id}/artifacts`.
173    ///
174    /// Hand-rolled because progenitor's default templates skip `multipart/
175    /// form-data` endpoints. We reuse the inner reqwest::Client (same auth
176    /// headers, same timeouts) and funnel non-2xx responses through the
177    /// same `classify_response` used for generated methods — so upload
178    /// errors map to the same `Error` variants as every other call.
179    pub async fn upload_artifact(
180        &self,
181        task_id: &uuid::Uuid,
182        filename: String,
183        content_type: String,
184        bytes: Vec<u8>,
185    ) -> Result<api::types::Artifact> {
186        let url = format!("{}/tasks/{}/artifacts", self.inner.baseurl(), task_id);
187        let part = reqwest::multipart::Part::bytes(bytes)
188            .file_name(filename)
189            .mime_str(&content_type)
190            .map_err(|e| Error::Server(format!("invalid content-type: {e}")))?;
191        let form = reqwest::multipart::Form::new().part("file", part);
192        let resp = self.inner.client().post(url).multipart(form).send().await?;
193        if resp.status().is_success() {
194            let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
195            Ok(serde_json::from_slice::<api::types::Artifact>(&bytes)?)
196        } else {
197            Err(classify_response(resp).await)
198        }
199    }
200
201    /// `GET /config/network` — per-network chain metadata.
202    ///
203    /// Public endpoint, no auth required; the `X-API-Key` header attached by
204    /// `from_api_key` is harmless on the server side. Result is cached on
205    /// this client instance for the rest of the process — the payload is
206    /// immutable per deployment (operator-side config changes require a
207    /// redeploy to take effect).
208    ///
209    /// Each entry's `rpc_url` points at this same deployment's authenticated
210    /// `POST /rpc/{network}` proxy, NOT the upstream Tempo gateway.
211    pub async fn fetch_network_config(&self) -> Result<&NetworkConfigResponse> {
212        self.network_cfg
213            .get_or_try_init(|| async {
214                let url = format!("{}/config/network", self.inner.baseurl());
215                let resp = self.inner.client().get(url).send().await?;
216                if !resp.status().is_success() {
217                    return Err(classify_response(resp).await);
218                }
219                let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
220                Ok(serde_json::from_slice::<NetworkConfigResponse>(&bytes)?)
221            })
222            .await
223    }
224
225    /// `POST /rpc/{network}` — forward a JSON-RPC call through the
226    /// operator's upstream proxy.
227    ///
228    /// `network` is the key into the `fetch_network_config` map
229    /// (`"testnet"` / `"mainnet"`). Response body is returned verbatim as
230    /// [`serde_json::Value`] — the proxy forwards the upstream JSON-RPC
231    /// envelope without touching it, so callers parse `result` / `error`
232    /// per the JSON-RPC 2.0 spec.
233    ///
234    /// 429 surfaces as [`Error::RateLimited`] with the `Retry-After`
235    /// header honored by `classify_response`; 5xx as [`Error::Server`].
236    pub async fn post_json_rpc(
237        &self,
238        network: &str,
239        request: &serde_json::Value,
240    ) -> Result<serde_json::Value> {
241        let url = format!("{}/rpc/{}", self.inner.baseurl(), network);
242        let resp = self.inner.client().post(url).json(request).send().await?;
243        if !resp.status().is_success() {
244            return Err(classify_response(resp).await);
245        }
246        let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
247        Ok(serde_json::from_slice::<serde_json::Value>(&bytes)?)
248    }
249
250    /// `GET /agents/me` — raw JSON escape hatch.
251    ///
252    /// Mirrors the generated `get_agent_profile` but returns the body as
253    /// [`serde_json::Value`] so `taskfast me` can print fields the server
254    /// added after this client's spec was vendored (gh#172).
255    pub async fn get_agent_profile_raw(&self) -> Result<serde_json::Value> {
256        let url = format!("{}/agents/me", self.inner.baseurl());
257        let resp = self.inner.client().get(url).send().await?;
258        if !resp.status().is_success() {
259            return Err(classify_response(resp).await);
260        }
261        let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
262        Ok(serde_json::from_slice::<serde_json::Value>(&bytes)?)
263    }
264
265    /// `GET /agents/me/events` — raw JSON escape hatch.
266    ///
267    /// Mirrors the generated `list_agent_events` but returns
268    /// [`serde_json::Value`] instead of the strict [`api::types::AgentEventListResponse`]
269    /// so the agent layer can apply per-item tolerant decoding when a
270    /// single malformed event would otherwise fail the whole page.
271    /// Non-2xx responses still funnel through `classify_response`.
272    pub async fn list_agent_events_raw(
273        &self,
274        cursor: Option<&str>,
275        limit: Option<i64>,
276    ) -> Result<serde_json::Value> {
277        let url = format!("{}/agents/me/events", self.inner.baseurl());
278        let mut req = self.inner.client().get(url);
279        let mut query: Vec<(&str, String)> = Vec::new();
280        if let Some(c) = cursor {
281            query.push(("cursor", c.to_string()));
282        }
283        if let Some(l) = limit {
284            query.push(("limit", l.to_string()));
285        }
286        if !query.is_empty() {
287            req = req.query(&query);
288        }
289        let resp = req.send().await?;
290        if !resp.status().is_success() {
291            return Err(classify_response(resp).await);
292        }
293        let bytes = read_body_capped(resp, MAX_RESPONSE_BYTES).await?;
294        Ok(serde_json::from_slice::<serde_json::Value>(&bytes)?)
295    }
296}
297
298/// Stream `resp` body chunks into memory, refusing to allocate past `cap`.
299///
300/// Reqwest offers no native body-size cap on the ClientBuilder. Wrapping
301/// `chunk()` is the cheap route: each call yields whatever reqwest has
302/// buffered, so we can bail as soon as the running total would exceed
303/// the ceiling — no need for a trusted `Content-Length` header (chunked
304/// bodies from a malicious server carry none).
305async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> Result<Vec<u8>> {
306    // Capture the server x-request-id for the CLI trace (gh#85). Generated
307    // methods are covered by progenitor's post-hook; the hand-rolled methods
308    // all funnel their bodies (success and error, via classify_response)
309    // through here, so this one line covers every hand-rolled path.
310    crate::corr::record_corr_response(&resp);
311    let mut buf = Vec::new();
312    while let Some(chunk) = resp.chunk().await? {
313        if buf.len().saturating_add(chunk.len()) > cap {
314            return Err(Error::Server(format!(
315                "response body exceeds {cap}-byte safety cap"
316            )));
317        }
318        buf.extend_from_slice(&chunk);
319    }
320    Ok(buf)
321}
322
323/// Translate a progenitor [`api::Error<()>`] into a typed [`Error`].
324///
325/// Async because the `UnexpectedResponse` path has to consume the response
326/// body to extract TaskFast's `{error, message}` envelope.
327pub async fn map_api_error(err: api::Error<()>) -> Error {
328    use api::Error as AE;
329    match err {
330        AE::InvalidRequest(m) => Error::Server(format!("invalid request: {m}")),
331        AE::CommunicationError(e) => Error::Network(e),
332        AE::InvalidUpgrade(e) => Error::Network(e),
333        AE::ResponseBodyError(e) => Error::Network(e),
334        AE::InvalidResponsePayload(_, e) => Error::Decode(e),
335        AE::UnexpectedResponse(resp) => classify_response(resp).await,
336        AE::ErrorResponse(_) => {
337            // Unreachable with stripped-non-2xx normalization (E = ()), but we
338            // refuse to panic — produce a descriptive Server error instead.
339            Error::Server(
340                "progenitor emitted typed ErrorResponse for E=() — spec normalization regression"
341                    .into(),
342            )
343        }
344        // progenitor 0.14 collapsed the former Pre/PostHookError variants
345        // into a single Custom variant for consumer-defined hook failures.
346        AE::Custom(m) => Error::Server(format!("hook: {m}")),
347    }
348}
349
350/// Per-network chain configuration returned by `GET /config/network`.
351///
352/// Keys of `networks` are network names (`"testnet"`, `"mainnet"`);
353/// networks the deployment does not support are simply absent.
354#[derive(Debug, Clone, Deserialize, Serialize)]
355pub struct NetworkConfigResponse {
356    /// Network name → chain metadata.
357    pub networks: HashMap<String, NetworkConfigEntry>,
358}
359
360impl NetworkConfigResponse {
361    /// Return the entry for `network` name or an `Error::Validation` if
362    /// the deployment does not advertise it.
363    pub fn entry(&self, network: &str) -> Result<&NetworkConfigEntry> {
364        self.networks.get(network).ok_or_else(|| Error::Validation {
365            code: "unknown_network".to_string(),
366            message: format!("deployment does not advertise network `{network}`"),
367        })
368    }
369
370    /// Reverse-lookup: find the entry whose `chain_id` matches. Used when
371    /// the caller knows the chain ID (e.g. from escrow params) but not
372    /// the network name. Errors if no entry matches.
373    pub fn entry_by_chain_id(&self, chain_id: i64) -> Result<(&str, &NetworkConfigEntry)> {
374        self.networks
375            .iter()
376            .find(|(_, e)| e.chain_id == chain_id)
377            .map(|(name, entry)| (name.as_str(), entry))
378            .ok_or_else(|| Error::Validation {
379                code: "unknown_chain_id".to_string(),
380                message: format!("no network in /config/network advertises chain_id={chain_id}"),
381            })
382    }
383}
384
385/// Chain metadata for a single Tempo network.
386#[derive(Debug, Clone, Deserialize, Serialize)]
387pub struct NetworkConfigEntry {
388    /// EVM chain ID (e.g. `4217` for Tempo mainnet, `42_431` for moderato testnet).
389    pub chain_id: i64,
390    /// Authenticated JSON-RPC proxy URL on this deployment
391    /// (`{api_base}/rpc/{network}`). NOT the upstream Tempo gateway.
392    pub rpc_url: String,
393    /// WebSocket JSON-RPC URL on the native Tempo gateway (no proxy).
394    /// `None` for Tempo Zone entries — a zone venue advertises only its RPC
395    /// proxy (the server emits `null`), and rejecting the whole
396    /// `/config/network` payload over it would break every command that
397    /// resolves the default proxy route while any zone venue is active.
398    #[serde(default)]
399    pub wss_url: Option<String>,
400    /// Block-explorer base URL for this network; `None` for Tempo Zone
401    /// entries (zones have no public explorer).
402    #[serde(default)]
403    pub explorer_url: Option<String>,
404    /// Default stablecoin ticker; `None` when the deployment has not
405    /// finalized a token for the network (e.g. mainnet pre-launch).
406    pub default_stablecoin: Option<String>,
407    /// TaskBond contract address for this network; `None` when the
408    /// deployment has not published one (or predates the field).
409    pub task_bond_contract: Option<String>,
410}
411
412/// Owning human's display name + email, returned by `GET /users/me`.
413/// Lives alongside the hand-rolled fetch until the endpoint is added to
414/// `spec/openapi.yaml` and the type can be regenerated under
415/// [`crate::api::types`].
416#[derive(Debug, Clone, Deserialize)]
417pub struct UserProfile {
418    /// Display name for the owning human (e.g. `"Alice Smith"`).
419    pub name: String,
420    /// Owning human's email address.
421    pub email: String,
422}
423
424#[derive(Debug, Deserialize)]
425struct ErrorBody {
426    /// TaskFast's `components/schemas/Error.error` — short code.
427    #[serde(default)]
428    error: String,
429    /// Human-readable detail.
430    #[serde(default)]
431    message: String,
432}
433
434/// Classify a non-2xx response by status code, reading the body to extract
435/// TaskFast's `{error, message}` envelope when relevant.
436///
437/// Body read + JSON parse are best-effort: a transport failure mid-body or a
438/// non-JSON payload collapses to an empty envelope (status line surfaces in
439/// `Display`). Both failure modes emit a `tracing::warn` so silent classifier
440/// drops are observable in logs.
441async fn classify_response(resp: reqwest::Response) -> Error {
442    let status = resp.status();
443    let code = status.as_u16();
444    let retry_after = parse_retry_after(&resp);
445    // Cap the error body read at 64 KiB. TaskFast error envelopes are
446    // `{error, message}` JSON objects — a megabyte-class body on a 4xx
447    // is an abuse signal, not a legitimate payload. Smaller cap than
448    // success bodies: error paths never carry task-list pages.
449    let body = match read_body_capped(resp, 64 * 1024).await {
450        Ok(b) => String::from_utf8_lossy(&b).into_owned(),
451        Err(e) => {
452            tracing::warn!(error = %e.kind(), %code, "classify_response: failed to read body");
453            String::new()
454        }
455    };
456    let parsed: ErrorBody = match serde_json::from_str(&body) {
457        Ok(p) => p,
458        Err(e) => {
459            if !body.is_empty() {
460                tracing::warn!(error = %e, %code, "classify_response: body is not JSON; using raw text");
461            }
462            ErrorBody {
463                error: String::new(),
464                message: body,
465            }
466        }
467    };
468
469    match code {
470        401 | 403 => Error::Auth(format_status(
471            code,
472            display_or_status(&parsed, status.as_str()),
473        )),
474        422 => Error::Validation {
475            code: or_default(&parsed.error, "validation_error"),
476            message: or_default(&parsed.message, "request body failed validation"),
477        },
478        429 => Error::RateLimited {
479            retry_after: retry_after.unwrap_or(Duration::from_secs(1)),
480        },
481        500..=599 => Error::Server(format_status(
482            code,
483            display_or_status(&parsed, status.as_str()),
484        )),
485        // 4xx other than the above (404, 409, etc.) — progenitor would have
486        // surfaced this as UnexpectedResponse because we stripped non-2xx from
487        // the spec. Treat as a validation-ish failure so callers can branch.
488        400..=499 => Error::Validation {
489            code: or_default(&parsed.error, status.as_str()),
490            message: or_default(
491                &parsed.message,
492                status.canonical_reason().unwrap_or("client error"),
493            ),
494        },
495        _ => Error::Server(format_status(
496            code,
497            display_or_status(&parsed, status.as_str()),
498        )),
499    }
500}
501
502fn parse_retry_after(resp: &reqwest::Response) -> Option<Duration> {
503    let v = resp.headers().get(reqwest::header::RETRY_AFTER)?;
504    let s = v.to_str().ok()?.trim();
505    // Only seconds form supported for now; HTTP-date form can land later.
506    s.parse::<u64>().ok().map(Duration::from_secs)
507}
508
509fn or_default(s: &str, fallback: &str) -> String {
510    if s.is_empty() {
511        fallback.to_string()
512    } else {
513        s.to_string()
514    }
515}
516
517fn display_or_status<'a>(body: &'a ErrorBody, status: &'a str) -> &'a str {
518    if !body.message.is_empty() {
519        &body.message
520    } else if !body.error.is_empty() {
521        &body.error
522    } else {
523        status
524    }
525}
526
527fn format_status(code: u16, detail: &str) -> String {
528    format!("HTTP {code}: {detail}")
529}