use std::time::Duration;
use chrono::Utc;
use color_eyre::{
Result,
eyre::{WrapErr, eyre},
};
use lazy_static::lazy_static;
use reqwest::{Client, StatusCode};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder};
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use uuid::Uuid;
#[derive(Deserialize)]
struct PaginatedResponse<T> {
results: Vec<T>,
next_cursor: Option<String>,
}
use super::{
Comment, CreateComment, CreateLabel, CreateProject, CreateSection, CreateTask, Label, LabelID,
Project, ProjectID, Section, SectionID, Task, TaskDue, TaskID, UpdateTask,
};
pub struct Gateway {
client: ClientWithMiddleware,
token: String,
url: url::Url,
}
lazy_static! {
pub static ref TODOIST_API_URL: url::Url = {
url::Url::parse("https://api.todoist.com/").unwrap()
};
}
impl Gateway {
pub fn new(token: &str, url: &url::Url) -> Gateway {
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(Client::new())
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
Gateway {
client,
token: token.to_string(),
url: url.clone(),
}
}
pub async fn task(&self, id: &TaskID) -> Result<Task> {
self.get::<(), _>(&format!("api/v1/tasks/{id}"), None)
.await
.wrap_err("unable to get task")
}
pub async fn tasks(&self, filter: Option<&str>) -> Result<Vec<Task>> {
if let Some(filter) = filter {
self.get_list(
"api/v1/tasks/filter",
vec![("query".to_string(), filter.to_string())],
)
.await
.wrap_err("unable to get tasks")
} else {
self.get_list("api/v1/tasks", vec![])
.await
.wrap_err("unable to get tasks")
}
}
pub async fn close(&self, id: &TaskID) -> Result<()> {
self.post_empty(&format!("api/v1/tasks/{id}/close"), &serde_json::Map::new())
.await
.wrap_err("unable to close task")?;
Ok(())
}
pub async fn complete(&self, id: &TaskID) -> Result<()> {
self.update(
id,
&UpdateTask {
due: Some(TaskDue::DateTime(Utc::now())),
..Default::default()
},
)
.await
.wrap_err("unable to complete task")?;
self.close(id).await.wrap_err("unable to complete task")?;
Ok(())
}
pub async fn create(&self, task: &CreateTask) -> Result<Task> {
self.post("api/v1/tasks", task)
.await
.wrap_err("unable to create task")?
.ok_or_else(|| eyre!("unable to create task"))
}
pub async fn update(&self, id: &TaskID, task: &UpdateTask) -> Result<()> {
self.post_empty(&format!("api/v1/tasks/{id}"), &task)
.await
.wrap_err("unable to update task")?;
Ok(())
}
pub async fn projects(&self) -> Result<Vec<Project>> {
self.get_list("api/v1/projects", vec![])
.await
.wrap_err("unable to get projects")
}
pub async fn sections(&self) -> Result<Vec<Section>> {
self.get_list("api/v1/sections", vec![])
.await
.wrap_err("unable to get sections")
}
pub async fn labels(&self) -> Result<Vec<Label>> {
self.get_list("api/v1/labels", vec![])
.await
.wrap_err("unable to get labels")
}
pub async fn project_comments(&self, id: &ProjectID) -> Result<Vec<Comment>> {
self.get_list(
"api/v1/comments",
vec![("project_id".to_string(), id.to_string())],
)
.await
.wrap_err("unable to get comments")
}
pub async fn task_comments(&self, id: &TaskID) -> Result<Vec<Comment>> {
self.get_list(
"api/v1/comments",
vec![("task_id".to_string(), id.to_string())],
)
.await
.wrap_err("unable to get comments")
}
pub async fn create_comment(&self, comment: &CreateComment) -> Result<Comment> {
self.post("api/v1/comments", comment)
.await
.wrap_err("unable to create comment")?
.ok_or_else(|| eyre!("unable to create comment"))
}
pub async fn project(&self, id: &ProjectID) -> Result<Project> {
self.get::<(), _>(&format!("api/v1/projects/{id}"), None)
.await
.wrap_err("unable to get project")
}
pub async fn create_project(&self, project: &CreateProject) -> Result<Project> {
self.post("api/v1/projects", project)
.await
.wrap_err("unable to create project")?
.ok_or_else(|| eyre!("unable to create project"))
}
pub async fn delete_project(&self, project: &ProjectID) -> Result<()> {
self.delete(&format!("api/v1/projects/{project}"))
.await
.wrap_err("unable to delete project")
}
pub async fn section(&self, id: &SectionID) -> Result<Section> {
self.get::<(), _>(&format!("api/v1/sections/{id}"), None)
.await
.wrap_err("unable to get section")
}
pub async fn create_section(&self, section: &CreateSection) -> Result<Section> {
self.post("api/v1/sections", section)
.await
.wrap_err("unable to create section")?
.ok_or_else(|| eyre!("unable to create section"))
}
pub async fn delete_section(&self, section: &SectionID) -> Result<()> {
self.delete(&format!("api/v1/sections/{section}"))
.await
.wrap_err("unable to delete section")
}
pub async fn label(&self, id: &LabelID) -> Result<Label> {
self.get::<(), _>(&format!("api/v1/labels/{id}"), None)
.await
.wrap_err("unable to get label")
}
pub async fn create_label(&self, label: &CreateLabel) -> Result<Label> {
self.post("api/v1/labels", label)
.await
.wrap_err("unable to create label")?
.ok_or_else(|| eyre!("unable to create label"))
}
pub async fn delete_label(&self, label: &LabelID) -> Result<()> {
self.delete(&format!("api/v1/labels/{label}"))
.await
.wrap_err("unable to delete label")
}
async fn get<'a, T: 'a + Serialize, R: DeserializeOwned>(
&self,
path: &str,
query: Option<T>,
) -> Result<R> {
let req = self
.client
.get(self.url.join(path)?)
.bearer_auth(&self.token);
let req = if let Some(q) = query {
req.query(&q)
} else {
req
};
handle_req(req)
.await?
.ok_or_else(|| eyre!("Invalid response from API"))
}
async fn get_list<R: DeserializeOwned>(
&self,
path: &str,
mut params: Vec<(String, String)>,
) -> Result<Vec<R>> {
let mut all_results = Vec::new();
loop {
let req = self
.client
.get(self.url.join(path)?)
.bearer_auth(&self.token)
.query(¶ms);
let page = handle_req::<PaginatedResponse<R>>(req)
.await?
.ok_or_else(|| eyre!("Invalid response from API"))?;
all_results.extend(page.results);
match page.next_cursor {
Some(cursor) => {
params.retain(|(k, _)| k != "cursor");
params.push(("cursor".to_string(), cursor));
}
None => break,
}
}
Ok(all_results)
}
async fn post<T: Serialize, R: DeserializeOwned>(
&self,
path: &str,
content: &T,
) -> Result<Option<R>> {
let uuid = Uuid::new_v4();
handle_req(
self.client
.post(self.url.join(path)?)
.bearer_auth(&self.token)
.body(serde_json::to_string(&content)?)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header("X-Request-Id", uuid.to_string()),
)
.await
}
async fn delete(&self, path: &str) -> Result<()> {
handle_req::<()>(
self.client
.delete(self.url.join(path)?)
.bearer_auth(&self.token),
)
.await?;
Ok(())
}
async fn post_empty<T: Serialize>(&self, path: &str, content: &T) -> Result<()> {
self.post::<_, Task>(path, content).await?;
Ok(())
}
}
async fn handle_req<R: DeserializeOwned>(req: RequestBuilder) -> Result<Option<R>> {
let resp = req
.timeout(Duration::from_secs(30))
.send()
.await
.wrap_err("unable to send request")?;
let status = resp.status();
if status == StatusCode::NO_CONTENT {
return Ok(None);
}
let text = resp.text().await.wrap_err("unable to read response")?;
if !status.is_success() {
return Err(eyre!("Bad response from API: {} - {}", status, text));
}
let result = serde_json::from_str(&text).wrap_err("unable to parse API response")?;
Ok(Some(result))
}
#[cfg(test)]
mod test {
use serde::Serialize;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{bearer_token, method, path, query_param},
};
use super::*;
use crate::api::rest::{Task, ThreadID};
use color_eyre::Result;
#[derive(Serialize)]
struct PagedList<T: Serialize> {
results: Vec<T>,
}
fn paged<T: Serialize>(items: Vec<T>) -> PagedList<T> {
PagedList { results: items }
}
#[tokio::test]
async fn has_authentication() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(bearer_token("hellothere"))
.and(path("/api/v1/tasks/123"))
.respond_with(
ResponseTemplate::new(200).set_body_json(create_task("123", "456", "hello")),
)
.mount(&mock_server)
.await;
let gw = gateway("hellothere", &mock_server);
let task = gw.task(&"123".to_string()).await;
assert!(task.is_ok());
}
#[tokio::test]
async fn task() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/tasks/123"))
.respond_with(
ResponseTemplate::new(200).set_body_json(create_task("123", "456", "hello")),
)
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let task = gw.task(&"123".to_string()).await.unwrap();
mock_server.verify().await;
assert_eq!(task.id, "123");
assert!(gw.task(&"1234".to_string()).await.is_err());
}
#[tokio::test]
async fn tasks() -> Result<()> {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/tasks"))
.respond_with(ResponseTemplate::new(200).set_body_json(paged(vec![
create_task("123", "456", "hello there"),
create_task("234", "567", "general kenobi"),
])))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let tasks = gw.tasks(None).await.unwrap();
mock_server.verify().await;
assert_eq!(tasks.len(), 2);
Ok(())
}
#[tokio::test]
async fn tasks_with_filter() -> Result<()> {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/tasks/filter"))
.and(query_param("query", "(today | overdue)"))
.respond_with(ResponseTemplate::new(200).set_body_json(paged(vec![
create_task("123", "456", "hello there"),
create_task("234", "567", "general kenobi"),
])))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let tasks = gw.tasks(Some("(today | overdue)")).await.unwrap();
mock_server.verify().await;
assert_eq!(tasks.len(), 2);
Ok(())
}
#[tokio::test]
async fn close_task() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/tasks/123/close"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let closed = gw.close(&"123".to_string()).await;
assert!(closed.is_ok());
}
#[tokio::test]
async fn complete_task() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/tasks/123"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/tasks/123/close"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let completed = gw.complete(&"123".to_string()).await;
mock_server.verify().await;
assert!(completed.is_ok());
}
#[tokio::test]
async fn update_task() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/tasks/123"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let completed = gw
.update(
&"123".to_string(),
&UpdateTask {
content: Some("hello".to_string()),
..Default::default()
},
)
.await;
mock_server.verify().await;
assert!(completed.is_ok());
}
#[tokio::test]
async fn creates_task() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/tasks"))
.respond_with(
ResponseTemplate::new(200).set_body_json(create_task("123", "456", "hello")),
)
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let task = gw
.create(&CreateTask {
content: "hello".to_string(),
project_id: Some("456".to_string()),
..Default::default()
})
.await
.unwrap();
mock_server.verify().await;
assert_eq!(task.id, "123");
}
#[tokio::test]
async fn lists_projects() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/projects"))
.respond_with(ResponseTemplate::new(200).set_body_json(paged(vec![
Project::new("123", "one"),
Project::new("456", "two"),
])))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let projects = gw.projects().await.unwrap();
mock_server.verify().await;
assert_eq!(projects.len(), 2);
}
#[tokio::test]
async fn show_project() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/projects/123"))
.respond_with(ResponseTemplate::new(200).set_body_json(Project::new("123", "one")))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let project = gw.project(&"123".to_string()).await.unwrap();
mock_server.verify().await;
assert_eq!(project.id, "123");
assert_eq!(project.name, "one");
}
#[tokio::test]
async fn lists_labels() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(paged(vec![
Label::new("123", "one"),
Label::new("456", "two"),
])))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let labels = gw.labels().await.unwrap();
mock_server.verify().await;
assert_eq!(labels.len(), 2);
}
#[tokio::test]
async fn show_label() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/labels/123"))
.respond_with(ResponseTemplate::new(200).set_body_json(Label::new("123", "one")))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let label = gw.label(&"123".to_string()).await.unwrap();
mock_server.verify().await;
assert_eq!(label.id, "123");
assert_eq!(label.name, "one");
}
#[tokio::test]
async fn lists_sections() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/sections"))
.respond_with(ResponseTemplate::new(200).set_body_json(paged(vec![
Section::new("123", "1", "one"),
Section::new("456", "2", "two"),
])))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let sections = gw.sections().await.unwrap();
mock_server.verify().await;
assert_eq!(sections.len(), 2);
}
#[tokio::test]
async fn show_section() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/sections/123"))
.respond_with(ResponseTemplate::new(200).set_body_json(Section::new("123", "1", "one")))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let section = gw.section(&"123".to_string()).await.unwrap();
mock_server.verify().await;
assert_eq!(section.id, "123");
assert_eq!(section.name, "one");
}
#[tokio::test]
async fn create_project_comment() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(create_comment(
"1",
ThreadID::Project {
project_id: "123".to_string(),
},
"hello",
)))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let comment = gw
.create_comment(&CreateComment {
thread: ThreadID::Project {
project_id: "123".to_string(),
},
content: "hello".to_string(),
})
.await
.unwrap();
mock_server.verify().await;
assert_eq!(comment.id, "1");
assert_eq!(comment.content, "hello");
}
#[tokio::test]
async fn create_task_comment() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(create_comment(
"1",
ThreadID::Task {
task_id: "123".to_string(),
},
"hello",
)))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let comment = gw
.create_comment(&CreateComment {
thread: ThreadID::Task {
task_id: "123".to_string(),
},
content: "hello".to_string(),
})
.await
.unwrap();
mock_server.verify().await;
assert_eq!(comment.id, "1");
assert_eq!(comment.content, "hello");
}
#[tokio::test]
async fn show_comments() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/comments"))
.and(query_param("project_id", "123"))
.respond_with(ResponseTemplate::new(200).set_body_json(paged(vec![
create_comment(
"1",
ThreadID::Project {
project_id: "123".to_string(),
},
"hello",
),
create_comment(
"1",
ThreadID::Project {
project_id: "123".to_string(),
},
"there",
),
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/comments"))
.and(query_param("task_id", "456"))
.respond_with(ResponseTemplate::new(200).set_body_json(paged(vec![
create_comment(
"1",
ThreadID::Task {
task_id: "456".to_string(),
},
"no",
),
create_comment(
"1",
ThreadID::Task {
task_id: "456".to_string(),
},
"way",
),
])))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let project_comments = gw.project_comments(&"123".to_string()).await.unwrap();
let task_comments = gw.task_comments(&"456".to_string()).await.unwrap();
mock_server.verify().await;
assert_eq!(project_comments.len(), 2);
assert_eq!(project_comments[0].content, "hello");
assert_eq!(task_comments.len(), 2);
assert_eq!(task_comments[0].content, "no");
}
#[tokio::test]
async fn creates_label() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(Label::new("123", "hello")))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let label = gw
.create_label(&CreateLabel {
name: "hello".to_string(),
..Default::default()
})
.await
.unwrap();
mock_server.verify().await;
assert_eq!(label.id, "123");
}
#[tokio::test]
async fn delete_label() {
let mock_server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/labels/123"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let closed = gw.delete_label(&"123".to_string()).await;
assert!(closed.is_ok());
}
#[tokio::test]
async fn creates_project() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/projects"))
.respond_with(ResponseTemplate::new(200).set_body_json(Project::new("123", "hello")))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let project = gw
.create_project(&CreateProject {
name: "hello".to_string(),
..Default::default()
})
.await
.unwrap();
mock_server.verify().await;
assert_eq!(project.id, "123");
}
#[tokio::test]
async fn delete_project() {
let mock_server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/projects/123"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let closed = gw.delete_project(&"123".to_string()).await;
assert!(closed.is_ok());
}
fn gateway(token: &str, ms: &MockServer) -> Gateway {
Gateway::new(token, &ms.uri().parse().unwrap())
}
#[tokio::test]
async fn creates_section() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/sections"))
.respond_with(
ResponseTemplate::new(200).set_body_json(Section::new("123", "456", "heya")),
)
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let section = gw
.create_section(&CreateSection {
name: "hello".to_string(),
project_id: "456".to_string(),
..Default::default()
})
.await
.unwrap();
mock_server.verify().await;
assert_eq!(section.id, "123");
assert_eq!(section.project_id, "456");
}
#[tokio::test]
async fn delete_section() {
let mock_server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/sections/123"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let gw = gateway("", &mock_server);
let closed = gw.delete_section(&"123".to_string()).await;
assert!(closed.is_ok());
}
fn create_task(id: &str, project_id: &str, content: &str) -> Task {
crate::api::rest::Task {
project_id: project_id.to_string(),
..crate::api::rest::Task::new(id, content)
}
}
fn create_comment(id: &str, tid: ThreadID, content: &str) -> Comment {
let (item_id, project_id) = match tid {
ThreadID::Task { task_id } => (Some(task_id), None),
ThreadID::Project { project_id } => (None, Some(project_id)),
};
Comment {
id: id.to_string(),
item_id,
project_id,
posted_at: Utc::now(),
content: content.to_string(),
attachment: None,
}
}
}