291 lines
8.8 KiB
Rust
291 lines
8.8 KiB
Rust
use base64::engine::general_purpose::STANDARD;
|
|
use base64::Engine;
|
|
use chrono::{DateTime, SecondsFormat, Utc};
|
|
use reqwest::header::{HeaderMap, HeaderValue};
|
|
use reqwest::Response;
|
|
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
|
|
use reqwest_retry::policies::ExponentialBackoff;
|
|
use reqwest_retry::{Jitter, RetryTransientMiddleware};
|
|
use serde::de::DeserializeOwned;
|
|
use std::time::Duration;
|
|
use support::ReqwestRateLimiter;
|
|
|
|
mod support;
|
|
pub mod types;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TogglApi {
|
|
client: ClientWithMiddleware,
|
|
pub workspace_id: u64,
|
|
headers: HeaderMap,
|
|
}
|
|
|
|
const BASE_URL: &str = "https://api.track.toggl.com/api/v9";
|
|
const REPORTS_BASE_URL: &str = "https://api.track.toggl.com/reports/api/v3";
|
|
|
|
impl TogglApi {
|
|
pub fn new(api_key: &str, workspace_id: u64) -> Self {
|
|
let rate_limiter = ReqwestRateLimiter::new();
|
|
let backoff = ExponentialBackoff::builder()
|
|
.retry_bounds(Duration::from_secs(1), Duration::from_secs(60))
|
|
.jitter(Jitter::Bounded)
|
|
.base(2)
|
|
.build_with_total_retry_duration(Duration::from_secs(24 * 60 * 60));
|
|
|
|
let client = ClientBuilder::new(reqwest::Client::new())
|
|
.with(reqwest_ratelimit::all(rate_limiter))
|
|
.with(RetryTransientMiddleware::new_with_policy(backoff))
|
|
.build();
|
|
|
|
let toggl_auth = &STANDARD.encode(format!("{}:api_token", api_key));
|
|
let headers = Self::authorisation_headers(toggl_auth);
|
|
|
|
Self {
|
|
client,
|
|
workspace_id,
|
|
headers,
|
|
}
|
|
}
|
|
|
|
fn authorisation_headers(toggl_auth: &str) -> HeaderMap {
|
|
let mut headers = HeaderMap::new();
|
|
let mut value = HeaderValue::from_str(&format!("Basic {}", toggl_auth)).unwrap();
|
|
value.set_sensitive(true);
|
|
headers.insert("Authorization", value);
|
|
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
|
|
headers
|
|
}
|
|
|
|
/// Get the workspaces that a user is a part of
|
|
#[tracing::instrument(skip(self))]
|
|
async fn get_users_workspaces(&self) -> Result<Vec<types::Workspace>, TogglError> {
|
|
let url = format!("{base_url}/me/workspaces", base_url = BASE_URL);
|
|
|
|
let response = self
|
|
.client
|
|
.get(&url)
|
|
.headers(self.headers.clone())
|
|
.send()
|
|
.await?;
|
|
|
|
let data = response.text().await?;
|
|
let workspaces: Vec<types::Workspace> = serde_json::from_str(&data)?;
|
|
|
|
Ok(workspaces)
|
|
}
|
|
|
|
/// Get a specific workspace by its ID
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn get_workspace(&self, id: u64) -> Result<types::Workspace, TogglError> {
|
|
let url = format!("{base_url}/workspaces/{id}", base_url = BASE_URL, id = id);
|
|
|
|
Self::parse(
|
|
self.client
|
|
.get(&url)
|
|
.headers(self.headers.clone())
|
|
.send()
|
|
.await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Fetches all time entries for this user from Toggl that have been modified since the given
|
|
/// date.
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn get_time_entries_for_user_modified_since(
|
|
&self,
|
|
since: DateTime<Utc>,
|
|
) -> Result<Vec<types::TimeEntry>, TogglError> {
|
|
let url = format!(
|
|
"{base_url}/me/time_entries?since={since}",
|
|
base_url = BASE_URL,
|
|
since = since.timestamp()
|
|
);
|
|
|
|
Self::parse(
|
|
self.client
|
|
.get(&url)
|
|
.headers(self.headers.clone())
|
|
.send()
|
|
.await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Fetches all time entries for this user from Toggl that have a start time between the given
|
|
/// start and end times.
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn get_time_user_entries_between(
|
|
&self,
|
|
start: DateTime<Utc>,
|
|
until: DateTime<Utc>,
|
|
) -> Result<Vec<types::TimeEntry>, TogglError> {
|
|
let url = format!("{base_url}/me/time_entries", base_url = BASE_URL,);
|
|
|
|
Self::parse(
|
|
self.client
|
|
.get(url)
|
|
.headers(self.headers.clone())
|
|
.query(&[
|
|
(
|
|
"start_date",
|
|
start.to_rfc3339_opts(SecondsFormat::Secs, true),
|
|
),
|
|
("end_date", until.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
|
])
|
|
.send()
|
|
.await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn get_current_time_entry(&self) -> Result<Option<types::TimeEntry>, TogglError> {
|
|
let url = format!("{base_url}/me/time_entries/current", base_url = BASE_URL);
|
|
|
|
Ok(self
|
|
.client
|
|
.get(&url)
|
|
.headers(self.headers.clone())
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?)
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn get_projects(&self) -> Result<Vec<types::Project>, TogglError> {
|
|
let url = format!(
|
|
"{base_url}/workspaces/{workspace_id}/projects",
|
|
base_url = BASE_URL,
|
|
workspace_id = self.workspace_id
|
|
);
|
|
|
|
Self::parse(
|
|
self.client
|
|
.get(&url)
|
|
.headers(self.headers.clone())
|
|
.send()
|
|
.await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn get_clients(&self) -> Result<Vec<types::TrackingClient>, TogglError> {
|
|
let url = format!(
|
|
"{base_url}/workspaces/{workspace_id}/clients",
|
|
base_url = BASE_URL,
|
|
workspace_id = self.workspace_id
|
|
);
|
|
|
|
Self::parse(
|
|
self.client
|
|
.get(&url)
|
|
.headers(self.headers.clone())
|
|
.send()
|
|
.await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn parse<T: DeserializeOwned>(response: Response) -> Result<T, TogglError> {
|
|
let data = response.text().await?;
|
|
let result = serde_json_path_to_error::from_str(&data);
|
|
|
|
let result = result.map_err(|error| TogglError::JsonWithDataError { data, error })?;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn get_tags(&self) -> Result<Vec<types::Tag>, TogglError> {
|
|
let url = format!(
|
|
"{base_url}/workspaces/{workspace_id}/tags",
|
|
base_url = BASE_URL,
|
|
workspace_id = self.workspace_id
|
|
);
|
|
|
|
Self::parse(
|
|
self.client
|
|
.get(&url)
|
|
.headers(self.headers.clone())
|
|
.send()
|
|
.await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn paginate_filters(
|
|
original_filters: &types::TogglReportFilters,
|
|
last_row_id: u64,
|
|
) -> types::TogglReportFilters {
|
|
let mut filters = original_filters.clone();
|
|
filters.first_row_number = Some(last_row_id + 1);
|
|
filters
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn search(
|
|
&self,
|
|
workspace_id: u64,
|
|
filters: types::TogglReportFilters,
|
|
) -> Result<Vec<types::ReportEntry>, TogglError> {
|
|
let url = format!(
|
|
"{base_url}/workspace/{workspace_id}/search/time_entries",
|
|
base_url = &REPORTS_BASE_URL,
|
|
workspace_id = workspace_id,
|
|
);
|
|
|
|
let mut last_row_number = Some(0);
|
|
let mut results = vec![];
|
|
|
|
while let Some(last_row_number_n) = last_row_number {
|
|
// If we are not on the first page, wait a bit to avoid rate limiting
|
|
if last_row_number_n != 0 {
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
|
|
let data: Vec<types::ReportEntry> = Self::parse(
|
|
self.client
|
|
.post(&url)
|
|
.headers(self.headers.clone())
|
|
.json(&Self::paginate_filters(&filters, last_row_number_n))
|
|
.send()
|
|
.await?,
|
|
)
|
|
.await?;
|
|
|
|
last_row_number = data.last().map(|e| e.row_number as u64);
|
|
|
|
data.into_iter().for_each(|e| results.push(e));
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum TogglError {
|
|
#[error("Toggl returned error: {0}")]
|
|
TogglError(serde_json::Value),
|
|
|
|
#[error("Reqwest error: {0}")]
|
|
ReqwestError(#[from] reqwest_middleware::Error),
|
|
|
|
#[error("Json error: {0}")]
|
|
JsonError(#[from] serde_json::Error),
|
|
|
|
#[error("Failed to parse JSON data: {data}, error: {error}")]
|
|
JsonWithDataError {
|
|
data: String,
|
|
error: serde_json_path_to_error::Error,
|
|
},
|
|
}
|
|
|
|
impl From<reqwest::Error> for TogglError {
|
|
fn from(value: reqwest::Error) -> Self {
|
|
TogglError::ReqwestError(reqwest_middleware::Error::Reqwest(value))
|
|
}
|
|
}
|