acari-lib 0.1.12

Pragmatic client for the mite timetracking API
Documentation
use crate::everhour_model::{
  build_time_entry_id, date_span_query_param, parse_time_entry_id, EverhourCreateTimeRecord, EverhourError, EverhourTask, EverhourTimeEntry, EverhourTimer,
  EverhourUser,
};
use crate::model::{Account, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
use crate::query::{DateSpan, Day};
use crate::Client;
use crate::{error::AcariError, everhour_model::EverhourProject};
use chrono::Utc;
use reqwest::{blocking, header, Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use serde_json::json;
use std::collections::HashMap;
use url::Url;

const USER_AGENT: &str = "acari-lib (https://github.com/untoldwind/acari)";

#[derive(Debug)]
pub struct EverhourClient {
  base_url: Url,
  client: blocking::Client,
}

impl EverhourClient {
  pub fn new(domain: &str, token: &str) -> Result<EverhourClient, AcariError> {
    Ok(Self::new_form_url(format!("https://{}@{}", token, domain).parse()?))
  }

  pub fn new_form_url(base_url: Url) -> EverhourClient {
    EverhourClient {
      base_url,
      client: blocking::Client::new(),
    }
  }

  fn base_request(&self, method: Method, uri: &str) -> Result<blocking::RequestBuilder, AcariError> {
    Ok(
      self
        .client
        .request(method, self.base_url.join(uri)?.as_str())
        .header(header::USER_AGENT, USER_AGENT)
        .header(header::HOST, self.base_url.host_str().unwrap_or(""))
        .header("X-Api-Key", self.base_url.username()),
    )
  }

  fn request<T: DeserializeOwned>(&self, method: Method, uri: &str) -> Result<T, AcariError> {
    let response = self.base_request(method, uri)?.send()?;

    Self::handle_response(response)
  }

  fn request_with_body<T: DeserializeOwned, D: Serialize>(&self, method: Method, uri: &str, data: D) -> Result<T, AcariError> {
    let response = self.base_request(method, uri)?.json(&data).send()?;

    Self::handle_response(response)
  }

  fn handle_response<T: DeserializeOwned>(response: blocking::Response) -> Result<T, AcariError> {
    match response.status() {
      StatusCode::OK | StatusCode::CREATED => Ok(response.json()?),
      status => match response.json::<EverhourError>() {
        Ok(err) => Err(AcariError::Mite(err.code, err.message)),
        _ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
      },
    }
  }

  fn entry_from_timer(&self, timer: EverhourTimer) -> Result<Option<TimeEntry>, AcariError> {
    match (timer.status.as_str(), timer.task, timer.user) {
      ("active", Some(task), Some(user)) => {
        let maybe_project = match task.projects.get(0) {
          Some(project_id) => Some(self.request::<EverhourProject>(Method::GET, &format!("/projects/{}", project_id.path_encoded()))?),
          None => None,
        };
        let minutes = Minutes((timer.duration.unwrap_or_default() + timer.today.unwrap_or_default()) / 60);
        Ok(Some(TimeEntry {
          id: build_time_entry_id(&user.id, &task.id, &timer.started_at.naive_utc().date()),
          date_at: timer.started_at.naive_utc().date(),
          minutes,
          customer_id: maybe_project.as_ref().map(|p| p.workspace_id.clone()).unwrap_or_default(),
          customer_name: maybe_project.as_ref().map(|p| p.workspace_name.clone()).unwrap_or_default(),
          project_id: maybe_project.as_ref().map(|p| p.id.clone()).unwrap_or_default(),
          project_name: maybe_project.as_ref().map(|p| p.name.clone()).unwrap_or_default(),
          service_id: task.id,
          service_name: task.name,
          user_id: user.id.clone(),
          user_name: user.name.clone(),
          note: timer.comment.unwrap_or_default(),
          billable: true,
          locked: false,
          created_at: timer.started_at,
        }))
      }
      _ => Ok(None),
    }
  }
}

impl Client for EverhourClient {
  fn get_domain(&self) -> String {
    self.base_url.host_str().unwrap_or("").to_owned()
  }

  fn get_account(&self) -> Result<Account, AcariError> {
    Ok(self.request::<EverhourUser>(Method::GET, "/users/me")?.into())
  }

  fn get_myself(&self) -> Result<User, AcariError> {
    Ok(self.request::<EverhourUser>(Method::GET, "/users/me")?.into())
  }

  fn get_customers(&self) -> Result<Vec<Customer>, AcariError> {
    let projects = self.request::<Vec<EverhourProject>>(Method::GET, "/projects")?;
    let mut customers_map: HashMap<CustomerId, Customer> = HashMap::new();

    for project in projects {
      let created_at = project.created_at;
      let archived = project.status != "open";
      let customer_ref = customers_map.entry(project.workspace_id.clone()).or_insert_with(|| project.into());

      if created_at < customer_ref.created_at {
        customer_ref.created_at = created_at;
      }
      if !archived {
        customer_ref.archived = false;
      }
    }

    Ok(customers_map.into_iter().map(|(_, v)| v).collect())
  }

  fn get_projects(&self) -> Result<Vec<Project>, AcariError> {
    let projects = self.request::<Vec<EverhourProject>>(Method::GET, "/projects")?;

    Ok(projects.into_iter().map(Into::into).collect())
  }

  fn get_services(&self, project_id: &ProjectId) -> Result<Vec<Service>, AcariError> {
    let tasks = self.request::<Vec<EverhourTask>>(Method::GET, &format!("/projects/{}/tasks", project_id.path_encoded()))?;

    Ok(tasks.into_iter().map(Into::into).collect())
  }

  fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError> {
    let user = self.request::<EverhourUser>(Method::GET, "/users/me")?;
    let project_map: HashMap<ProjectId, EverhourProject> = self
      .request::<Vec<EverhourProject>>(Method::GET, "/projects")?
      .into_iter()
      .map(|p| (p.id.clone(), p))
      .collect();
    let entries = self.request::<Vec<EverhourTimeEntry>>(Method::GET, &format!("/users/me/time?{}", date_span_query_param(&date_span)))?;

    Ok(entries.into_iter().filter_map(|e| e.into_entry(&project_map, &user)).collect())
  }

  fn create_time_entry(&self, day: Day, _: &ProjectId, service_id: &ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError> {
    let user = self.request::<EverhourUser>(Method::GET, "/users/me")?;
    let project_map: HashMap<ProjectId, EverhourProject> = self
      .request::<Vec<EverhourProject>>(Method::GET, "/projects")?
      .into_iter()
      .map(|p| (p.id.clone(), p))
      .collect();

    let entry: EverhourTimeEntry = self.request_with_body(
      Method::PUT,
      &format!("/tasks/{}/time", service_id.path_encoded()),
      EverhourCreateTimeRecord {
        date: day.as_date(),
        user: user.id.clone(),
        time: minutes,
        comment: note.unwrap_or_default(),
      },
    )?;

    entry
      .into_entry(&project_map, &user)
      .ok_or_else(|| AcariError::InternalError("Invalid time entry id (invalid parts)".to_string()))
  }

  fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
    let (user_id, service_id, date) = parse_time_entry_id(entry_id)?;

    if minutes.0 == 0 {
      let _: EverhourTimeEntry = self.request_with_body(
        Method::DELETE,
        &format!("/tasks/{}/time", service_id.path_encoded()),
        json!({
          "date": date,
          "user": user_id,
        }),
      )?;
    } else {
      let _: EverhourTimeEntry = self.request_with_body(
        Method::PUT,
        &format!("/tasks/{}/time", service_id.path_encoded()),
        EverhourCreateTimeRecord {
          date,
          user: user_id,
          time: minutes,
          comment: note.unwrap_or_default(),
        },
      )?;
    }

    Ok(())
  }

  fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError> {
    let (user_id, service_id, date) = parse_time_entry_id(entry_id)?;

    let _: EverhourTimeEntry = self.request_with_body(
      Method::POST,
      &format!("/tasks/{}/time", service_id.path_encoded()),
      json!({
        "user": user_id,
        "date": date,
      }),
    )?;

    Ok(())
  }

  fn get_tracker(&self) -> Result<Tracker, AcariError> {
    let timer = self.request::<EverhourTimer>(Method::GET, "/timers/current")?;
    let started_at = timer.started_at;

    match self.entry_from_timer(timer)? {
      Some(time_entry) => Ok(Tracker {
        since: Some(started_at),
        tracking_time_entry: Some(time_entry),
        stopped_time_entry: None,
      }),
      _ => Ok(Tracker {
        since: None,
        tracking_time_entry: None,
        stopped_time_entry: None,
      }),
    }
  }

  fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
    let (_, service_id, date) = parse_time_entry_id(entry_id)?;
    let timer: EverhourTimer = self.request_with_body(
      Method::POST,
      "/timers",
      json!({
        "task": service_id,
        "userDate": date,
      }),
    )?;

    Ok(Tracker {
      since: Some(Utc::now()),
      tracking_time_entry: self.entry_from_timer(timer)?,
      stopped_time_entry: None,
    })
  }

  fn delete_tracker(&self, _: &TimeEntryId) -> Result<Tracker, AcariError> {
    let timer = self.request::<EverhourTimer>(Method::DELETE, "/timers/current")?;

    Ok(Tracker {
      since: None,
      tracking_time_entry: None,
      stopped_time_entry: self.entry_from_timer(timer)?,
    })
  }
}