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}