Skip to main content

cairn_cli/
client.rs

1use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
2use serde_json::Value;
3
4use crate::config::{CairnConfig, Credentials};
5use crate::errors::CairnError;
6
7/// HTTP client wrapper with automatic JWT injection and x402 payment fallback.
8pub struct BackpacClient {
9    inner: reqwest::Client,
10    base_url: String,
11    jwt: Option<String>,
12}
13
14impl BackpacClient {
15    /// Create a new client. Reads JWT from env, flag, or saved credentials.
16    pub fn new(jwt_override: Option<&str>, api_url: Option<&str>) -> Self {
17        let config = CairnConfig::load();
18
19        let base_url = api_url
20            .map(|s| s.to_string())
21            .or(config.api_url.clone())
22            .or_else(|| std::env::var("BACKPAC_API_URL").ok())
23            .unwrap_or_else(|| "https://api.backpac.xyz".to_string());
24
25        let jwt = jwt_override
26            .map(|s| s.to_string())
27            .or_else(|| std::env::var("BACKPAC_JWT").ok())
28            .or_else(|| Credentials::load().map(|c| c.jwt));
29
30        let inner = reqwest::Client::builder()
31            .timeout(std::time::Duration::from_secs(30))
32            .build()
33            .expect("Failed to create HTTP client");
34
35        Self { inner, base_url, jwt }
36    }
37
38    /// Build headers with JWT Bearer auth.
39    fn auth_headers(&self) -> Result<HeaderMap, CairnError> {
40        let mut headers = HeaderMap::new();
41        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
42
43        if let Some(ref jwt) = self.jwt {
44            let val = format!("Bearer {}", jwt);
45            headers.insert(
46                AUTHORIZATION,
47                HeaderValue::from_str(&val).map_err(|e| CairnError::Auth(e.to_string()))?,
48            );
49        }
50        Ok(headers)
51    }
52
53    /// GET request with auth.
54    pub async fn get(&self, path: &str) -> Result<Value, CairnError> {
55        let url = format!("{}{}", self.base_url, path);
56        let resp = self
57            .inner
58            .get(&url)
59            .headers(self.auth_headers()?)
60            .send()
61            .await?;
62
63        self.handle_response(resp).await
64    }
65
66    /// Create an EventSource for Server-Sent Events (SSE).
67    pub fn stream(&self, path: &str) -> Result<reqwest_eventsource::EventSource, CairnError> {
68        let url = format!("{}{}", self.base_url, path);
69        let req = self.inner.get(&url).headers(self.auth_headers()?);
70        reqwest_eventsource::EventSource::new(req).map_err(|e| CairnError::General(e.to_string()))
71    }
72
73    /// POST request with auth and JSON body.
74    pub async fn post(&self, path: &str, body: &Value) -> Result<Value, CairnError> {
75        let url = format!("{}{}", self.base_url, path);
76        let resp = self
77            .inner
78            .post(&url)
79            .headers(self.auth_headers()?)
80            .json(body)
81            .send()
82            .await?;
83
84        self.handle_response(resp).await
85    }
86
87    /// PUT request with auth and JSON body.
88    pub async fn put(&self, path: &str, body: &Value) -> Result<Value, CairnError> {
89        let url = format!("{}{}", self.base_url, path);
90        let resp = self
91            .inner
92            .put(&url)
93            .headers(self.auth_headers()?)
94            .json(body)
95            .send()
96            .await?;
97
98        self.handle_response(resp).await
99    }
100
101    /// POST for RPC route (root path /) with custom headers for PoI binding.
102    pub async fn rpc_post(
103        &self,
104        body: &Value,
105        poi_id: Option<&str>,
106        confidence: Option<f64>,
107    ) -> Result<Value, CairnError> {
108        let url = format!("{}/", self.base_url);
109        let mut headers = self.auth_headers()?;
110
111        if let Some(poi) = poi_id {
112            headers.insert(
113                "X-Backpac-Poi-Id",
114                HeaderValue::from_str(poi).map_err(|e| CairnError::InvalidInput(e.to_string()))?,
115            );
116        }
117        if let Some(conf) = confidence {
118            headers.insert(
119                "X-Backpac-Confidence",
120                HeaderValue::from_str(&conf.to_string())
121                    .map_err(|e| CairnError::InvalidInput(e.to_string()))?,
122            );
123        }
124
125        let resp = self.inner.post(&url).headers(headers).json(body).send().await?;
126        self.handle_response(resp).await
127    }
128
129    /// Handle HTTP response, mapping status codes to CairnErrors.
130    async fn handle_response(&self, resp: reqwest::Response) -> Result<Value, CairnError> {
131        let status = resp.status();
132
133        // Extract headers before taking ownership of resp body
134        let mut l402_challenge = String::new();
135        if let Some(header) = resp.headers().get("WWW-Authenticate") {
136            if let Ok(val) = header.to_str() {
137                l402_challenge = val.to_string();
138            }
139        } else if let Some(header) = resp.headers().get("L402") {
140            if let Ok(val) = header.to_str() {
141                l402_challenge = val.to_string();
142            }
143        }
144
145        if status.is_success() {
146            let body: Value = resp.json().await?;
147            return Ok(body);
148        }
149
150        // Try to parse error body
151        let body_text = resp.text().await.unwrap_or_default();
152        let error_msg = serde_json::from_str::<Value>(&body_text)
153            .ok()
154            .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
155            .unwrap_or_else(|| body_text.clone());
156
157        match status.as_u16() {
158            400 => Err(CairnError::InvalidInput(error_msg)),
159            401 => {
160                if error_msg.contains("expired") {
161                    Err(CairnError::TokenExpired)
162                } else {
163                    Err(CairnError::Auth(error_msg))
164                }
165            }
166            402 => {
167                if !l402_challenge.is_empty() {
168                    // x402 Payment Required — parse macaroon and invoice
169                    let is_auto_pay = std::env::var("CAIRN_AUTO_PAY").map(|v| v == "1").unwrap_or(false);
170                    
171                    if is_auto_pay {
172                        // TODO: Parse L402 macaroon & invoice, sign with wallet via ethers,
173                        // and re-submit the RPC request with Authorization: L402 <macaroon> <sig>
174                        return Err(CairnError::InsufficientFunds(
175                            format!("Credits exhausted. Auto-payment attempted for L402 challenge but signing logic is pending implementation. Challenge: {}", l402_challenge),
176                        ));
177                    } else {
178                        return Err(CairnError::InsufficientFunds(
179                            format!("Credits exhausted. Payment required: {}\nHint: Set CAIRN_AUTO_PAY=1 to enable wallet auto-payment.", l402_challenge),
180                        ));
181                    }
182                }
183
184                Err(CairnError::InsufficientFunds(
185                    "Credits exhausted. x402 auto-payment not available (missing L402 headers).".to_string(),
186                ))
187            }
188            403 => Err(CairnError::Auth(error_msg)),
189            404 => Err(CairnError::NotFound(error_msg)),
190            409 => Err(CairnError::Conflict(error_msg)),
191            410 => {
192                if error_msg.contains("expired") {
193                    Err(CairnError::IntentExpired(error_msg))
194                } else {
195                    Err(CairnError::IntentAborted(error_msg))
196                }
197            }
198            _ => Err(CairnError::General(format!("HTTP {}: {}", status, error_msg))),
199        }
200    }
201}