Skip to main content

gotify/
client.rs

1use std::fmt;
2use std::time::Duration;
3
4use reqwest::{Client, Method};
5use serde_json::{json, Value};
6
7use crate::error::{GotifyError, Result};
8use crate::{http, GotifyConfig};
9
10/// HTTP REST client for Gotify push-notification servers.
11///
12/// Authentication uses the `X-Gotify-Key` header with one of two distinct
13/// token kinds: a **client token** for management operations (messages,
14/// applications, clients, current user) and an **app token** for sending
15/// messages. `health`/`version` need no token at all.
16///
17/// Builds and holds one pooled [`reqwest::Client`] for its lifetime — clone
18/// and share a `GotifyClient` rather than constructing a new one per
19/// request, so requests reuse connections instead of paying a fresh TLS
20/// handshake each time.
21///
22/// `Debug` redacts both tokens, same as [`GotifyConfig`].
23#[derive(Clone)]
24pub struct GotifyClient {
25    http: Client,
26    url: String,
27    client_token: String,
28    app_token: String,
29    request_timeout: Duration,
30}
31
32impl fmt::Debug for GotifyClient {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.debug_struct("GotifyClient")
35            .field("url", &self.url)
36            .field(
37                "client_token",
38                &format_args!("<redacted, {} bytes>", self.client_token.len()),
39            )
40            .field(
41                "app_token",
42                &format_args!("<redacted, {} bytes>", self.app_token.len()),
43            )
44            .field("request_timeout", &self.request_timeout)
45            .finish()
46    }
47}
48
49impl GotifyClient {
50    /// Builds a client from `cfg`.
51    ///
52    /// Unlike `client_token`/`app_token` (each validated lazily, only by the
53    /// specific calls that need them), `url` is required upfront — every
54    /// call needs it, so failing fast here is strictly more useful than
55    /// deferring to the first request.
56    ///
57    /// # Errors
58    /// Returns [`GotifyError::MissingUrl`] if `cfg.url` is empty, or
59    /// [`GotifyError::ClientBuild`] if the underlying HTTP client fails to
60    /// construct.
61    pub fn new(cfg: &GotifyConfig) -> Result<Self> {
62        if cfg.url.is_empty() {
63            return Err(GotifyError::MissingUrl);
64        }
65        let http = http::build_client(cfg)?;
66        Ok(Self {
67            http,
68            url: cfg.url.trim_end_matches('/').to_string(),
69            client_token: cfg.client_token.clone(),
70            app_token: cfg.app_token.clone(),
71            request_timeout: cfg.request_timeout,
72        })
73    }
74
75    /// Reconstructs the [`GotifyConfig`] this client was built from.
76    pub fn config(&self) -> GotifyConfig {
77        GotifyConfig {
78            url: self.url.clone(),
79            client_token: self.client_token.clone(),
80            app_token: self.app_token.clone(),
81            request_timeout: self.request_timeout,
82        }
83    }
84
85    fn require_client_token(&self) -> Result<&str> {
86        if self.client_token.is_empty() {
87            return Err(GotifyError::MissingClientToken);
88        }
89        Ok(&self.client_token)
90    }
91
92    fn require_app_token(&self) -> Result<&str> {
93        if self.app_token.is_empty() {
94            return Err(GotifyError::MissingAppToken);
95        }
96        Ok(&self.app_token)
97    }
98
99    #[allow(clippy::too_many_arguments)]
100    async fn request(
101        &self,
102        token: Option<&str>,
103        action: &str,
104        method: Method,
105        path: &str,
106        query: Option<&[(&str, String)]>,
107        body: Option<&Value>,
108    ) -> Result<Value> {
109        http::request_json(
110            &self.http, &self.url, token, action, method, path, query, body,
111        )
112        .await
113    }
114
115    // ── unauthenticated ───────────────────────────────────────────────────────
116
117    /// Server health check. Needs no token.
118    ///
119    /// # Errors
120    /// See [`GotifyError`] for the failure cases this can return.
121    pub async fn health(&self) -> Result<Value> {
122        self.request(None, "health", Method::GET, "health", None, None)
123            .await
124    }
125
126    /// Server version. Needs no token.
127    ///
128    /// # Errors
129    /// See [`GotifyError`] for the failure cases this can return.
130    pub async fn version(&self) -> Result<Value> {
131        self.request(None, "version", Method::GET, "version", None, None)
132            .await
133    }
134
135    // ── current user ─────────────────────────────────────────────────────────
136
137    /// Authenticated user info. Requires a client token.
138    ///
139    /// # Errors
140    /// Returns [`GotifyError::MissingClientToken`] if no client token is
141    /// configured; see [`GotifyError`] for the other failure cases this can
142    /// return.
143    pub async fn me(&self) -> Result<Value> {
144        let token = self.require_client_token()?;
145        self.request(Some(token), "me", Method::GET, "current/user", None, None)
146            .await
147    }
148
149    // ── messages ──────────────────────────────────────────────────────────────
150
151    /// Lists messages, optionally scoped to one application. Requires a
152    /// client token.
153    ///
154    /// `limit` defaults to 50 server-side if `None`.
155    ///
156    /// # Errors
157    /// Returns [`GotifyError::MissingClientToken`] if no client token is
158    /// configured; see [`GotifyError`] for the other failure cases this can
159    /// return.
160    pub async fn messages(
161        &self,
162        app_id: Option<i64>,
163        limit: Option<i64>,
164        since: Option<i64>,
165    ) -> Result<Value> {
166        let token = self.require_client_token()?;
167        let path = match app_id {
168            Some(id) => format!("application/{id}/message"),
169            None => "message".to_string(),
170        };
171        let mut query: Vec<(&str, String)> = vec![("limit", limit.unwrap_or(50).to_string())];
172        if let Some(since) = since {
173            query.push(("since", since.to_string()));
174        }
175        self.request(
176            Some(token),
177            "messages",
178            Method::GET,
179            &path,
180            Some(&query),
181            None,
182        )
183        .await
184    }
185
186    /// Sends a notification. Requires an app token (not a client token).
187    ///
188    /// # Errors
189    /// Returns [`GotifyError::MissingAppToken`] if no app token is
190    /// configured; see [`GotifyError`] for the other failure cases this can
191    /// return.
192    pub async fn send_message(
193        &self,
194        message: &str,
195        title: Option<&str>,
196        priority: Option<i64>,
197        extras: Option<Value>,
198    ) -> Result<Value> {
199        let token = self.require_app_token()?;
200        let mut body = json!({ "message": message });
201        if let Some(title) = title {
202            body["title"] = json!(title);
203        }
204        if let Some(priority) = priority {
205            body["priority"] = json!(priority);
206        }
207        if let Some(extras) = extras {
208            body["extras"] = extras;
209        }
210        self.request(
211            Some(token),
212            "send_message",
213            Method::POST,
214            "message",
215            None,
216            Some(&body),
217        )
218        .await
219    }
220
221    /// Deletes one message. Requires a client token.
222    ///
223    /// # Errors
224    /// Returns [`GotifyError::MissingClientToken`] if no client token is
225    /// configured; see [`GotifyError`] for the other failure cases this can
226    /// return.
227    pub async fn delete_message(&self, id: i64) -> Result<Value> {
228        let token = self.require_client_token()?;
229        self.request(
230            Some(token),
231            "delete_message",
232            Method::DELETE,
233            &format!("message/{id}"),
234            None,
235            None,
236        )
237        .await
238    }
239
240    /// Deletes every message. Requires a client token.
241    ///
242    /// # Errors
243    /// Returns [`GotifyError::MissingClientToken`] if no client token is
244    /// configured; see [`GotifyError`] for the other failure cases this can
245    /// return.
246    pub async fn delete_all_messages(&self) -> Result<Value> {
247        let token = self.require_client_token()?;
248        self.request(
249            Some(token),
250            "delete_all_messages",
251            Method::DELETE,
252            "message",
253            None,
254            None,
255        )
256        .await
257    }
258
259    // ── applications ──────────────────────────────────────────────────────────
260
261    /// Lists applications. Requires a client token.
262    ///
263    /// # Errors
264    /// Returns [`GotifyError::MissingClientToken`] if no client token is
265    /// configured; see [`GotifyError`] for the other failure cases this can
266    /// return.
267    pub async fn applications(&self) -> Result<Value> {
268        let token = self.require_client_token()?;
269        self.request(
270            Some(token),
271            "applications",
272            Method::GET,
273            "application",
274            None,
275            None,
276        )
277        .await
278    }
279
280    /// Creates an application. Requires a client token.
281    ///
282    /// # Errors
283    /// Returns [`GotifyError::MissingClientToken`] if no client token is
284    /// configured; see [`GotifyError`] for the other failure cases this can
285    /// return.
286    pub async fn create_application(
287        &self,
288        name: &str,
289        description: Option<&str>,
290        default_priority: Option<i64>,
291    ) -> Result<Value> {
292        let token = self.require_client_token()?;
293        let mut body = json!({ "name": name });
294        if let Some(description) = description {
295            body["description"] = json!(description);
296        }
297        if let Some(default_priority) = default_priority {
298            body["defaultPriority"] = json!(default_priority);
299        }
300        self.request(
301            Some(token),
302            "create_application",
303            Method::POST,
304            "application",
305            None,
306            Some(&body),
307        )
308        .await
309    }
310
311    /// Updates an application. Requires a client token.
312    ///
313    /// # Errors
314    /// Returns [`GotifyError::MissingClientToken`] if no client token is
315    /// configured; see [`GotifyError`] for the other failure cases this can
316    /// return.
317    pub async fn update_application(
318        &self,
319        app_id: i64,
320        name: Option<&str>,
321        description: Option<&str>,
322        default_priority: Option<i64>,
323    ) -> Result<Value> {
324        let token = self.require_client_token()?;
325        let mut body = json!({});
326        if let Some(name) = name {
327            body["name"] = json!(name);
328        }
329        if let Some(description) = description {
330            body["description"] = json!(description);
331        }
332        if let Some(default_priority) = default_priority {
333            body["defaultPriority"] = json!(default_priority);
334        }
335        self.request(
336            Some(token),
337            "update_application",
338            Method::PUT,
339            &format!("application/{app_id}"),
340            None,
341            Some(&body),
342        )
343        .await
344    }
345
346    /// Deletes an application. Requires a client token.
347    ///
348    /// # Errors
349    /// Returns [`GotifyError::MissingClientToken`] if no client token is
350    /// configured; see [`GotifyError`] for the other failure cases this can
351    /// return.
352    pub async fn delete_application(&self, app_id: i64) -> Result<Value> {
353        let token = self.require_client_token()?;
354        self.request(
355            Some(token),
356            "delete_application",
357            Method::DELETE,
358            &format!("application/{app_id}"),
359            None,
360            None,
361        )
362        .await
363    }
364
365    // ── clients ───────────────────────────────────────────────────────────────
366
367    /// Lists clients. Requires a client token.
368    ///
369    /// # Errors
370    /// Returns [`GotifyError::MissingClientToken`] if no client token is
371    /// configured; see [`GotifyError`] for the other failure cases this can
372    /// return.
373    pub async fn clients(&self) -> Result<Value> {
374        let token = self.require_client_token()?;
375        self.request(Some(token), "clients", Method::GET, "client", None, None)
376            .await
377    }
378
379    /// Creates a client. Requires a client token.
380    ///
381    /// # Errors
382    /// Returns [`GotifyError::MissingClientToken`] if no client token is
383    /// configured; see [`GotifyError`] for the other failure cases this can
384    /// return.
385    pub async fn create_client(&self, name: &str) -> Result<Value> {
386        let token = self.require_client_token()?;
387        self.request(
388            Some(token),
389            "create_client",
390            Method::POST,
391            "client",
392            None,
393            Some(&json!({ "name": name })),
394        )
395        .await
396    }
397
398    /// Deletes a client. Requires a client token.
399    ///
400    /// # Errors
401    /// Returns [`GotifyError::MissingClientToken`] if no client token is
402    /// configured; see [`GotifyError`] for the other failure cases this can
403    /// return.
404    pub async fn delete_client(&self, client_id: i64) -> Result<Value> {
405        let token = self.require_client_token()?;
406        self.request(
407            Some(token),
408            "delete_client",
409            Method::DELETE,
410            &format!("client/{client_id}"),
411            None,
412            None,
413        )
414        .await
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    fn config(url: &str) -> GotifyConfig {
423        GotifyConfig {
424            url: url.to_string(),
425            ..GotifyConfig::default()
426        }
427    }
428
429    #[test]
430    fn new_rejects_a_missing_url() {
431        let err = GotifyClient::new(&GotifyConfig::default()).unwrap_err();
432
433        assert!(matches!(err, GotifyError::MissingUrl));
434    }
435
436    #[test]
437    fn new_succeeds_without_any_token_configured() {
438        // Unlike unifi's single api_key, Gotify's two token kinds are each
439        // validated lazily by only the calls that need them — a client with
440        // no tokens at all is legitimate (health/version still work).
441        GotifyClient::new(&config("https://gotify.local")).unwrap();
442    }
443
444    #[test]
445    fn config_round_trips_a_non_default_request_timeout() {
446        let cfg = GotifyConfig {
447            url: "https://gotify.local".to_string(),
448            request_timeout: Duration::from_secs(90),
449            ..GotifyConfig::default()
450        };
451
452        let client = GotifyClient::new(&cfg).unwrap();
453
454        assert_eq!(client.config().request_timeout, Duration::from_secs(90));
455    }
456}