1use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
2use serde_json::Value;
3
4use crate::config::{CairnConfig, Credentials};
5use crate::errors::CairnError;
6
7pub struct BackpacClient {
9 inner: reqwest::Client,
10 base_url: String,
11 jwt: Option<String>,
12}
13
14impl BackpacClient {
15 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 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 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 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 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 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 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 async fn handle_response(&self, resp: reqwest::Response) -> Result<Value, CairnError> {
131 let status = resp.status();
132
133 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 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 let is_auto_pay = std::env::var("CAIRN_AUTO_PAY").map(|v| v == "1").unwrap_or(false);
170
171 if is_auto_pay {
172 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}