commit 31b322d643834f0f4237470a70dbf5d756cf0f72 Author: Joshua Coles Date: Sun Oct 29 10:33:07 2023 +0000 Move toggl-portal to its own package diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e4d1d59 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "toggl-portal" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1.0", features = ["full"] } +axum = "0.6.20" +clap = { version = "4.4.3", features = ["derive", "env"] } +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.106" +tower-http = { version = "0.4.3", features = ["trace", "cors", "compression-gzip"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } +anyhow = { version = "^1.0", features = ["backtrace"] } +beachhead = { path = "../" } +hyper = "0.14.27" +reqwest = { version = "0.11.20", features = ["rustls", "json"] } +base64 = "0.21.4" +serde_with = "3.3.0" diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..281c689 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,155 @@ +use reqwest::Client; +use serde_json::Value; +use std::collections::HashMap; +use std::time::Duration; +use hyper::HeaderMap; +use tracing::instrument; +use tracing::log::debug; +use crate::types::{Current, Project, ReportEntry, TogglQuery}; + +#[derive(Debug, Clone)] +pub struct TogglClient { + client: Client, + workspace_id: String, + base_url: String, + reports_base_url: String, + + headers: HeaderMap, +} + +impl TogglClient { + pub async fn check_health(&self) -> bool { + true + } + + pub fn new(workspace_id: &str, toggl_auth: &str) -> Self { + let client = Client::builder() + .default_headers(Self::default_headers(toggl_auth)) + .build() + .expect("Failed to build reqwest client"); + + Self { + client, + workspace_id: workspace_id.to_string(), + base_url: "https://api.track.toggl.com/api/v9".to_string(), + reports_base_url: "https://api.track.toggl.com/reports/api/v3".to_string(), + headers: Self::default_headers(toggl_auth), + } + } + + fn default_headers(toggl_auth: &str) -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "Authorization", + reqwest::header::HeaderValue::from_str(&format!("Basic {}", toggl_auth)).unwrap(), + ); + headers + } + + pub async fn fetch_projects(&self) -> Result, reqwest::Error> { + let url = format!( + "{base_url}/workspaces/{}/projects", + self.workspace_id, + base_url = self.base_url, + ); + + let res = self + .client + .get(&url) + .headers(self.headers.clone()) + .send() + .await? + .json::>() + .await + .unwrap(); + + Ok(res) + } + + pub async fn get_current(&self) -> Result, reqwest::Error> { + let url = format!( + "{base_url}/me/time_entries/current", + base_url = self.base_url + ); + + let res = self + .client + .get(url) + .send() + .await? + .json::>() + .await + .unwrap(); + + Ok(res) + } + + fn create_filters(original_filters: &TogglQuery, last_row_id: u64) -> TogglQuery { + let mut filters: TogglQuery = original_filters.clone(); + filters.first_row_number = Some(last_row_id + 1); + filters + } + + #[instrument(skip(self, filters))] + pub async fn full_report( + &self, + filters: &TogglQuery, + ) -> anyhow::Result> { + let url = format!( + "{base_url}/workspace/{workspace_id}/search/time_entries", + base_url = self.reports_base_url, + workspace_id = self.workspace_id + ); + + let mut last_row_number = Some(0); + let mut results = vec![]; + + while let Some(last_row_number_n) = last_row_number { + debug!("Fetching page starting with {}", last_row_number_n); + // 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_millis(1000)).await; + } + + let data = self + .client + .post(&url) + .headers(self.headers.clone()) + .json(&Self::create_filters(&filters, last_row_number_n)) + .send() + .await? + .json::>() + .await?; + + last_row_number = data.last().map(|e| e.row_number as u64); + + data.into_iter().for_each(|e| results.push(e)); + } + + Ok(results) + } + + pub async fn start_time_entry(&self, mut body: HashMap) -> anyhow::Result<()> { + let url = format!( + "{base_url}/workspaces/{workspace_id}/time_entries", + base_url = self.base_url, + workspace_id = self.workspace_id + ); + + body.insert( + "workspace_id".to_string(), + self.workspace_id.parse::().unwrap().into(), + ); + + dbg!(self.client + .post(url) + .headers(self.headers.clone()) + .json(&body) + .send() + .await? + .text() + .await?); + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a49dfd9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,89 @@ +use crate::client::TogglClient; +use crate::types::{Current, ReportEntry, TogglQuery}; +use anyhow::anyhow; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Extension, Json, Router}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use beachhead::{shutdown_signal, Result}; +use clap::Parser; +use serde_json::Value; +use std::collections::HashMap; +use std::net::SocketAddr; +use tower_http::trace::TraceLayer; + +mod client; +mod types; + +#[derive(Debug, Clone, Parser)] +struct Config { + #[arg(long = "workspace", short, env)] + workspace_id: u32, + + #[arg(long = "token", short, env)] + toggl_api_token: String, + + #[arg(long = "addr", short, env, default_value = "0.0.0.0:3000")] + address: SocketAddr, +} + +pub async fn report( + Extension(toggl_client): Extension, + Json(query): Json, +) -> Result>> { + Ok(toggl_client.full_report(&query).await.map(Json)?) +} + +pub async fn current( + Extension(toggl_client): Extension, +) -> Result>> { + Ok(toggl_client.get_current().await.map(Json)?) +} + +pub async fn start_time_entry( + Extension(toggl_client): Extension, + Json(body): Json>, +) -> Result { + toggl_client.start_time_entry(body).await?; + + Ok((StatusCode::OK, "Ok")) +} + +async fn health(Extension(toggl_client): Extension) -> Result<&'static str> { + return if toggl_client.check_health().await { + Ok("Ok") + } else { + Err(anyhow!("Panopto health check failed").into()) + }; +} + +#[tokio::main] +async fn main() -> Result<()> { + // install global collector configured based on RUST_LOG env var. + tracing_subscriber::fmt::init(); + + let config = Config::parse(); + let toggl_client = TogglClient::new( + &config.workspace_id.to_string(), + &STANDARD.encode(&format!("{}:api_token", config.toggl_api_token)), + ); + + // build our application with a route + let app = Router::new() + .route("/health", get(health)) + .route("/current", get(current)) + .route("/report", post(report)) + .route("/start_time_entry", post(start_time_entry)) + .layer(Extension(toggl_client)) + .layer(TraceLayer::new_for_http()); + + tracing::info!("Listening on {}", config.address); + axum::Server::try_bind(&config.address)? + .serve(app.into_make_service()) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..2d6c5ff --- /dev/null +++ b/src/types.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_with::skip_serializing_none; +use std::collections::HashMap; +use std::option::Option; + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct TimeEntry { + pub id: u64, + pub seconds: u32, + pub start: String, + pub stop: String, + pub at: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct ReportEntry { + pub user_id: u32, + pub username: String, + pub project_id: Option, + pub task_id: Option, + pub billable: bool, + pub description: String, + pub tag_ids: Vec, + pub billable_amount_in_cents: Option, + pub hourly_rate_in_cents: Option, + pub currency: String, + pub time_entries: Vec, + pub row_number: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Current { + pub id: u64, + pub workspace_id: u64, + pub project_id: Option, + pub task_id: Option, + pub billable: bool, + pub start: String, + pub stop: Option, + pub duration: i64, + pub description: String, + pub tags: Vec, + pub tag_ids: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Project { + pub id: u64, + workspace_id: u64, + pub client_id: Option, + name: String, + active: bool, + + #[serde(flatten)] + pub rest: HashMap, +} + +#[allow(non_snake_case)] +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TogglQuery { + pub billable: Option, + pub client_ids: Option>, + pub description: Option, + pub end_date: Option, + pub first_id: Option, + pub first_row_number: Option, + pub first_timestamp: Option, + pub group_ids: Option>, + pub grouped: Option, + pub hide_amounts: Option, + pub max_duration_seconds: Option, + pub min_duration_seconds: Option, + pub order_by: Option, + pub order_dir: Option, + pub postedFields: Option>, + pub project_ids: Option>, + pub rounding: Option, + pub rounding_minutes: Option, + pub startTime: Option, + pub start_date: Option, + pub tag_ids: Option>, + pub task_ids: Option>, + pub time_entry_ids: Option>, + pub user_ids: Option>, + + #[serde(flatten)] + pub rest: HashMap, +}