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