use std::env;
use std::time::Duration;
use reqwest::Client;
use reqwest::Response;
use reqwest::header::AUTHORIZATION;
use reqwest::header::CONTENT_TYPE;
use serde_json::Value;
use serde_json::json;
use spinners::Spinner;
use spinners::Spinners;
use uuid::Uuid;
use crate::config::Args;
use crate::config::Config;
use crate::config::DEFAULT_TIMEOUT_SECONDS;
use crate::debug;
use crate::errors::Error;
use crate::format;
use crate::todoist::REMINDERS_URL;
const FAKE_UUID: &str = "42963283-2bab-4b1f-bad2-278ef2b6ba2c";
const TODOIST_URL: &str = "https://api.todoist.com";
const REMINDERS_PRO_PLAN_MESSAGE: &str = "Reminders are only available on Pro Todoist plans. Upgrade to Todoist Pro to access reminder features.";
const SPINNER: Spinners = Spinners::Dots4;
const MESSAGE: &str = "Querying API";
const HTTP_UNAUTHORIZED: u16 = 401;
const HTTP_FORBIDDEN: u16 = 403;
pub async fn post_todoist(
config: &Config,
url: &str,
body: serde_json::Value,
spinner: bool,
) -> Result<String, Error> {
let base_url = get_base_url(config);
let token = get_token(config)?;
let request_url = format!("{base_url}{url}");
let authorization = format!("Bearer {token}");
let spinner = maybe_start_spinner(config, spinner);
debug::maybe_print(config, &format!("POST {request_url}\nbody: {body}"));
let client = Client::new()
.post(request_url.clone())
.header(CONTENT_TYPE, "application/json")
.header(AUTHORIZATION, authorization)
.header("X-Request-Id", new_uuid())
.timeout(get_timeout(config));
let response = match &body {
Value::Null => client.send().await?,
body => client.json(&body).send().await?,
};
maybe_stop_spinner(spinner);
handle_response(config, response, "POST", url, body).await
}
pub async fn post_todoist_no_token(
config: &Config,
url: &str,
body: serde_json::Value,
spinner: bool,
) -> Result<String, Error> {
let base_url = get_base_url(config);
let request_url = format!("{base_url}{url}");
let spinner = maybe_start_spinner(config, spinner);
debug::maybe_print(config, &format!("POST {request_url}\nbody: {body}"));
let client = Client::new()
.post(request_url.clone())
.header(CONTENT_TYPE, "application/json")
.header("X-Request-Id", new_uuid())
.timeout(get_timeout(config));
let response = match &body {
Value::Null => client.send().await?,
body => client.json(&body).send().await?,
};
maybe_stop_spinner(spinner);
handle_response(config, response, "POST", url, body).await
}
fn get_token(config: &Config) -> Result<String, Error> {
config
.token
.clone()
.ok_or_else(|| Error::new("post_todoist", "No token, use auth login to set your token"))
}
pub async fn delete_todoist(
config: &Config,
url: &str,
body: serde_json::Value,
spinner: bool,
) -> Result<String, Error> {
let base_url = get_base_url(config);
let token = get_token(config)?;
let request_url = format!("{base_url}{url}");
let authorization = format!("Bearer {token}");
let spinner = maybe_start_spinner(config, spinner);
debug::maybe_print(config, &format!("DELETE {request_url}\nbody: {body}"));
let response = Client::new()
.delete(request_url.clone())
.header(CONTENT_TYPE, "application/json")
.header(AUTHORIZATION, authorization)
.header("X-Request-Id", new_uuid())
.json(&body)
.timeout(get_timeout(config))
.send()
.await?;
maybe_stop_spinner(spinner);
handle_response(config, response, "DELETE", url, body).await
}
pub async fn get_todoist(config: &Config, url: &str, spinner: bool) -> Result<String, Error> {
let base_url = get_base_url(config);
let token = get_token(config)?;
let request_url = format!("{base_url}{url}");
let authorization = format!("Bearer {token}");
let spinner = maybe_start_spinner(config, spinner);
if config.verbose.unwrap_or_default() {
println!("GET {request_url}");
}
debug::maybe_print(config, &format!("GET {request_url}"));
let response = Client::new()
.get(request_url.clone())
.header(CONTENT_TYPE, "application/json")
.header(AUTHORIZATION, authorization)
.timeout(get_timeout(config))
.send()
.await?;
maybe_stop_spinner(spinner);
handle_response(config, response, "GET", url, json!({})).await
}
const CODES_REQUIRING_LOGIN: [u16; 2] = [HTTP_FORBIDDEN, HTTP_UNAUTHORIZED];
const PRO_PLAN_URLS: [&str; 1] = [REMINDERS_URL];
fn requires_login(status_code: u16) -> bool {
CODES_REQUIRING_LOGIN.contains(&status_code)
}
fn is_pro_plan_url(url: &str) -> bool {
PRO_PLAN_URLS.iter().any(|pro_url| url.contains(pro_url))
}
async fn handle_response(
config: &Config,
response: Response,
method: &str,
url: &str,
body: serde_json::Value,
) -> Result<String, Error> {
let status = response.status();
let status_code = status.as_u16();
if status.is_success() {
let json_string = response.text().await?;
debug::maybe_print(config, &format!("{method} {url}\nresponse: {json_string}"));
Ok(json_string)
} else if requires_login(status_code) && !is_pro_plan_url(url) {
let command = format::blue_string("tod auth login");
Err(Error::new(
"reqwest",
&format!(
"Unauthorized or Forbidden response from Todoist\nRun {command} to reauthenticate"
),
))
} else if requires_login(status_code) && is_pro_plan_url(url) {
Err(Error::new("reqwest", REMINDERS_PRO_PLAN_MESSAGE))
} else {
let json_string = response.text().await?;
Err(Error::new(
"reqwest",
&format!(
"
method: {method}
url: {url}
body: {body}
response: {json_string}",
),
))
}
}
fn get_timeout(config: &Config) -> Duration {
match config {
Config {
timeout: None,
args: Args { timeout: None, .. },
..
} => Duration::from_secs(DEFAULT_TIMEOUT_SECONDS),
Config {
timeout: Some(timeout),
args: Args { timeout: None, .. },
..
}
| Config {
timeout: Some(_),
args: Args {
timeout: Some(timeout),
..
},
..
}
| Config {
timeout: None,
args: Args {
timeout: Some(timeout),
..
},
..
} => Duration::from_secs(timeout.to_owned()),
}
}
fn get_base_url(config: &Config) -> String {
if cfg!(test) {
config.mock_url.clone().expect("Mock URL not set")
} else {
TODOIST_URL.to_string()
}
}
fn maybe_start_spinner(config: &Config, spinner: bool) -> Option<Spinner> {
if cfg!(test) {
return None;
}
match (env::var("DISABLE_SPINNER"), config.spinners, spinner) {
(Ok(_), _, _) | (_, Some(false), _) | (_, _, false) => None,
_ => {
let sp = Spinner::new(SPINNER, MESSAGE.into());
Some(sp)
}
}
}
fn maybe_stop_spinner(spinner: Option<Spinner>) {
if let Some(mut sp) = spinner {
sp.stop();
print!("\x1b[2K\r");
}
}
pub fn new_uuid() -> String {
if cfg!(test) {
FAKE_UUID.into()
} else {
Uuid::new_v4().to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_requires_login() {
assert!(requires_login(HTTP_UNAUTHORIZED));
assert!(requires_login(HTTP_FORBIDDEN));
assert!(!requires_login(200));
assert!(!requires_login(500));
}
#[test]
fn test_is_pro_plan_url() {
assert!(is_pro_plan_url(REMINDERS_URL));
assert!(is_pro_plan_url("/api/v1/reminders?limit=200"));
assert!(!is_pro_plan_url("/api/v1/tasks/"));
}
}