1use crate::error::CliError;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::Arc;
4use tokio::time::{sleep, Duration};
5
6pub struct ClickUpClient {
7 http: reqwest::Client,
8 base_url: String,
9 token: String,
10 rate_limit_remaining: Arc<AtomicU64>,
11 rate_limit_reset: Arc<AtomicU64>,
12}
13
14impl ClickUpClient {
15 pub fn new(token: &str, timeout_secs: u64) -> Result<Self, CliError> {
16 let http = reqwest::Client::builder()
17 .timeout(std::time::Duration::from_secs(timeout_secs))
18 .build()
19 .map_err(|e| CliError::ClientError {
20 message: format!("Failed to create HTTP client: {}", e),
21 status: 0,
22 })?;
23 let base_url = std::env::var("CLICKUP_API_URL")
24 .unwrap_or_else(|_| "https://api.clickup.com/api".to_string());
25 Ok(Self {
26 http,
27 base_url,
28 token: token.to_string(),
29 rate_limit_remaining: Arc::new(AtomicU64::new(100)),
30 rate_limit_reset: Arc::new(AtomicU64::new(0)),
31 })
32 }
33
34 fn update_rate_limits(&self, headers: &reqwest::header::HeaderMap) {
35 if let Some(remaining) = headers.get("X-RateLimit-Remaining") {
36 if let Ok(val) = remaining.to_str().unwrap_or("0").parse::<u64>() {
37 self.rate_limit_remaining.store(val, Ordering::Relaxed);
38 }
39 }
40 if let Some(reset) = headers.get("X-RateLimit-Reset") {
41 if let Ok(val) = reset.to_str().unwrap_or("0").parse::<u64>() {
42 self.rate_limit_reset.store(val, Ordering::Relaxed);
43 }
44 }
45 }
46
47 async fn parse_success_response(
48 resp: reqwest::Response,
49 status: u16,
50 ) -> Result<serde_json::Value, CliError> {
51 if status == 204 {
52 return Ok(serde_json::json!({}));
53 }
54
55 let body_text = resp.text().await.map_err(|e| CliError::ClientError {
56 message: format!("Failed to read response body: {}", e),
57 status,
58 })?;
59
60 if body_text.trim().is_empty() {
61 return Ok(serde_json::json!({}));
62 }
63
64 serde_json::from_str(&body_text).map_err(|e| CliError::ClientError {
65 message: format!("Failed to parse response: {}", e),
66 status,
67 })
68 }
69
70 async fn request(
71 &self,
72 method: reqwest::Method,
73 path: &str,
74 body: Option<&serde_json::Value>,
75 ) -> Result<serde_json::Value, CliError> {
76 let url = format!("{}{}", self.base_url, path);
77 let max_retries = 3;
78
79 for attempt in 0..=max_retries {
80 let mut req = self
81 .http
82 .request(method.clone(), &url)
83 .header("Authorization", &self.token)
84 .header("Content-Type", "application/json");
85
86 if let Some(b) = body {
87 req = req.json(b);
88 }
89
90 let resp = req.send().await.map_err(|e| CliError::ClientError {
91 message: format!("Request failed: {}", e),
92 status: 0,
93 })?;
94
95 let status = resp.status().as_u16();
96 self.update_rate_limits(resp.headers());
97
98 if (200..300).contains(&status) {
99 return Self::parse_success_response(resp, status).await;
100 }
101
102 if status == 429 && attempt == 0 {
104 let reset = self.rate_limit_reset.load(Ordering::Relaxed);
105 let now = std::time::SystemTime::now()
106 .duration_since(std::time::UNIX_EPOCH)
107 .unwrap()
108 .as_secs();
109 let wait = if reset > now { reset - now } else { 1 };
110 eprintln!("Rate limited. Waiting {} seconds...", wait);
111 sleep(Duration::from_secs(wait)).await;
112 continue;
113 }
114
115 if (500..=599).contains(&status) && attempt < max_retries {
117 let wait = 1u64 << attempt; eprintln!("Server error ({}). Retrying in {}s...", status, wait);
119 sleep(Duration::from_secs(wait)).await;
120 continue;
121 }
122
123 let body_text = resp.text().await.unwrap_or_default();
125 let body_json: Option<serde_json::Value> = serde_json::from_str(&body_text).ok();
126
127 let message = body_json
128 .as_ref()
129 .and_then(|v| {
130 v.get("err")
131 .or_else(|| v.get("message"))
132 .and_then(|e| e.as_str())
133 .map(String::from)
134 })
135 .unwrap_or_else(|| {
136 if body_text.is_empty() {
137 format!("HTTP {}", status)
138 } else {
139 let truncated = if body_text.chars().count() > 200 {
143 let head: String = body_text.chars().take(200).collect();
144 format!("{}...", head)
145 } else {
146 body_text
147 };
148 format!("HTTP {}: {}", status, truncated)
149 }
150 });
151
152 return match status {
153 401 => Err(CliError::AuthError { message }),
154 403 => Err(CliError::Forbidden { message }),
155 404 => Err(CliError::NotFound {
156 message,
157 resource_id: String::new(),
158 }),
159 429 => Err(CliError::RateLimited {
160 message,
161 retry_after: None,
162 }),
163 500..=599 => Err(CliError::ServerError { message }),
164 _ => Err(CliError::ClientError { message, status }),
165 };
166 }
167
168 Err(CliError::ServerError {
169 message: "Max retries exceeded".into(),
170 })
171 }
172
173 pub async fn get(&self, path: &str) -> Result<serde_json::Value, CliError> {
174 self.request(reqwest::Method::GET, path, None).await
175 }
176
177 pub async fn post(
178 &self,
179 path: &str,
180 body: &serde_json::Value,
181 ) -> Result<serde_json::Value, CliError> {
182 self.request(reqwest::Method::POST, path, Some(body)).await
183 }
184
185 pub async fn put(
186 &self,
187 path: &str,
188 body: &serde_json::Value,
189 ) -> Result<serde_json::Value, CliError> {
190 self.request(reqwest::Method::PUT, path, Some(body)).await
191 }
192
193 pub async fn delete(&self, path: &str) -> Result<serde_json::Value, CliError> {
194 self.request(reqwest::Method::DELETE, path, None).await
195 }
196
197 pub async fn patch(
198 &self,
199 path: &str,
200 body: &serde_json::Value,
201 ) -> Result<serde_json::Value, CliError> {
202 self.request(reqwest::Method::PATCH, path, Some(body)).await
203 }
204
205 pub async fn delete_with_body(
206 &self,
207 path: &str,
208 body: &serde_json::Value,
209 ) -> Result<serde_json::Value, CliError> {
210 self.request(reqwest::Method::DELETE, path, Some(body))
211 .await
212 }
213
214 pub async fn upload_file(
215 &self,
216 path: &str,
217 file_path: &std::path::Path,
218 ) -> Result<serde_json::Value, CliError> {
219 let url = format!("{}{}", self.base_url, path);
220 let file_name = file_path
221 .file_name()
222 .and_then(|n| n.to_str())
223 .unwrap_or("file")
224 .to_string();
225 let file_bytes = tokio::fs::read(file_path)
226 .await
227 .map_err(CliError::IoError)?;
228 let part = reqwest::multipart::Part::bytes(file_bytes).file_name(file_name);
229 let form = reqwest::multipart::Form::new().part("attachment", part);
230
231 let resp = self
232 .http
233 .post(&url)
234 .header("Authorization", &self.token)
235 .multipart(form)
236 .send()
237 .await
238 .map_err(|e| CliError::ClientError {
239 message: format!("Upload failed: {}", e),
240 status: 0,
241 })?;
242
243 let status = resp.status().as_u16();
244 self.update_rate_limits(resp.headers());
245
246 if (200..300).contains(&status) {
247 return Self::parse_success_response(resp, status).await;
248 }
249
250 let body_text = resp.text().await.unwrap_or_default();
251 let message = serde_json::from_str::<serde_json::Value>(&body_text)
252 .ok()
253 .and_then(|v| v.get("err").and_then(|e| e.as_str()).map(String::from))
254 .unwrap_or_else(|| format!("HTTP {}", status));
255
256 Err(match status {
257 401 => CliError::AuthError { message },
258 404 => CliError::NotFound {
259 message,
260 resource_id: String::new(),
261 },
262 429 => CliError::RateLimited {
263 message,
264 retry_after: None,
265 },
266 500..=599 => CliError::ServerError { message },
267 _ => CliError::ClientError { message, status },
268 })
269 }
270
271 pub fn with_base_url(mut self, base_url: &str) -> Self {
273 self.base_url = base_url.to_string();
274 self
275 }
276}