use crate::error::{ClickUpError, Result};
use reqwest::{Client as HttpClient, Response};
use serde::de::DeserializeOwned;
use serde_json::{json, Value};
use std::time::Duration;
#[derive(Clone, Debug)]
pub struct ClickUpClient {
http_client: HttpClient,
api_token: String,
base_url: String,
}
impl ClickUpClient {
pub fn new(api_token: impl Into<String>) -> Result<Self> {
let http_client = HttpClient::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.build()
.map_err(|e| {
ClickUpError::ConfigError(format!("Failed to create HTTP client: {}", e))
})?;
Ok(Self {
http_client,
api_token: api_token.into(),
base_url: "https://api.clickup.com/api/v2".to_string(),
})
}
pub fn with_timeouts(
api_token: impl Into<String>,
total_timeout_secs: u64,
connect_timeout_secs: u64,
) -> Result<Self> {
let http_client = HttpClient::builder()
.timeout(Duration::from_secs(total_timeout_secs))
.connect_timeout(Duration::from_secs(connect_timeout_secs))
.build()
.map_err(|e| {
ClickUpError::ConfigError(format!("Failed to create HTTP client: {}", e))
})?;
Ok(Self {
http_client,
api_token: api_token.into(),
base_url: "https://api.clickup.com/api/v2".to_string(),
})
}
pub(crate) async fn get(&self, endpoint: &str) -> Result<Response> {
let url = format!("{}{}", self.base_url, endpoint);
tracing::debug!("GET {}", url);
let response = self
.http_client
.get(&url)
.header("Authorization", format!("Bearer {}", &self.api_token))
.header("Content-Type", "application/json")
.send()
.await?;
self.handle_response(response).await
}
pub(crate) async fn get_json<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
let response = self.get(endpoint).await?;
let json = response.json().await?;
Ok(json)
}
pub(crate) async fn post(&self, endpoint: &str, body: &Value) -> Result<Response> {
let url = format!("{}{}", self.base_url, endpoint);
tracing::debug!(
"POST {} with body: {}",
url,
serde_json::to_string(body).unwrap_or_default()
);
let response = self
.http_client
.post(&url)
.header("Authorization", format!("Bearer {}", &self.api_token))
.header("Content-Type", "application/json")
.json(body)
.send()
.await?;
self.handle_response(response).await
}
pub(crate) async fn post_json<T: DeserializeOwned>(
&self,
endpoint: &str,
body: &Value,
) -> Result<T> {
let response = self.post(endpoint, body).await?;
let json = response.json().await?;
Ok(json)
}
pub(crate) async fn put(&self, endpoint: &str, body: &Value) -> Result<Response> {
let url = format!("{}{}", self.base_url, endpoint);
tracing::debug!("PUT {}", url);
let response = self
.http_client
.put(&url)
.header("Authorization", format!("Bearer {}", &self.api_token))
.header("Content-Type", "application/json")
.json(body)
.send()
.await?;
self.handle_response(response).await
}
pub(crate) async fn put_json<T: DeserializeOwned>(
&self,
endpoint: &str,
body: &Value,
) -> Result<T> {
let response = self.put(endpoint, body).await?;
let json = response.json().await?;
Ok(json)
}
#[allow(dead_code)] pub(crate) async fn delete(&self, endpoint: &str) -> Result<Response> {
let url = format!("{}{}", self.base_url, endpoint);
tracing::debug!("DELETE {}", url);
let response = self
.http_client
.delete(&url)
.header("Authorization", format!("Bearer {}", &self.api_token))
.header("Content-Type", "application/json")
.send()
.await?;
self.handle_response(response).await
}
pub(crate) async fn delete_json<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
let response = self.delete(endpoint).await?;
let json = response.json().await?;
Ok(json)
}
async fn handle_response(&self, response: Response) -> Result<Response> {
let status = response.status();
if status.is_success() {
Ok(response)
} else {
let status_code = status.as_u16();
let error_body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
tracing::error!("ClickUp API error ({}): {}", status_code, error_body);
let message = if let Ok(json) = serde_json::from_str::<Value>(&error_body) {
json.get("err")
.or_else(|| json.get("error"))
.or_else(|| json.get("message"))
.and_then(|v| v.as_str())
.unwrap_or(&error_body)
.to_string()
} else {
error_body
};
Err(ClickUpError::ApiError {
status: status_code,
message,
})
}
}
pub fn token(&self) -> &str {
&self.api_token
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn find_list_by_name(
&self,
folder_id: &str,
list_name: &str,
) -> Result<Option<ListInfo>> {
let endpoint = format!("/folder/{}", folder_id);
let folder_data: Value = self.get_json(&endpoint).await?;
if let Some(lists) = folder_data["lists"].as_array() {
for list in lists {
if let Some(name) = list["name"].as_str() {
if name.eq_ignore_ascii_case(list_name) {
let id = list["id"]
.as_str()
.map(|s| s.to_string())
.or_else(|| list["id"].as_u64().map(|n| n.to_string()))
.unwrap_or_else(String::new);
return Ok(Some(ListInfo {
id,
name: name.to_string(),
}));
}
}
}
}
Ok(None)
}
pub async fn create_list(
&self,
folder_id: &str,
list_data: &CreateListRequest,
) -> Result<ListInfo> {
let endpoint = format!("/folder/{}/list", folder_id);
let body = json!({
"name": list_data.name,
"content": list_data.content,
"due_date": list_data.due_date,
"priority": list_data.priority,
"assignee": list_data.assignee,
"status": list_data.status
});
let response: Value = self.post_json(&endpoint, &body).await?;
let id = response["id"]
.as_str()
.map(|s| s.to_string())
.or_else(|| response["id"].as_u64().map(|n| n.to_string()))
.unwrap_or_default();
let name = response["name"]
.as_str()
.unwrap_or(&list_data.name)
.to_string();
Ok(ListInfo { id, name })
}
}
#[derive(Debug, Clone)]
pub struct ListInfo {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone)]
pub struct CreateListRequest {
pub name: String,
pub content: Option<String>,
pub due_date: Option<i64>,
pub priority: Option<u32>,
pub assignee: Option<String>,
pub status: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = ClickUpClient::new("test-token").unwrap();
assert_eq!(client.token(), "test-token");
assert_eq!(client.base_url(), "https://api.clickup.com/api/v2");
}
#[test]
fn test_client_with_custom_timeouts() {
let client = ClickUpClient::with_timeouts("test-token", 60, 10).unwrap();
assert_eq!(client.token(), "test-token");
}
}