Skip to main content

gotify/
service.rs

1use serde_json::{json, Value};
2
3use crate::error::Result;
4use crate::GotifyClient;
5
6/// Business-logic facade over [`GotifyClient`]: the same endpoints, plus
7/// client-side filtering and pagination shaping that any embedder is likely
8/// to want (Gotify's own API has no server-side text search, and its
9/// `applications`/`clients` listings have no filter parameter at all).
10///
11/// Consumers embedding this crate (CLI commands, MCP tools, HTTP handlers)
12/// should depend on `GotifyService`, not [`GotifyClient`] directly — it is
13/// the stable seam for adding cross-cutting behavior without touching every
14/// call site.
15///
16/// Deliberately **not** included here: request counters, uptime/status
17/// reporting, or a destructive-action confirmation gate. Those are
18/// server/product policy (how *your* MCP tool or CLI chooses to expose
19/// `delete_*`), not something this crate should bake in — same reasoning as
20/// `unifi`'s `AuthScope` being metadata only, not enforced by the crate.
21#[derive(Clone)]
22pub struct GotifyService {
23    client: GotifyClient,
24}
25
26impl GotifyService {
27    /// Wraps an already-built [`GotifyClient`].
28    pub fn new(client: GotifyClient) -> Self {
29        Self { client }
30    }
31
32    // ── unauthenticated ───────────────────────────────────────────────────────
33
34    /// Server health check.
35    ///
36    /// # Errors
37    /// See [`crate::GotifyError`] for the failure cases this can return.
38    pub async fn health(&self) -> Result<Value> {
39        self.client.health().await
40    }
41
42    /// Server version.
43    ///
44    /// # Errors
45    /// See [`crate::GotifyError`] for the failure cases this can return.
46    pub async fn version(&self) -> Result<Value> {
47        self.client.version().await
48    }
49
50    // ── current user ─────────────────────────────────────────────────────────
51
52    /// Authenticated user info.
53    ///
54    /// # Errors
55    /// See [`crate::GotifyError`] for the failure cases this can return.
56    pub async fn me(&self) -> Result<Value> {
57        self.client.me().await
58    }
59
60    // ── messages ──────────────────────────────────────────────────────────────
61
62    /// Lists messages with client-side text search and offset pagination on
63    /// top of [`GotifyClient::messages`]'s server-paginated result.
64    ///
65    /// `limit` is capped at 200 and defaults to 50. `query`, when given,
66    /// keeps only messages whose `message` or `title` contains it
67    /// (case-insensitive). `offset` skips that many matching results before
68    /// the page starts.
69    ///
70    /// # Errors
71    /// See [`crate::GotifyError`] for the failure cases this can return.
72    pub async fn messages(
73        &self,
74        app_id: Option<i64>,
75        limit: Option<i64>,
76        since: Option<i64>,
77        offset: Option<i64>,
78        query: Option<&str>,
79    ) -> Result<Value> {
80        let limit = limit.unwrap_or(50).min(200);
81        let result = self.client.messages(app_id, Some(limit), since).await?;
82
83        let mut messages: Vec<Value> = result
84            .get("messages")
85            .and_then(Value::as_array)
86            .cloned()
87            .or_else(|| result.as_array().cloned())
88            .unwrap_or_default();
89        let total_before_filter = messages.len();
90
91        if let Some(query) = query {
92            let query = query.to_lowercase();
93            messages.retain(|message| {
94                let body = message["message"].as_str().unwrap_or("").to_lowercase();
95                let title = message["title"].as_str().unwrap_or("").to_lowercase();
96                body.contains(&query) || title.contains(&query)
97            });
98        }
99
100        let offset = offset.unwrap_or(0).max(0) as usize;
101        messages = if offset < messages.len() {
102            messages[offset..].to_vec()
103        } else {
104            Vec::new()
105        };
106
107        let total = if query.is_some() {
108            messages.len() as i64
109        } else {
110            result["paging"]["size"]
111                .as_i64()
112                .unwrap_or(total_before_filter as i64)
113        };
114        let has_more = messages.len() as i64 >= limit;
115        let next_offset = offset as i64 + messages.len() as i64;
116
117        Ok(json!({
118            "messages": messages,
119            "total": total,
120            "limit": limit,
121            "offset": offset,
122            "has_more": has_more,
123            "next_offset": next_offset,
124        }))
125    }
126
127    /// Sends a notification.
128    ///
129    /// # Errors
130    /// See [`crate::GotifyError`] for the failure cases this can return.
131    pub async fn send_message(
132        &self,
133        message: &str,
134        title: Option<&str>,
135        priority: Option<i64>,
136        extras: Option<Value>,
137    ) -> Result<Value> {
138        self.client
139            .send_message(message, title, priority, extras)
140            .await
141    }
142
143    /// Deletes one message.
144    ///
145    /// # Errors
146    /// See [`crate::GotifyError`] for the failure cases this can return.
147    pub async fn delete_message(&self, id: i64) -> Result<Value> {
148        self.client.delete_message(id).await
149    }
150
151    /// Deletes every message.
152    ///
153    /// # Errors
154    /// See [`crate::GotifyError`] for the failure cases this can return.
155    pub async fn delete_all_messages(&self) -> Result<Value> {
156        self.client.delete_all_messages().await
157    }
158
159    // ── applications ──────────────────────────────────────────────────────────
160
161    /// Lists applications, optionally filtered by a case-insensitive
162    /// substring match on `name` — Gotify's own API has no filter parameter
163    /// for this listing.
164    ///
165    /// # Errors
166    /// See [`crate::GotifyError`] for the failure cases this can return.
167    pub async fn applications(&self, name_filter: Option<&str>) -> Result<Value> {
168        let result = self.client.applications().await?;
169        let Some(filter) = name_filter else {
170            return Ok(result);
171        };
172
173        let filter_lower = filter.to_lowercase();
174        let filtered: Vec<Value> = result
175            .as_array()
176            .cloned()
177            .unwrap_or_default()
178            .into_iter()
179            .filter(|app| {
180                app["name"]
181                    .as_str()
182                    .is_some_and(|name| name.to_lowercase().contains(&filter_lower))
183            })
184            .collect();
185
186        Ok(json!({
187            "applications": filtered,
188            "total": filtered.len(),
189            "filter_name": filter,
190        }))
191    }
192
193    /// Creates an application.
194    ///
195    /// # Errors
196    /// See [`crate::GotifyError`] for the failure cases this can return.
197    pub async fn create_application(
198        &self,
199        name: &str,
200        description: Option<&str>,
201        default_priority: Option<i64>,
202    ) -> Result<Value> {
203        self.client
204            .create_application(name, description, default_priority)
205            .await
206    }
207
208    /// Updates an application.
209    ///
210    /// # Errors
211    /// See [`crate::GotifyError`] for the failure cases this can return.
212    pub async fn update_application(
213        &self,
214        app_id: i64,
215        name: Option<&str>,
216        description: Option<&str>,
217        default_priority: Option<i64>,
218    ) -> Result<Value> {
219        self.client
220            .update_application(app_id, name, description, default_priority)
221            .await
222    }
223
224    /// Deletes an application.
225    ///
226    /// # Errors
227    /// See [`crate::GotifyError`] for the failure cases this can return.
228    pub async fn delete_application(&self, app_id: i64) -> Result<Value> {
229        self.client.delete_application(app_id).await
230    }
231
232    // ── clients ───────────────────────────────────────────────────────────────
233
234    /// Lists clients.
235    ///
236    /// # Errors
237    /// See [`crate::GotifyError`] for the failure cases this can return.
238    pub async fn clients(&self) -> Result<Value> {
239        self.client.clients().await
240    }
241
242    /// Creates a client.
243    ///
244    /// # Errors
245    /// See [`crate::GotifyError`] for the failure cases this can return.
246    pub async fn create_client(&self, name: &str) -> Result<Value> {
247        self.client.create_client(name).await
248    }
249
250    /// Deletes a client.
251    ///
252    /// # Errors
253    /// See [`crate::GotifyError`] for the failure cases this can return.
254    pub async fn delete_client(&self, client_id: i64) -> Result<Value> {
255        self.client.delete_client(client_id).await
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::GotifyConfig;
263
264    fn service() -> GotifyService {
265        let client = GotifyClient::new(&GotifyConfig {
266            url: "https://gotify.local".to_string(),
267            ..GotifyConfig::default()
268        })
269        .unwrap();
270        GotifyService::new(client)
271    }
272
273    #[test]
274    fn service_can_be_constructed_from_a_client() {
275        let _ = service();
276    }
277}