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