Skip to main content

clickup_cli/
client.rs

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            // Retry on 429 — wait for rate limit reset, retry once
103            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            // Retry on 5xx with exponential backoff
116            if (500..=599).contains(&status) && attempt < max_retries {
117                let wait = 1u64 << attempt; // 1, 2, 4 seconds
118                eprintln!("Server error ({}). Retrying in {}s...", status, wait);
119                sleep(Duration::from_secs(wait)).await;
120                continue;
121            }
122
123            // No retry — return error
124            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                        // Limit body size in error message. Slice by chars,
140                        // not bytes, so a multibyte UTF-8 codepoint at the
141                        // 200-byte boundary can't panic this branch.
142                        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    /// Override the base URL. Used in tests to point at a mock server.
272    pub fn with_base_url(mut self, base_url: &str) -> Self {
273        self.base_url = base_url.to_string();
274        self
275    }
276}