Skip to main content

umami_api/
lib.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use reqwest::Client;
5use serde::{Deserialize, Serialize, de::DeserializeOwned};
6use struct_iterable::Iterable;
7use thiserror::Error;
8
9use crate::users::User;
10
11pub mod teams;
12pub mod users;
13pub mod website_stats;
14
15pub struct Umami {
16  pub client: Client,
17  pub token: Token,
18  pub instance: String,
19}
20
21#[derive(Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct Timestamps {
24  start_at: DateTime<Utc>,
25  end_at: DateTime<Utc>,
26}
27
28/// Many endpoints can make use of the filters in this interface
29#[derive(Default, Iterable)]
30pub struct Filters {
31  /// Name of URL
32  path: Option<&'static str>,
33  /// Name of referrer
34  referrer: Option<&'static str>,
35  /// Name of page title
36  title: Option<&'static str>,
37  /// Name of query parameter
38  query: Option<&'static str>,
39  /// Name of browser
40  browser: Option<&'static str>,
41  /// Name of operating system
42  os: Option<&'static str>,
43  /// Name of device (ex. Mobile)
44  device: Option<&'static str>,
45  /// Name of country (two letters diminutive)
46  country: Option<&'static str>,
47  /// Name of region/state/province
48  region: Option<&'static str>,
49  /// Name of city
50  city: Option<&'static str>,
51  /// Name of hostname
52  hostname: Option<&'static str>,
53  /// Name of tag
54  tag: Option<&'static str>,
55  /// UUID of segment
56  segment: Option<&'static str>,
57  /// UUID of cohort
58  cohort: Option<&'static str>,
59}
60
61impl Filters {
62  /// Converts the filters into a format that is usable by the client
63  fn as_parameters(&self) -> Vec<(&str, String)> {
64    self.iter()
65      .filter_map(|(key, value)| {
66        if let Some(Some(string)) = value.downcast_ref::<Option<&str>>() {
67          Some((key, string.to_string()))
68        } else {
69          None
70        }
71      }).collect()
72  }
73}
74
75#[derive(Clone, Debug, Deserialize)]
76pub struct Token {
77  pub token: String,
78  pub user: User,
79}
80
81impl Umami {
82  /// Create a new Umami client, which you'll use to make your requests!
83  ///
84  /// * `instance` - The URL of the instance's API route, for example `https://your-umami-instance.com/api`.
85  /// * `username` - The username of the user you want to authenticate as.
86  /// * `password` - The password of the user you want to authenticate as.
87  pub async fn new(instance: String, username: String, password: String) -> Result<Self, UmamiError> {
88    let client = Client::new();
89    let token = get_new_token(&instance, &username, &password).await?;
90
91    Ok(Self {
92      client,
93      token,
94      instance,
95    })
96  }
97
98  pub async fn request<T: DeserializeOwned, P: Serialize + Sized>(&self, method: &str, endpoint: &str, params: P) -> Result<T, UmamiError> {
99    let url = format!("{}/{}", self.instance, endpoint);
100
101    let request = match method {
102      "post" => self.client.post(&url).json(&params), // TODO likely to not work? hashmap likely better?
103      _ => self.client.get(&url).query(&params),
104    };
105
106    let response = request
107      .bearer_auth(&self.token.token)
108      .send()
109      .await?;
110
111    // We cannot consume the response twice, so instead of trying to call .json() twice,
112    // let's use serde_json twice to consume references to a String containing what we want from the Response!
113    let text = response
114      .text()
115      .await?;
116
117    Ok(serde_json::from_str::<T>(&text)?)
118  }
119}
120
121/// Generate a new token by using your credentials!
122pub async fn get_new_token(instance: &str, username: &str, password: &str) -> Result<Token, UmamiError> {
123  let client = Client::new();
124  let url = format!("{}/auth/login", instance);
125
126  let mut params = HashMap::new();
127  params.insert("username", username);
128  params.insert("password", password);
129
130  let response = client
131    .post(&url)
132    .json(&params)
133    .send()
134    .await?;
135
136  let text = response
137    .text()
138    .await?;
139
140  Ok(serde_json::from_str::<Token>(&text)?)
141}
142
143/// When something goes wrong, an UmamiError is used to describe what happened.
144#[derive(Error, Debug)]
145pub enum UmamiError {
146  /// A generic error used when none of the other possible errors are fitting.
147  #[error("HTTP request failed: {0}")]
148  RequestFailed(#[from] reqwest::Error),
149  /// 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()).
150  #[error("Failed to parse JSON: {0}")]
151  JsonParseError(#[from] serde_json::Error),
152}