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