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/api/v1";
7
8/// A comprehensive wrapper around the Todoist Unified API v1
9#[derive(Clone)]
10pub struct TodoistWrapper {
11    client: Client,
12    api_token: String,
13    base_url: String,
14}
15
16impl TodoistWrapper {
17    /// Create a new Todoist client
18    #[must_use]
19    pub fn new(api_token: String) -> Self {
20        let client = Client::builder()
21            .timeout(std::time::Duration::from_secs(10))
22            .build()
23            .unwrap_or_else(|_| Client::new());
24        Self {
25            client,
26            api_token,
27            base_url: TODOIST_API_BASE.to_string(),
28        }
29    }
30
31    /// Create a new Todoist client with custom base URL (for testing)
32    #[doc(hidden)]
33    #[must_use]
34    pub fn with_base_url(api_token: String, base_url: String) -> Self {
35        let client = Client::builder()
36            .timeout(std::time::Duration::from_secs(10))
37            .build()
38            .unwrap_or_else(|_| Client::new());
39        Self {
40            client,
41            api_token,
42            base_url,
43        }
44    }
45
46    /// Helper method for making GET requests
47    async fn make_get_request<T>(&self, endpoint: &str) -> TodoistResult<T>
48    where
49        T: serde::de::DeserializeOwned,
50    {
51        self.make_get_request_with_params(endpoint, &[] as &[(&str, String)])
52            .await
53    }
54
55    /// Helper method for making GET requests with query parameters
56    async fn make_get_request_with_params<T>(&self, endpoint: &str, query_params: &[(&str, String)]) -> TodoistResult<T>
57    where
58        T: serde::de::DeserializeOwned,
59    {
60        let url = format!(
61            "{}/{}",
62            self.base_url.trim_end_matches('/'),
63            endpoint.trim_start_matches('/')
64        );
65
66        let response = self
67            .client
68            .get(&url)
69            .query(query_params)
70            .bearer_auth(&self.api_token)
71            .send()
72            .await
73            .map_err(|e| TodoistError::NetworkError {
74                message: format!("Failed to send request: {}", e),
75            })?;
76
77        self.handle_response("GET", endpoint, response).await
78    }
79
80    /// Helper method for making POST requests
81    async fn make_post_request<T>(&self, endpoint: &str, body: Option<&Value>) -> TodoistResult<T>
82    where
83        T: serde::de::DeserializeOwned,
84    {
85        let url = format!(
86            "{}/{}",
87            self.base_url.trim_end_matches('/'),
88            endpoint.trim_start_matches('/')
89        );
90        let mut request = self
91            .client
92            .post(&url)
93            .bearer_auth(&self.api_token)
94            .header("Content-Type", "application/json");
95
96        if let Some(body_value) = body {
97            request = request.json(body_value);
98        }
99
100        let response = request.send().await.map_err(|e| TodoistError::NetworkError {
101            message: format!("Failed to send request: {}", e),
102        })?;
103
104        self.handle_response("POST", endpoint, response).await
105    }
106
107    /// Helper method for making DELETE requests
108    async fn make_delete_request<T>(&self, endpoint: &str) -> TodoistResult<T>
109    where
110        T: serde::de::DeserializeOwned,
111    {
112        let url = format!(
113            "{}/{}",
114            self.base_url.trim_end_matches('/'),
115            endpoint.trim_start_matches('/')
116        );
117        let response = self
118            .client
119            .delete(&url)
120            .bearer_auth(&self.api_token)
121            .send()
122            .await
123            .map_err(|e| TodoistError::NetworkError {
124                message: format!("Failed to send request: {}", e),
125            })?;
126
127        self.handle_response("DELETE", endpoint, response).await
128    }
129
130    /// Helper method for making GET requests that return paginated responses
131    /// This is used for API v1 list endpoints that return {results: [...], next_cursor: "..."}
132    async fn make_get_request_paginated<T>(
133        &self,
134        endpoint: &str,
135        query_params: &[(&str, String)],
136    ) -> TodoistResult<PaginatedResponse<T>>
137    where
138        T: serde::de::DeserializeOwned,
139    {
140        let url = format!(
141            "{}/{}",
142            self.base_url.trim_end_matches('/'),
143            endpoint.trim_start_matches('/')
144        );
145
146        let response = self
147            .client
148            .get(&url)
149            .query(query_params)
150            .bearer_auth(&self.api_token)
151            .send()
152            .await
153            .map_err(|e| TodoistError::NetworkError {
154                message: format!("Failed to send request: {}", e),
155            })?;
156
157        self.handle_response("GET", endpoint, response).await
158    }
159
160    /// Helper method to handle HTTP responses and convert them to TodoistResult
161    async fn handle_response<T>(
162        &self,
163        http_method: &str,
164        endpoint: &str,
165        response: reqwest::Response,
166    ) -> TodoistResult<T>
167    where
168        T: serde::de::DeserializeOwned,
169    {
170        let status = response.status();
171        let headers = response.headers().clone();
172
173        if status.is_success() {
174            // Read response body
175            let text = response.text().await.map_err(|e| TodoistError::NetworkError {
176                message: format!("Failed to read response body: {}", e),
177            })?;
178
179            // For DELETE requests, empty responses are expected and valid
180            if http_method == "DELETE" && text.trim().is_empty() {
181                // Try to deserialize "null" for empty DELETE responses
182                return serde_json::from_str::<T>("null").map_err(|e| TodoistError::ParseError {
183                    message: format!("Failed to deserialize empty DELETE response: {}", e),
184                });
185            }
186
187            // For POST requests to close/reopen tasks, empty responses or 204 are expected and valid
188            if http_method == "POST" && (status.as_u16() == 204 || text.trim().is_empty()) {
189                // Try to deserialize "null" for empty POST responses
190                return serde_json::from_str::<T>("null").map_err(|e| TodoistError::ParseError {
191                    message: format!("Failed to deserialize empty POST response: {}", e),
192                });
193            }
194
195            // Handle empty responses for other methods
196            if text.trim().is_empty() {
197                return Err(empty_response_error(endpoint, "API returned empty response body"));
198            }
199
200            // Try to parse response
201            serde_json::from_str::<T>(&text).map_err(|e| TodoistError::ParseError {
202                message: format!("Failed to parse response: {}", e),
203            })
204        } else {
205            // Handle different error status codes
206            let error_text = response
207                .text()
208                .await
209                .unwrap_or_else(|_| format!("Unknown error occurred (HTTP {})", status));
210
211            let error = match status.as_u16() {
212                401 => TodoistError::AuthenticationError { message: error_text },
213                403 => TodoistError::AuthorizationError { message: error_text },
214                404 => TodoistError::NotFound {
215                    resource_type: "Resource".to_string(),
216                    resource_id: None,
217                    message: error_text,
218                },
219                429 => {
220                    let retry_after = headers
221                        .get("Retry-After")
222                        .and_then(|v| v.to_str().ok())
223                        .and_then(|s| s.parse::<u64>().ok());
224                    TodoistError::RateLimited {
225                        retry_after,
226                        message: error_text,
227                    }
228                }
229                400 => TodoistError::ValidationError {
230                    field: None,
231                    message: error_text,
232                },
233                500..=599 => TodoistError::ServerError {
234                    status_code: status.as_u16(),
235                    message: error_text,
236                },
237                _ => TodoistError::Generic {
238                    status_code: Some(status.as_u16()),
239                    message: error_text,
240                },
241            };
242
243            Err(error)
244        }
245    }
246
247    // ===== PROJECT OPERATIONS =====
248
249    /// Get all projects (paginated)
250    pub async fn get_projects(
251        &self,
252        limit: Option<i32>,
253        cursor: Option<String>,
254    ) -> TodoistResult<PaginatedResponse<Project>> {
255        let mut query_params = Vec::new();
256        if let Some(l) = limit {
257            query_params.push(("limit", l.to_string()));
258        }
259        if let Some(c) = cursor {
260            query_params.push(("cursor", c));
261        }
262        self.make_get_request_paginated("/projects", &query_params).await
263    }
264
265    /// Get projects with filtering and pagination
266    pub async fn get_projects_filtered(&self, args: &ProjectFilterArgs) -> TodoistResult<Vec<Project>> {
267        let mut query_params = Vec::new();
268
269        if let Some(limit) = args.limit {
270            query_params.push(("limit", limit.to_string()));
271        }
272        if let Some(cursor) = &args.cursor {
273            query_params.push(("cursor", cursor.clone()));
274        }
275
276        self.make_get_request_with_params("/projects", &query_params).await
277    }
278
279    /// Get a specific project by ID
280    pub async fn get_project(&self, project_id: &str) -> TodoistResult<Project> {
281        self.make_get_request(&format!("/projects/{project_id}")).await
282    }
283
284    /// Create a new project
285    pub async fn create_project(&self, args: &CreateProjectArgs) -> TodoistResult<Project> {
286        let body_value = serde_json::to_value(args)?;
287        self.make_post_request("/projects", Some(&body_value)).await
288    }
289
290    /// Update an existing project
291    pub async fn update_project(&self, project_id: &str, args: &UpdateProjectArgs) -> TodoistResult<Project> {
292        if !args.has_updates() {
293            return Err(TodoistError::ValidationError {
294                field: None,
295                message: "No fields specified for update".to_string(),
296            });
297        }
298        let body_value = serde_json::to_value(args)?;
299        self.make_post_request(&format!("/projects/{project_id}"), Some(&body_value))
300            .await
301    }
302
303    /// Delete a project
304    pub async fn delete_project(&self, project_id: &str) -> TodoistResult<()> {
305        self.make_delete_request(&format!("/projects/{project_id}")).await
306    }
307
308    // ===== TASK OPERATIONS =====
309
310    /// Get all tasks (paginated)
311    pub async fn get_tasks(
312        &self,
313        limit: Option<i32>,
314        cursor: Option<String>,
315    ) -> TodoistResult<PaginatedResponse<Task>> {
316        let mut query_params = Vec::new();
317        if let Some(l) = limit {
318            query_params.push(("limit", l.to_string()));
319        }
320        if let Some(c) = cursor {
321            query_params.push(("cursor", c));
322        }
323        self.make_get_request_paginated("/tasks", &query_params).await
324    }
325
326    /// Get tasks for a specific project (paginated)
327    pub async fn get_tasks_for_project(
328        &self,
329        project_id: &str,
330        limit: Option<i32>,
331        cursor: Option<String>,
332    ) -> TodoistResult<PaginatedResponse<Task>> {
333        let mut query_params = vec![("project_id", project_id.to_string())];
334        if let Some(l) = limit {
335            query_params.push(("limit", l.to_string()));
336        }
337        if let Some(c) = cursor {
338            query_params.push(("cursor", c));
339        }
340        self.make_get_request_paginated("/tasks", &query_params).await
341    }
342
343    /// Get a specific task by ID
344    pub async fn get_task(&self, task_id: &str) -> TodoistResult<Task> {
345        self.make_get_request(&format!("/tasks/{task_id}")).await
346    }
347
348    /// Get tasks by filter query (paginated)
349    pub async fn get_tasks_by_filter(&self, args: &TaskFilterArgs) -> TodoistResult<PaginatedResponse<Task>> {
350        let mut query_params = vec![("query", args.query.clone())];
351
352        if let Some(lang) = &args.lang {
353            query_params.push(("lang", lang.clone()));
354        }
355        if let Some(limit) = args.limit {
356            query_params.push(("limit", limit.to_string()));
357        }
358        if let Some(cursor) = &args.cursor {
359            query_params.push(("cursor", cursor.clone()));
360        }
361
362        self.make_get_request_paginated("/tasks", &query_params).await
363    }
364
365    /// Create a new task
366    pub async fn create_task(&self, args: &CreateTaskArgs) -> TodoistResult<Task> {
367        let body_value = serde_json::to_value(args)?;
368        self.make_post_request("/tasks", Some(&body_value)).await
369    }
370
371    /// Update an existing task
372    pub async fn update_task(&self, task_id: &str, args: &UpdateTaskArgs) -> TodoistResult<Task> {
373        if !args.has_updates() {
374            return Err(TodoistError::ValidationError {
375                field: None,
376                message: "No fields specified for update".to_string(),
377            });
378        }
379        let body_value = serde_json::to_value(args)?;
380        self.make_post_request(&format!("/tasks/{task_id}"), Some(&body_value))
381            .await
382    }
383
384    /// Complete a task
385    pub async fn complete_task(&self, task_id: &str) -> TodoistResult<()> {
386        self.make_post_request(&format!("/tasks/{task_id}/close"), None).await
387    }
388
389    /// Reopen a completed task
390    pub async fn reopen_task(&self, task_id: &str) -> TodoistResult<()> {
391        self.make_post_request(&format!("/tasks/{task_id}/reopen"), None).await
392    }
393
394    /// Delete a task
395    pub async fn delete_task(&self, task_id: &str) -> TodoistResult<()> {
396        self.make_delete_request(&format!("/tasks/{task_id}")).await
397    }
398
399    /// Get completed tasks by completion date (up to 3 months range)
400    /// Retrieves tasks completed within the specified date range
401    pub async fn get_completed_tasks_by_completion_date(
402        &self,
403        args: &CompletedTasksFilterArgs,
404    ) -> TodoistResult<PaginatedResponse<Task>> {
405        let mut query_params = Vec::new();
406
407        if let Some(since) = &args.since {
408            query_params.push(("since", since.clone()));
409        }
410        if let Some(until) = &args.until {
411            query_params.push(("until", until.clone()));
412        }
413        if let Some(project_id) = &args.project_id {
414            query_params.push(("project_id", project_id.clone()));
415        }
416        if let Some(section_id) = &args.section_id {
417            query_params.push(("section_id", section_id.clone()));
418        }
419        if let Some(limit) = args.limit {
420            query_params.push(("limit", limit.to_string()));
421        }
422        if let Some(cursor) = &args.cursor {
423            query_params.push(("cursor", cursor.clone()));
424        }
425
426        self.make_get_request_paginated("/tasks/completed/by_completion_date", &query_params)
427            .await
428    }
429
430    /// Get completed tasks by due date (up to 6 weeks range)
431    /// Retrieves tasks completed within the specified due date range
432    pub async fn get_completed_tasks_by_due_date(
433        &self,
434        args: &CompletedTasksFilterArgs,
435    ) -> TodoistResult<PaginatedResponse<Task>> {
436        let mut query_params = Vec::new();
437
438        if let Some(since) = &args.since {
439            query_params.push(("since", since.clone()));
440        }
441        if let Some(until) = &args.until {
442            query_params.push(("until", until.clone()));
443        }
444        if let Some(project_id) = &args.project_id {
445            query_params.push(("project_id", project_id.clone()));
446        }
447        if let Some(section_id) = &args.section_id {
448            query_params.push(("section_id", section_id.clone()));
449        }
450        if let Some(limit) = args.limit {
451            query_params.push(("limit", limit.to_string()));
452        }
453        if let Some(cursor) = &args.cursor {
454            query_params.push(("cursor", cursor.clone()));
455        }
456
457        self.make_get_request_paginated("/tasks/completed/by_due_date", &query_params)
458            .await
459    }
460
461    // ===== LABEL OPERATIONS =====
462
463    /// Get all labels (paginated)
464    pub async fn get_labels(
465        &self,
466        limit: Option<i32>,
467        cursor: Option<String>,
468    ) -> TodoistResult<PaginatedResponse<Label>> {
469        let mut query_params = Vec::new();
470        if let Some(l) = limit {
471            query_params.push(("limit", l.to_string()));
472        }
473        if let Some(c) = cursor {
474            query_params.push(("cursor", c));
475        }
476        self.make_get_request_paginated("/labels", &query_params).await
477    }
478
479    /// Get labels with filtering and pagination
480    pub async fn get_labels_filtered(&self, args: &LabelFilterArgs) -> TodoistResult<Vec<Label>> {
481        let mut query_params = Vec::new();
482
483        if let Some(limit) = args.limit {
484            query_params.push(("limit", limit.to_string()));
485        }
486        if let Some(cursor) = &args.cursor {
487            query_params.push(("cursor", cursor.clone()));
488        }
489
490        self.make_get_request_with_params("/labels", &query_params).await
491    }
492
493    /// Get a specific label by ID
494    pub async fn get_label(&self, label_id: &str) -> TodoistResult<Label> {
495        self.make_get_request(&format!("/labels/{label_id}")).await
496    }
497
498    /// Create a new label
499    pub async fn create_label(&self, args: &CreateLabelArgs) -> TodoistResult<Label> {
500        let body_value = serde_json::to_value(args)?;
501        self.make_post_request("/labels", Some(&body_value)).await
502    }
503
504    /// Update an existing label
505    pub async fn update_label(&self, label_id: &str, args: &UpdateLabelArgs) -> TodoistResult<Label> {
506        if !args.has_updates() {
507            return Err(TodoistError::ValidationError {
508                field: None,
509                message: "No fields specified for update".to_string(),
510            });
511        }
512        let body_value = serde_json::to_value(args)?;
513        self.make_post_request(&format!("/labels/{label_id}"), Some(&body_value))
514            .await
515    }
516
517    /// Delete a label
518    pub async fn delete_label(&self, label_id: &str) -> TodoistResult<()> {
519        self.make_delete_request(&format!("/labels/{label_id}")).await
520    }
521
522    // ===== SECTION OPERATIONS =====
523
524    /// Get all sections (paginated)
525    pub async fn get_sections(
526        &self,
527        limit: Option<i32>,
528        cursor: Option<String>,
529    ) -> TodoistResult<PaginatedResponse<Section>> {
530        let mut query_params = Vec::new();
531        if let Some(l) = limit {
532            query_params.push(("limit", l.to_string()));
533        }
534        if let Some(c) = cursor {
535            query_params.push(("cursor", c));
536        }
537        self.make_get_request_paginated("/sections", &query_params).await
538    }
539
540    /// Get sections with filtering and pagination
541    pub async fn get_sections_filtered(&self, args: &SectionFilterArgs) -> TodoistResult<PaginatedResponse<Section>> {
542        let mut query_params = Vec::new();
543
544        if let Some(project_id) = &args.project_id {
545            query_params.push(("project_id", project_id.clone()));
546        }
547        if let Some(limit) = args.limit {
548            query_params.push(("limit", limit.to_string()));
549        }
550        if let Some(cursor) = &args.cursor {
551            query_params.push(("cursor", cursor.clone()));
552        }
553
554        self.make_get_request_paginated("/sections", &query_params).await
555    }
556
557    /// Get a specific section by ID
558    pub async fn get_section(&self, section_id: &str) -> TodoistResult<Section> {
559        self.make_get_request(&format!("/sections/{section_id}")).await
560    }
561
562    /// Create a new section
563    pub async fn create_section(&self, args: &CreateSectionArgs) -> TodoistResult<Section> {
564        let body_value = serde_json::to_value(args)?;
565        self.make_post_request("/sections", Some(&body_value)).await
566    }
567
568    /// Update an existing section
569    pub async fn update_section(&self, section_id: &str, args: &UpdateSectionArgs) -> TodoistResult<Section> {
570        let body_value = serde_json::to_value(args)?;
571        self.make_post_request(&format!("/sections/{section_id}"), Some(&body_value))
572            .await
573    }
574
575    /// Delete a section
576    pub async fn delete_section(&self, section_id: &str) -> TodoistResult<()> {
577        self.make_delete_request(&format!("/sections/{section_id}")).await
578    }
579
580    // ===== COMMENT OPERATIONS =====
581
582    /// Get all comments
583    pub async fn get_comments(&self) -> TodoistResult<Vec<Comment>> {
584        self.make_get_request("/comments").await
585    }
586
587    /// Get comments with filtering and pagination
588    pub async fn get_comments_filtered(&self, args: &CommentFilterArgs) -> TodoistResult<Vec<Comment>> {
589        let mut query_params = Vec::new();
590
591        if let Some(task_id) = &args.task_id {
592            query_params.push(("task_id", task_id.clone()));
593        }
594        if let Some(project_id) = &args.project_id {
595            query_params.push(("project_id", project_id.clone()));
596        }
597        if let Some(limit) = args.limit {
598            query_params.push(("limit", limit.to_string()));
599        }
600        if let Some(cursor) = &args.cursor {
601            query_params.push(("cursor", cursor.clone()));
602        }
603
604        self.make_get_request_with_params("/comments", &query_params).await
605    }
606
607    /// Get a specific comment by ID
608    pub async fn get_comment(&self, comment_id: &str) -> TodoistResult<Comment> {
609        self.make_get_request(&format!("/comments/{comment_id}")).await
610    }
611
612    /// Create a new comment
613    pub async fn create_comment(&self, args: &CreateCommentArgs) -> TodoistResult<Comment> {
614        let body_value = serde_json::to_value(args)?;
615        self.make_post_request("/comments", Some(&body_value)).await
616    }
617
618    /// Update an existing comment
619    pub async fn update_comment(&self, comment_id: &str, args: &UpdateCommentArgs) -> TodoistResult<Comment> {
620        if !args.has_updates() {
621            return Err(TodoistError::ValidationError {
622                field: None,
623                message: "No fields specified for update".to_string(),
624            });
625        }
626        let body_value = serde_json::to_value(args)?;
627        self.make_post_request(&format!("/comments/{comment_id}"), Some(&body_value))
628            .await
629    }
630
631    /// Delete a comment
632    pub async fn delete_comment(&self, comment_id: &str) -> TodoistResult<()> {
633        self.make_delete_request(&format!("/comments/{comment_id}")).await
634    }
635}