toggl 0.4.1

Unofficial command-line interface for Toggl Track using the v9 API.
use std::collections::HashMap;

use crate::credentials;
use crate::error;
use crate::models;
use crate::models::Entities;
use crate::models::Project;
use crate::models::Task;
use crate::models::TimeEntry;
use async_trait::async_trait;
use base64::{engine::general_purpose, Engine as _};
use error::ApiError;
#[cfg(test)]
use mockall::automock;
use models::{ResultWithDefaultError, User};
use reqwest::Client;
use reqwest::{header, RequestBuilder};
use serde::{de, Serialize};

use super::models::NetworkClient;
use super::models::NetworkProject;
use super::models::NetworkTask;
use super::models::NetworkTimeEntry;

#[cfg_attr(test, automock)]
#[async_trait]
pub trait ApiClient {
    async fn get_user(&self) -> ResultWithDefaultError<User>;
    async fn get_entities(&self) -> ResultWithDefaultError<Entities>;

    async fn create_time_entry(&self, time_entry: TimeEntry) -> ResultWithDefaultError<i64>;
    async fn update_time_entry(&self, time_entry: TimeEntry) -> ResultWithDefaultError<i64>;
}

pub struct V9ApiClient {
    http_client: Client,
    base_url: String,
}

impl V9ApiClient {
    async fn get_time_entries(&self) -> ResultWithDefaultError<Vec<NetworkTimeEntry>> {
        let url = format!("{}/me/time_entries", self.base_url);
        self.get::<Vec<NetworkTimeEntry>>(url).await
    }

    async fn get_projects(&self) -> ResultWithDefaultError<Vec<NetworkProject>> {
        let url = format!("{}/me/projects", self.base_url);
        self.get::<Vec<NetworkProject>>(url).await
    }

    async fn get_clients(&self) -> ResultWithDefaultError<Vec<NetworkClient>> {
        let url = format!("{}/me/clients", self.base_url);
        self.get::<Vec<NetworkClient>>(url).await
    }

    async fn get_tasks(&self) -> ResultWithDefaultError<Vec<NetworkTask>> {
        let url = format!("{}/me/tasks", self.base_url);
        self.get::<Vec<NetworkTask>>(url).await
    }

    pub fn from_credentials(
        credentials: credentials::Credentials,
        proxy: Option<String>,
    ) -> ResultWithDefaultError<V9ApiClient> {
        let auth_string = credentials.api_token + ":api_token";
        let header_content =
            "Basic ".to_string() + general_purpose::STANDARD.encode(auth_string).as_str();
        let mut headers = header::HeaderMap::new();
        let auth_header =
            header::HeaderValue::from_str(header_content.as_str()).expect("Invalid header value");
        headers.insert(header::AUTHORIZATION, auth_header);

        let base_client = Client::builder().default_headers(headers);
        let http_client = {
            if let Some(proxy) = proxy {
                base_client.proxy(reqwest::Proxy::all(proxy).expect("Invalid proxy"))
            } else {
                base_client
            }
        }
        .build()
        .expect("Couldn't build a http client");
        let api_client = Self {
            http_client,
            base_url: "https://track.toggl.com/api/v9".to_string(),
        };
        Ok(api_client)
    }

    async fn get<T: de::DeserializeOwned>(&self, url: String) -> ResultWithDefaultError<T> {
        V9ApiClient::send::<T>(self.http_client.get(url)).await
    }

    async fn put<T: de::DeserializeOwned, Body: Serialize>(
        &self,
        url: String,
        body: &Body,
    ) -> ResultWithDefaultError<T> {
        V9ApiClient::send::<T>(self.http_client.put(url).json(body)).await
    }

    async fn post<T: de::DeserializeOwned, Body: Serialize>(
        &self,
        url: String,
        body: &Body,
    ) -> ResultWithDefaultError<T> {
        V9ApiClient::send::<T>(self.http_client.post(url).json(body)).await
    }

    async fn send<T: de::DeserializeOwned>(request: RequestBuilder) -> ResultWithDefaultError<T> {
        match request.send().await {
            Err(_) => Err(Box::new(ApiError::Network)),
            Ok(response) => match response.json::<T>().await {
                Err(_) => Err(Box::new(ApiError::Deserialization)),
                Ok(parsed_response) => Ok(parsed_response),
            },
        }
    }
}

#[async_trait]
impl ApiClient for V9ApiClient {
    async fn get_user(&self) -> ResultWithDefaultError<User> {
        let url = format!("{}/me", self.base_url);
        return self.get::<User>(url).await;
    }

    async fn create_time_entry(&self, time_entry: TimeEntry) -> ResultWithDefaultError<i64> {
        let url = format!("{}/time_entries", self.base_url);
        let network_time_entry = self
            .post::<NetworkTimeEntry, NetworkTimeEntry>(url, &time_entry.into())
            .await?;
        return Ok(network_time_entry.id);
    }

    async fn update_time_entry(&self, time_entry: TimeEntry) -> ResultWithDefaultError<i64> {
        let url = format!("{}/time_entries/{}", self.base_url, time_entry.id);
        let network_time_entry = self
            .put::<NetworkTimeEntry, NetworkTimeEntry>(url, &time_entry.into())
            .await?;
        return Ok(network_time_entry.id);
    }

    async fn get_entities(&self) -> ResultWithDefaultError<Entities> {
        let (network_time_entries, network_projects, network_tasks, network_clients) = tokio::join!(
            self.get_time_entries(),
            self.get_projects(),
            self.get_tasks(),
            self.get_clients(),
        );

        let clients: HashMap<i64, crate::models::Client> = network_clients
            .unwrap_or_default()
            .iter()
            .map(|c| {
                (
                    c.id,
                    crate::models::Client {
                        id: c.id,
                        name: c.name.clone(),
                        workspace_id: c.wid,
                    },
                )
            })
            .collect();

        let projects: HashMap<i64, Project> = network_projects
            .unwrap_or_default()
            .iter()
            .map(|p| {
                (
                    p.id,
                    Project {
                        id: p.id,
                        name: p.name.clone(),
                        workspace_id: p.workspace_id,
                        client: clients.get(&p.client_id.unwrap_or(-1)).cloned(),
                        is_private: p.is_private,
                        active: p.active,
                        at: p.at,
                        created_at: p.created_at,
                        color: p.color.clone(),
                        billable: p.billable,
                    },
                )
            })
            .collect();

        let tasks: HashMap<i64, Task> = network_tasks
            .unwrap_or_default()
            .iter()
            .map(|t| {
                (
                    t.id,
                    Task {
                        id: t.id,
                        name: t.name.clone(),
                        project: projects.get(&t.project_id).unwrap().clone(),
                        workspace_id: t.workspace_id,
                    },
                )
            })
            .collect();

        let time_entries = network_time_entries
            .unwrap_or_default()
            .iter()
            .map(|te| TimeEntry {
                id: te.id,
                description: te.description.clone(),
                start: te.start,
                stop: te.stop,
                duration: te.duration,
                billable: te.billable,
                workspace_id: te.workspace_id,
                tags: te.tags.clone(),
                project: projects.get(&te.project_id.unwrap_or(-1)).cloned(),
                task: tasks.get(&te.task_id.unwrap_or(-1)).cloned(),
                ..Default::default()
            })
            .collect();

        Ok(Entities {
            time_entries,
            projects,
            tasks,
            clients,
        })
    }
}