use std::fmt;
use std::time::Duration;
use serde::{de::DeserializeOwned, Serialize};
use crate::error::Result;
use crate::quick_add::{QuickAddRequest, QuickAddResponse};
use crate::retry::{
execute_empty_with_retry, execute_with_retry, RetryConfig, DEFAULT_INITIAL_BACKOFF_SECS,
DEFAULT_MAX_BACKOFF_SECS, DEFAULT_MAX_RETRIES,
};
use crate::sync::{SyncRequest, SyncResponse};
const BASE_URL: &str = "https://api.todoist.com/api/v1";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Clone, Debug)]
pub struct TodoistClientBuilder {
token: String,
base_url: String,
max_retries: u32,
initial_backoff: Duration,
max_backoff: Duration,
request_timeout: Duration,
}
impl TodoistClientBuilder {
pub fn new(token: impl Into<String>) -> Self {
Self {
token: token.into(),
base_url: BASE_URL.to_string(),
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: Duration::from_secs(DEFAULT_INITIAL_BACKOFF_SECS),
max_backoff: Duration::from_secs(DEFAULT_MAX_BACKOFF_SECS),
request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
}
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
pub fn initial_backoff(mut self, initial_backoff: Duration) -> Self {
self.initial_backoff = initial_backoff;
self
}
pub fn max_backoff(mut self, max_backoff: Duration) -> Self {
self.max_backoff = max_backoff;
self
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn build(self) -> Result<TodoistClient> {
let http_client = reqwest::Client::builder()
.timeout(self.request_timeout)
.build()
.map_err(crate::error::Error::Http)?;
Ok(TodoistClient {
token: self.token,
http_client,
base_url: self.base_url,
retry_config: RetryConfig {
max_retries: self.max_retries,
initial_backoff: self.initial_backoff,
max_backoff: self.max_backoff,
},
})
}
}
#[derive(Clone)]
pub struct TodoistClient {
token: String,
http_client: reqwest::Client,
base_url: String,
retry_config: RetryConfig,
}
impl TodoistClient {
pub fn new(token: impl Into<String>) -> Result<Self> {
TodoistClientBuilder::new(token).build()
}
pub fn with_base_url(token: impl Into<String>, base_url: impl Into<String>) -> Result<Self> {
TodoistClientBuilder::new(token).base_url(base_url).build()
}
pub fn builder(token: impl Into<String>) -> TodoistClientBuilder {
TodoistClientBuilder::new(token)
}
pub fn token(&self) -> &str {
&self.token
}
pub fn http_client(&self) -> &reqwest::Client {
&self.http_client
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn max_retries(&self) -> u32 {
self.retry_config.max_retries
}
pub fn initial_backoff(&self) -> Duration {
self.retry_config.initial_backoff
}
pub fn max_backoff(&self) -> Duration {
self.retry_config.max_backoff
}
#[cfg(test)]
fn calculate_backoff(&self, attempt: u32, retry_after: Option<u64>) -> Duration {
self.retry_config.calculate_backoff(attempt, retry_after)
}
pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
let url = format!("{}{}", self.base_url, endpoint);
let http_client = self.http_client.clone();
let token = self.token.clone();
execute_with_retry(&self.retry_config, || {
let url = url.clone();
let http_client = http_client.clone();
let token = token.clone();
async move {
http_client
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(crate::error::Error::Http)
}
})
.await
}
pub async fn post<T: DeserializeOwned, B: Serialize + Clone>(
&self,
endpoint: &str,
body: &B,
) -> Result<T> {
let url = format!("{}{}", self.base_url, endpoint);
let http_client = self.http_client.clone();
let token = self.token.clone();
let body = body.clone();
execute_with_retry(&self.retry_config, || {
let url = url.clone();
let http_client = http_client.clone();
let token = token.clone();
let body = body.clone();
async move {
http_client
.post(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await
.map_err(crate::error::Error::Http)
}
})
.await
}
pub async fn post_empty<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
let url = format!("{}{}", self.base_url, endpoint);
let http_client = self.http_client.clone();
let token = self.token.clone();
execute_with_retry(&self.retry_config, || {
let url = url.clone();
let http_client = http_client.clone();
let token = token.clone();
async move {
http_client
.post(&url)
.bearer_auth(&token)
.send()
.await
.map_err(crate::error::Error::Http)
}
})
.await
}
pub async fn delete(&self, endpoint: &str) -> Result<()> {
let url = format!("{}{}", self.base_url, endpoint);
let http_client = self.http_client.clone();
let token = self.token.clone();
execute_empty_with_retry(&self.retry_config, || {
let url = url.clone();
let http_client = http_client.clone();
let token = token.clone();
async move {
http_client
.delete(&url)
.bearer_auth(&token)
.send()
.await
.map_err(crate::error::Error::Http)
}
})
.await
}
pub async fn sync(&self, request: SyncRequest) -> Result<SyncResponse> {
let url = format!("{}/sync", self.base_url);
let http_client = self.http_client.clone();
let token = self.token.clone();
let form_body = request.to_form_body();
execute_with_retry(&self.retry_config, || {
let url = url.clone();
let http_client = http_client.clone();
let token = token.clone();
let form_body = form_body.clone();
async move {
http_client
.post(&url)
.bearer_auth(&token)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(form_body)
.send()
.await
.map_err(crate::error::Error::Http)
}
})
.await
}
pub async fn quick_add(&self, request: QuickAddRequest) -> Result<QuickAddResponse> {
let url = format!("{}/tasks/quick", self.base_url);
let http_client = self.http_client.clone();
let token = self.token.clone();
execute_with_retry(&self.retry_config, || {
let url = url.clone();
let http_client = http_client.clone();
let token = token.clone();
let request = request.clone();
async move {
http_client
.post(&url)
.bearer_auth(&token)
.json(&request)
.send()
.await
.map_err(crate::error::Error::Http)
}
})
.await
}
}
impl fmt::Debug for TodoistClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TodoistClient")
.field("token", &"[REDACTED]")
.field("http_client", &self.http_client)
.finish()
}
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;