umami-api 0.0.2

Easily interact with the Umami API (self-hosted instances)
Documentation
use std::collections::HashMap;

use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use struct_iterable::Iterable;
use thiserror::Error;

use crate::users::UserWithTeams;

pub mod admin;
pub mod teams;
pub mod users;
pub mod website_stats;
pub mod websites;

pub struct Umami {
  pub client: Client,
  pub token: Token,
  pub instance: String,
}

/// Allows you to specify a span of time
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Timestamps {
  pub start_at: DateTime<Utc>,
  pub end_at: DateTime<Utc>,
}

/// Many endpoints can make use of the filters in this interface
#[derive(Default, Iterable)]
pub struct Filters {
  /// Name of URL
  pub path: Option<&'static str>,
  /// Name of referrer
  pub referrer: Option<&'static str>,
  /// Name of page title
  pub title: Option<&'static str>,
  /// Name of query parameter
  pub query: Option<&'static str>,
  /// Name of browser
  pub browser: Option<&'static str>,
  /// Name of operating system
  pub os: Option<&'static str>,
  /// Name of device (ex. Mobile)
  pub device: Option<&'static str>,
  /// Name of country (two letters diminutive)
  pub country: Option<&'static str>,
  /// Name of region/state/province
  pub region: Option<&'static str>,
  /// Name of city
  pub city: Option<&'static str>,
  /// Name of hostname
  pub hostname: Option<&'static str>,
  /// Name of tag
  pub tag: Option<&'static str>,
  /// UUID of segment
  pub segment: Option<&'static str>,
  /// UUID of cohort
  pub cohort: Option<&'static str>,
}

impl Filters {
  /// Converts the filters into a format that is usable by the client
  fn as_parameters(&self) -> Vec<(&str, String)> {
    self.iter()
      .filter_map(|(key, value)| {
        if let Some(Some(string)) = value.downcast_ref::<Option<&str>>() {
          Some((key, string.to_string()))
        } else {
          None
        }
      }).collect()
  }
}

#[derive(Clone, Debug, Deserialize)]
pub struct Token {
  pub token: String,
  pub user: UserWithTeams,
}

impl Umami {
  /// Create a new Umami client, which you'll use to make your requests!
  ///
  /// * `instance` - The URL of the instance's API route, for example `https://your-umami-instance.com/api`.
  /// * `username` - The username of the user you want to authenticate as.
  /// * `password` - The password of the user you want to authenticate as.
  pub async fn new(instance: String, username: String, password: String) -> Result<Self, UmamiError> {
    let client = Client::new();
    let token = get_new_token(&instance, &username, &password).await?;

    Ok(Self {
      client,
      token,
      instance,
    })
  }

  pub async fn request<T: DeserializeOwned, P: Serialize + Sized>(&self, method: &str, endpoint: &str, params: P) -> Result<T, UmamiError> {
    let url = format!("{}/{}", self.instance, endpoint);

    let request = match method {
      "post" => self.client.post(&url).json(&params), // TODO likely to not work? hashmap likely better?
      _ => self.client.get(&url).query(&params),
    };

    let response = request
      .bearer_auth(&self.token.token)
      .send()
      .await?;

    // We cannot consume the response twice, so instead of trying to call .json() twice,
    // let's use serde_json twice to consume references to a String containing what we want from the Response!
    let text = response
      .text()
      .await?;

    Ok(serde_json::from_str::<T>(&text)?)
  }
}

/// Generate a new token by using your credentials!
pub async fn get_new_token(instance: &str, username: &str, password: &str) -> Result<Token, UmamiError> {
  let client = Client::new();
  let url = format!("{}/auth/login", instance);

  let mut params = HashMap::new();
  params.insert("username", username);
  params.insert("password", password);

  let response = client
    .post(&url)
    .json(&params)
    .send()
    .await?;

  let text = response
    .text()
    .await?;

  Ok(serde_json::from_str::<Token>(&text)?)
}

/// When something goes wrong, an UmamiError is used to describe what happened.
#[derive(Error, Debug)]
pub enum UmamiError {
  /// A generic error used when none of the other possible errors are fitting.
  #[error("HTTP request failed: {0}")]
  RequestFailed(#[from] reqwest::Error),
  /// Happens if the API gives us an unexpected object, likely means there's a mistake with this crate (or a bad struct given to request()).
  #[error("Failed to parse JSON: {0}")]
  JsonParseError(#[from] serde_json::Error),
}