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#[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 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 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 pub async fn health(&self) -> Result<Value> {
122 self.request(None, "health", Method::GET, "health", None, None)
123 .await
124 }
125
126 pub async fn version(&self) -> Result<Value> {
131 self.request(None, "version", Method::GET, "version", None, None)
132 .await
133 }
134
135 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 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 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 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 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 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 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 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 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 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 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 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 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}