1use reqwest::header::HeaderMap;
2use serde::{de::DeserializeOwned, Deserialize};
3use std::sync::Arc;
4use tracing::{debug, warn};
5
6use crate::auth::AuthStrategy;
7use crate::error::{Error, Result};
8use crate::types::*;
9
10const DEFAULT_BASE_URL: &str = "https://app.asana.com/api/1.0";
11
12pub struct ClientBuilder<A> {
13 auth: A,
14 base_url: String,
15}
16
17impl ClientBuilder<()> {
18 pub fn new() -> Self {
19 Self {
20 auth: (),
21 base_url: DEFAULT_BASE_URL.to_string(),
22 }
23 }
24
25 pub fn auth<S: AuthStrategy + 'static>(self, auth: S) -> ClientBuilder<S> {
26 ClientBuilder {
27 auth,
28 base_url: self.base_url,
29 }
30 }
31}
32
33impl Default for ClientBuilder<()> {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl<A: AuthStrategy + 'static> ClientBuilder<A> {
40 pub fn base_url(mut self, url: impl Into<String>) -> Self {
41 self.base_url = url.into();
42 self
43 }
44
45 pub fn build(self) -> Client {
46 Client {
47 http: reqwest::Client::new(),
48 auth: Arc::new(self.auth),
49 base_url: self.base_url,
50 }
51 }
52}
53
54#[derive(Clone)]
55pub struct Client {
56 http: reqwest::Client,
57 auth: Arc<dyn AuthStrategy>,
58 base_url: String,
59}
60
61impl Client {
62 pub fn builder() -> ClientBuilder<()> {
63 ClientBuilder::new()
64 }
65
66 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
67 self.request(reqwest::Method::GET, path, None::<&()>).await
68 }
69
70 async fn post<T: DeserializeOwned, B: serde::Serialize>(
71 &self,
72 path: &str,
73 body: &B,
74 ) -> Result<T> {
75 self.request(reqwest::Method::POST, path, Some(body)).await
76 }
77
78 async fn put<T: DeserializeOwned, B: serde::Serialize>(
79 &self,
80 path: &str,
81 body: &B,
82 ) -> Result<T> {
83 self.request(reqwest::Method::PUT, path, Some(body)).await
84 }
85
86 async fn request<T: DeserializeOwned>(
87 &self,
88 method: reqwest::Method,
89 path: &str,
90 body: Option<&impl serde::Serialize>,
91 ) -> Result<T> {
92 let url = format!("{}{}", self.base_url, path);
93 debug!("Asana API request: {} {}", method, url);
94
95 let mut headers = HeaderMap::new();
96 self.auth.apply(&mut headers).await?;
97 headers.insert("Content-Type", "application/json".parse().unwrap());
98
99 let mut request = self.http.request(method, &url).headers(headers);
100
101 if let Some(body) = body {
102 request = request.json(&serde_json::json!({ "data": body }));
103 }
104
105 let response = request.send().await?;
106 self.handle_response(response).await
107 }
108
109 async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
110 let status = response.status();
111
112 if status.is_success() {
113 let body = response.text().await?;
114 let resp: AsanaResponse<T> = serde_json::from_str(&body)?;
115 Ok(resp.data)
116 } else {
117 let status_code = status.as_u16();
118 let body = response.text().await.unwrap_or_default();
119 warn!("Asana API error ({}): {}", status_code, body);
120
121 match status_code {
122 401 => Err(Error::Unauthorized),
123 403 => Err(Error::Forbidden(body)),
124 404 => Err(Error::NotFound(body)),
125 429 => {
126 let retry_after = 60;
127 Err(Error::RateLimited { retry_after })
128 }
129 _ => Err(Error::Api {
130 status: status_code,
131 message: body,
132 }),
133 }
134 }
135 }
136
137 pub async fn get_task(&self, gid: &str) -> Result<Task> {
139 self.get(&format!("/tasks/{}", gid)).await
140 }
141
142 pub async fn create_task(&self, input: CreateTaskInput) -> Result<Task> {
144 self.post("/tasks", &input).await
145 }
146
147 pub async fn update_task(&self, gid: &str, input: UpdateTaskInput) -> Result<Task> {
149 self.put(&format!("/tasks/{}", gid), &input).await
150 }
151
152 pub async fn complete_task(&self, gid: &str) -> Result<Task> {
154 self.update_task(gid, UpdateTaskInput::new().completed(true))
155 .await
156 }
157
158 pub async fn list_tasks(&self, project_gid: &str) -> Result<Vec<Task>> {
160 #[derive(Deserialize)]
161 struct Response {
162 data: Vec<Task>,
163 }
164
165 let url = format!("{}/projects/{}/tasks?opt_fields=name,notes,completed,due_on,assignee,created_at,modified_at,permalink_url", self.base_url, project_gid);
166
167 let mut headers = HeaderMap::new();
168 self.auth.apply(&mut headers).await?;
169
170 let response = self.http.get(&url).headers(headers).send().await?;
171
172 if response.status().is_success() {
173 let body = response.text().await?;
174 let resp: Response = serde_json::from_str(&body)?;
175 Ok(resp.data)
176 } else {
177 let status = response.status().as_u16();
178 let body = response.text().await.unwrap_or_default();
179 Err(Error::Api { status, message: body })
180 }
181 }
182
183 pub async fn list_projects(&self, workspace_gid: &str) -> Result<Vec<Project>> {
185 #[derive(Deserialize)]
186 struct Response {
187 data: Vec<Project>,
188 }
189
190 let url = format!("{}/workspaces/{}/projects", self.base_url, workspace_gid);
191
192 let mut headers = HeaderMap::new();
193 self.auth.apply(&mut headers).await?;
194
195 let response = self.http.get(&url).headers(headers).send().await?;
196
197 if response.status().is_success() {
198 let body = response.text().await?;
199 let resp: Response = serde_json::from_str(&body)?;
200 Ok(resp.data)
201 } else {
202 let status = response.status().as_u16();
203 let body = response.text().await.unwrap_or_default();
204 Err(Error::Api { status, message: body })
205 }
206 }
207
208 pub async fn list_workspaces(&self) -> Result<Vec<Workspace>> {
210 #[derive(Deserialize)]
211 struct Response {
212 data: Vec<Workspace>,
213 }
214
215 let url = format!("{}/workspaces", self.base_url);
216
217 let mut headers = HeaderMap::new();
218 self.auth.apply(&mut headers).await?;
219
220 let response = self.http.get(&url).headers(headers).send().await?;
221
222 if response.status().is_success() {
223 let body = response.text().await?;
224 let resp: Response = serde_json::from_str(&body)?;
225 Ok(resp.data)
226 } else {
227 let status = response.status().as_u16();
228 let body = response.text().await.unwrap_or_default();
229 Err(Error::Api { status, message: body })
230 }
231 }
232
233 pub async fn add_task_to_project(&self, task_gid: &str, input: AddToProjectInput) -> Result<()> {
235 let url = format!("{}/tasks/{}/addProject", self.base_url, task_gid);
236
237 let mut headers = HeaderMap::new();
238 self.auth.apply(&mut headers).await?;
239 headers.insert("Content-Type", "application/json".parse().unwrap());
240
241 let response = self
242 .http
243 .post(&url)
244 .headers(headers)
245 .json(&serde_json::json!({ "data": input }))
246 .send()
247 .await?;
248
249 if response.status().is_success() {
250 Ok(())
251 } else {
252 let status = response.status().as_u16();
253 let body = response.text().await.unwrap_or_default();
254 Err(Error::Api { status, message: body })
255 }
256 }
257
258 pub async fn me(&self) -> Result<User> {
260 self.get("/users/me").await
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::auth::BearerAuth;
268
269 #[test]
270 fn test_builder() {
271 let client = Client::builder()
272 .auth(BearerAuth::new("test-token"))
273 .build();
274 assert_eq!(client.base_url, DEFAULT_BASE_URL);
275 }
276}