todoist_api/
wrapper.rs

1use reqwest::Client;
2use serde_json::Value;
3
4use crate::models::*;
5
6const TODOIST_API_BASE: &str = "https://api.todoist.com/rest/v2";
7
8/// A comprehensive wrapper around the Todoist REST API v2
9#[derive(Clone)]
10pub struct TodoistWrapper {
11    client: Client,
12    api_token: String,
13}
14
15impl TodoistWrapper {
16    /// Create a new Todoist client
17    #[must_use]
18    pub fn new(api_token: String) -> Self {
19        let client = Client::builder()
20            .timeout(std::time::Duration::from_secs(10))
21            .build()
22            .unwrap_or_else(|_| Client::new());
23        Self { client, api_token }
24    }
25
26    /// Helper method for making GET requests
27    async fn make_get_request<T>(&self, endpoint: &str) -> TodoistResult<T>
28    where
29        T: serde::de::DeserializeOwned,
30    {
31        self.make_get_request_with_params(endpoint, &[] as &[(&str, String)])
32            .await
33    }
34
35    /// Helper method for making GET requests with query parameters
36    async fn make_get_request_with_params<T>(&self, endpoint: &str, query_params: &[(&str, String)]) -> TodoistResult<T>
37    where
38        T: serde::de::DeserializeOwned,
39    {
40        let url = format!("{TODOIST_API_BASE}{endpoint}");
41
42        let response = self
43            .client
44            .get(&url)
45            .query(query_params)
46            .bearer_auth(&self.api_token)
47            .send()
48            .await
49            .map_err(|e| TodoistError::NetworkError {
50                message: format!("Failed to send request: {}", e),
51            })?;
52
53        self.handle_response("GET", endpoint, response).await
54    }
55
56    /// Helper method for making POST requests
57    async fn make_post_request<T>(&self, endpoint: &str, body: Option<&Value>) -> TodoistResult<T>
58    where
59        T: serde::de::DeserializeOwned,
60    {
61        let url = format!("{TODOIST_API_BASE}{endpoint}");
62        let mut request = self
63            .client
64            .post(&url)
65            .bearer_auth(&self.api_token)
66            .header("Content-Type", "application/json");
67
68        if let Some(body_value) = body {
69            request = request.json(body_value);
70        }
71
72        let response = request.send().await.map_err(|e| TodoistError::NetworkError {
73            message: format!("Failed to send request: {}", e),
74        })?;
75
76        self.handle_response("POST", endpoint, response).await
77    }
78
79    /// Helper method for making DELETE requests
80    async fn make_delete_request<T>(&self, endpoint: &str) -> TodoistResult<T>
81    where
82        T: serde::de::DeserializeOwned,
83    {
84        let url = format!("{TODOIST_API_BASE}{endpoint}");
85        let response = self
86            .client
87            .delete(&url)
88            .bearer_auth(&self.api_token)
89            .send()
90            .await
91            .map_err(|e| TodoistError::NetworkError {
92                message: format!("Failed to send request: {}", e),
93            })?;
94
95        self.handle_response("DELETE", endpoint, response).await
96    }
97
98    /// Helper method to handle HTTP responses and convert them to TodoistResult
99    async fn handle_response<T>(
100        &self,
101        http_method: &str,
102        endpoint: &str,
103        response: reqwest::Response,
104    ) -> TodoistResult<T>
105    where
106        T: serde::de::DeserializeOwned,
107    {
108        let status = response.status();
109        let headers = response.headers().clone();
110
111        if status.is_success() {
112            // Read response body
113            let text = response.text().await.map_err(|e| TodoistError::NetworkError {
114                message: format!("Failed to read response body: {}", e),
115            })?;
116
117            // For DELETE requests, empty responses are expected and valid
118            if http_method == "DELETE" && text.trim().is_empty() {
119                // Try to deserialize "null" for empty DELETE responses
120                return serde_json::from_str::<T>("null").map_err(|e| TodoistError::ParseError {
121                    message: format!("Failed to deserialize empty DELETE response: {}", e),
122                });
123            }
124
125            // For POST requests to close/reopen tasks, empty responses or 204 are expected and valid
126            if http_method == "POST" && (status.as_u16() == 204 || text.trim().is_empty()) {
127                // Try to deserialize "null" for empty POST responses
128                return serde_json::from_str::<T>("null").map_err(|e| TodoistError::ParseError {
129                    message: format!("Failed to deserialize empty POST response: {}", e),
130                });
131            }
132
133            // Handle empty responses for other methods
134            if text.trim().is_empty() {
135                return Err(empty_response_error(endpoint, "API returned empty response body"));
136            }
137
138            // Try to parse response
139            serde_json::from_str::<T>(&text).map_err(|e| TodoistError::ParseError {
140                message: format!("Failed to parse response: {}", e),
141            })
142        } else {
143            // Handle different error status codes
144            let error_text = response
145                .text()
146                .await
147                .unwrap_or_else(|_| format!("Unknown error occurred (HTTP {})", status));
148
149            let error = match status.as_u16() {
150                401 => TodoistError::AuthenticationError { message: error_text },
151                403 => TodoistError::AuthorizationError { message: error_text },
152                404 => TodoistError::NotFound {
153                    resource_type: "Resource".to_string(),
154                    resource_id: None,
155                    message: error_text,
156                },
157                429 => {
158                    let retry_after = headers
159                        .get("Retry-After")
160                        .and_then(|v| v.to_str().ok())
161                        .and_then(|s| s.parse::<u64>().ok());
162                    TodoistError::RateLimited {
163                        retry_after,
164                        message: error_text,
165                    }
166                }
167                400 => TodoistError::ValidationError {
168                    field: None,
169                    message: error_text,
170                },
171                500..=599 => TodoistError::ServerError {
172                    status_code: status.as_u16(),
173                    message: error_text,
174                },
175                _ => TodoistError::Generic {
176                    status_code: Some(status.as_u16()),
177                    message: error_text,
178                },
179            };
180
181            Err(error)
182        }
183    }
184
185    // ===== PROJECT OPERATIONS =====
186
187    /// Get all projects
188    pub async fn get_projects(&self) -> TodoistResult<Vec<Project>> {
189        self.make_get_request("/projects").await
190    }
191
192    /// Get projects with filtering and pagination
193    pub async fn get_projects_filtered(&self, args: &ProjectFilterArgs) -> TodoistResult<Vec<Project>> {
194        let mut query_params = Vec::new();
195
196        if let Some(limit) = args.limit {
197            query_params.push(("limit", limit.to_string()));
198        }
199        if let Some(cursor) = &args.cursor {
200            query_params.push(("cursor", cursor.clone()));
201        }
202
203        self.make_get_request_with_params("/projects", &query_params).await
204    }
205
206    /// Get a specific project by ID
207    pub async fn get_project(&self, project_id: &str) -> TodoistResult<Project> {
208        self.make_get_request(&format!("/projects/{project_id}")).await
209    }
210
211    /// Create a new project
212    pub async fn create_project(&self, args: &CreateProjectArgs) -> TodoistResult<Project> {
213        let body_value = serde_json::to_value(args)?;
214        self.make_post_request("/projects", Some(&body_value)).await
215    }
216
217    /// Update an existing project
218    pub async fn update_project(&self, project_id: &str, args: &UpdateProjectArgs) -> TodoistResult<Project> {
219        if !args.has_updates() {
220            return Err(TodoistError::ValidationError {
221                field: None,
222                message: "No fields specified for update".to_string(),
223            });
224        }
225        let body_value = serde_json::to_value(args)?;
226        self.make_post_request(&format!("/projects/{project_id}"), Some(&body_value))
227            .await
228    }
229
230    /// Delete a project
231    pub async fn delete_project(&self, project_id: &str) -> TodoistResult<()> {
232        self.make_delete_request(&format!("/projects/{project_id}")).await
233    }
234
235    // ===== TASK OPERATIONS =====
236
237    /// Get all tasks
238    pub async fn get_tasks(&self) -> TodoistResult<Vec<Task>> {
239        self.make_get_request("/tasks").await
240    }
241
242    /// Get tasks for a specific project
243    pub async fn get_tasks_for_project(&self, project_id: &str) -> TodoistResult<Vec<Task>> {
244        let query_params = vec![("project_id", project_id.to_string())];
245        self.make_get_request_with_params("/tasks", &query_params).await
246    }
247
248    /// Get a specific task by ID
249    pub async fn get_task(&self, task_id: &str) -> TodoistResult<Task> {
250        self.make_get_request(&format!("/tasks/{task_id}")).await
251    }
252
253    /// Get tasks by filter query
254    pub async fn get_tasks_by_filter(&self, args: &TaskFilterArgs) -> TodoistResult<Vec<Task>> {
255        let mut query_params = vec![("query", args.query.clone())];
256
257        if let Some(lang) = &args.lang {
258            query_params.push(("lang", lang.clone()));
259        }
260        if let Some(limit) = args.limit {
261            query_params.push(("limit", limit.to_string()));
262        }
263        if let Some(cursor) = &args.cursor {
264            query_params.push(("cursor", cursor.clone()));
265        }
266
267        self.make_get_request_with_params("/tasks", &query_params).await
268    }
269
270    /// Create a new task
271    pub async fn create_task(&self, args: &CreateTaskArgs) -> TodoistResult<Task> {
272        let body_value = serde_json::to_value(args)?;
273        self.make_post_request("/tasks", Some(&body_value)).await
274    }
275
276    /// Update an existing task
277    pub async fn update_task(&self, task_id: &str, args: &UpdateTaskArgs) -> TodoistResult<Task> {
278        if !args.has_updates() {
279            return Err(TodoistError::ValidationError {
280                field: None,
281                message: "No fields specified for update".to_string(),
282            });
283        }
284        let body_value = serde_json::to_value(args)?;
285        self.make_post_request(&format!("/tasks/{task_id}"), Some(&body_value))
286            .await
287    }
288
289    /// Complete a task
290    pub async fn complete_task(&self, task_id: &str) -> TodoistResult<()> {
291        self.make_post_request(&format!("/tasks/{task_id}/close"), None).await
292    }
293
294    /// Reopen a completed task
295    pub async fn reopen_task(&self, task_id: &str) -> TodoistResult<()> {
296        self.make_post_request(&format!("/tasks/{task_id}/reopen"), None).await
297    }
298
299    /// Delete a task
300    pub async fn delete_task(&self, task_id: &str) -> TodoistResult<()> {
301        self.make_delete_request(&format!("/tasks/{task_id}")).await
302    }
303
304    // ===== LABEL OPERATIONS =====
305
306    /// Get all labels
307    pub async fn get_labels(&self) -> TodoistResult<Vec<Label>> {
308        self.make_get_request("/labels").await
309    }
310
311    /// Get labels with filtering and pagination
312    pub async fn get_labels_filtered(&self, args: &LabelFilterArgs) -> TodoistResult<Vec<Label>> {
313        let mut query_params = Vec::new();
314
315        if let Some(limit) = args.limit {
316            query_params.push(("limit", limit.to_string()));
317        }
318        if let Some(cursor) = &args.cursor {
319            query_params.push(("cursor", cursor.clone()));
320        }
321
322        self.make_get_request_with_params("/labels", &query_params).await
323    }
324
325    /// Get a specific label by ID
326    pub async fn get_label(&self, label_id: &str) -> TodoistResult<Label> {
327        self.make_get_request(&format!("/labels/{label_id}")).await
328    }
329
330    /// Create a new label
331    pub async fn create_label(&self, args: &CreateLabelArgs) -> TodoistResult<Label> {
332        let body_value = serde_json::to_value(args)?;
333        self.make_post_request("/labels", Some(&body_value)).await
334    }
335
336    /// Update an existing label
337    pub async fn update_label(&self, label_id: &str, args: &UpdateLabelArgs) -> TodoistResult<Label> {
338        if !args.has_updates() {
339            return Err(TodoistError::ValidationError {
340                field: None,
341                message: "No fields specified for update".to_string(),
342            });
343        }
344        let body_value = serde_json::to_value(args)?;
345        self.make_post_request(&format!("/labels/{label_id}"), Some(&body_value))
346            .await
347    }
348
349    /// Delete a label
350    pub async fn delete_label(&self, label_id: &str) -> TodoistResult<()> {
351        self.make_delete_request(&format!("/labels/{label_id}")).await
352    }
353
354    // ===== SECTION OPERATIONS =====
355
356    /// Get all sections
357    pub async fn get_sections(&self) -> TodoistResult<Vec<Section>> {
358        self.make_get_request("/sections").await
359    }
360
361    /// Get sections with filtering and pagination
362    pub async fn get_sections_filtered(&self, args: &SectionFilterArgs) -> TodoistResult<Vec<Section>> {
363        let mut query_params = Vec::new();
364
365        if let Some(project_id) = &args.project_id {
366            query_params.push(("project_id", project_id.clone()));
367        }
368        if let Some(limit) = args.limit {
369            query_params.push(("limit", limit.to_string()));
370        }
371        if let Some(cursor) = &args.cursor {
372            query_params.push(("cursor", cursor.clone()));
373        }
374
375        self.make_get_request_with_params("/sections", &query_params).await
376    }
377
378    /// Get a specific section by ID
379    pub async fn get_section(&self, section_id: &str) -> TodoistResult<Section> {
380        self.make_get_request(&format!("/sections/{section_id}")).await
381    }
382
383    /// Create a new section
384    pub async fn create_section(&self, args: &CreateSectionArgs) -> TodoistResult<Section> {
385        let body_value = serde_json::to_value(args)?;
386        self.make_post_request("/sections", Some(&body_value)).await
387    }
388
389    /// Update an existing section
390    pub async fn update_section(&self, section_id: &str, args: &UpdateSectionArgs) -> TodoistResult<Section> {
391        let body_value = serde_json::to_value(args)?;
392        self.make_post_request(&format!("/sections/{section_id}"), Some(&body_value))
393            .await
394    }
395
396    /// Delete a section
397    pub async fn delete_section(&self, section_id: &str) -> TodoistResult<()> {
398        self.make_delete_request(&format!("/sections/{section_id}")).await
399    }
400
401    // ===== COMMENT OPERATIONS =====
402
403    /// Get all comments
404    pub async fn get_comments(&self) -> TodoistResult<Vec<Comment>> {
405        self.make_get_request("/comments").await
406    }
407
408    /// Get comments with filtering and pagination
409    pub async fn get_comments_filtered(&self, args: &CommentFilterArgs) -> TodoistResult<Vec<Comment>> {
410        let mut query_params = Vec::new();
411
412        if let Some(task_id) = &args.task_id {
413            query_params.push(("task_id", task_id.clone()));
414        }
415        if let Some(project_id) = &args.project_id {
416            query_params.push(("project_id", project_id.clone()));
417        }
418        if let Some(limit) = args.limit {
419            query_params.push(("limit", limit.to_string()));
420        }
421        if let Some(cursor) = &args.cursor {
422            query_params.push(("cursor", cursor.clone()));
423        }
424
425        self.make_get_request_with_params("/comments", &query_params).await
426    }
427
428    /// Get a specific comment by ID
429    pub async fn get_comment(&self, comment_id: &str) -> TodoistResult<Comment> {
430        self.make_get_request(&format!("/comments/{comment_id}")).await
431    }
432
433    /// Create a new comment
434    pub async fn create_comment(&self, args: &CreateCommentArgs) -> TodoistResult<Comment> {
435        let body_value = serde_json::to_value(args)?;
436        self.make_post_request("/comments", Some(&body_value)).await
437    }
438
439    /// Update an existing comment
440    pub async fn update_comment(&self, comment_id: &str, args: &UpdateCommentArgs) -> TodoistResult<Comment> {
441        if !args.has_updates() {
442            return Err(TodoistError::ValidationError {
443                field: None,
444                message: "No fields specified for update".to_string(),
445            });
446        }
447        let body_value = serde_json::to_value(args)?;
448        self.make_post_request(&format!("/comments/{comment_id}"), Some(&body_value))
449            .await
450    }
451
452    /// Delete a comment
453    pub async fn delete_comment(&self, comment_id: &str) -> TodoistResult<()> {
454        self.make_delete_request(&format!("/comments/{comment_id}")).await
455    }
456}