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        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            // Retry on 429 — wait for rate limit reset, retry once
85            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            // Retry on 5xx with exponential backoff
98            if (500..=599).contains(&status) && attempt < max_retries {
99                let wait = 1u64 << attempt; // 1, 2, 4 seconds
100                eprintln!("Server error ({}). Retrying in {}s...", status, wait);
101                sleep(Duration::from_secs(wait)).await;
102                continue;
103            }
104
105            // No retry — return error
106            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    /// Override the base URL. Used in tests to point at a mock server.
238    pub fn with_base_url(mut self, base_url: &str) -> Self {
239        self.base_url = base_url.to_string();
240        self
241    }
242}