Skip to main content

nextcloud_notifications/
client.rs

1//! Asynchronous client for Nextcloud's Notifications API v2.
2
3use nextcloud_ocs::{OcsEnvelope, OcsStatus};
4use nextcloud_sdk_core::{
5    ApiPath, AuthProvider, DecodeError, Error, Headers, HttpMethod, Request, Response, StatusCode,
6    StatusError, Transport,
7};
8use serde::Deserialize;
9
10use crate::notification::Notification;
11
12#[cfg(test)]
13mod tests;
14
15/// Relative path to Nextcloud's Notifications OCS API v2 beneath `/ocs/v2.php`.
16///
17/// See the official [Nextcloud Notifications API v2 documentation](https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md).
18pub const NOTIFICATIONS_API_PATH: &str = "apps/notifications/api/v2/notifications";
19
20/// Header that marks a request as an OCS API call. Nextcloud rejects requests without it.
21const OCS_REQUEST_HEADER: &str = "OCS-APIRequest";
22/// Content negotiation header to request JSON responses.
23const ACCEPT_HEADER: &str = "Accept";
24const ACCEPT_JSON: &str = "application/json";
25
26/// Client for interacting with Nextcloud's Notifications API (v2).
27///
28/// Executes calls through any [`Transport`] and applies credentials via an [`AuthProvider`].
29/// Both are held by reference, allowing the client to be freely cloned or borrowed across
30/// tasks without runtime dispatch or connection pooling overhead.
31///
32/// All endpoints interact with `/ocs/v2.php/apps/notifications/api/v2/notifications`.
33///
34/// See the [Nextcloud Notifications API v2 documentation](https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md)
35/// and [Nextcloud Developer Manual OCS Overview](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#notifications).
36#[derive(Clone, Copy, Debug)]
37pub struct NotificationClient<'a, T: Transport, A: AuthProvider> {
38    transport: &'a T,
39    auth: &'a A,
40}
41
42impl<'a, T: Transport, A: AuthProvider> NotificationClient<'a, T, A> {
43    /// Binds a notification client to a transport and credential provider.
44    pub const fn new(transport: &'a T, auth: &'a A) -> Self {
45        Self { transport, auth }
46    }
47
48    /// The transport this client sends requests through.
49    pub const fn transport(&self) -> &'a T {
50        self.transport
51    }
52
53    /// The credential provider applied to every call.
54    pub const fn auth(&self) -> &'a A {
55        self.auth
56    }
57
58    /// Lists all notifications for the authenticated user.
59    ///
60    /// Sends `GET /ocs/v2.php/apps/notifications/api/v2/notifications`.
61    ///
62    /// Returns an empty list when no notifications exist, or when the server responds
63    /// with `204 No Content` indicating polling should be reduced.
64    ///
65    /// See [Getting the notifications of a user](https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md#getting-the-notifications-of-a-user).
66    pub async fn list(&self) -> Result<Vec<Notification>, Error> {
67        tracing::debug!("Listing user notifications");
68        let api_path = ApiPath::ocs(NOTIFICATIONS_API_PATH)?;
69        let headers = self.authenticated_headers();
70        let request = Request::get(api_path.clone()).with_headers(headers);
71
72        let response = self.transport.execute(request).await?;
73        self.decode_list(api_path, response)
74    }
75
76    /// Retrieves a single notification by its `notification_id`.
77    ///
78    /// Sends `GET /ocs/v2.php/apps/notifications/api/v2/notifications/{id}`.
79    ///
80    /// See [Get a single notification for a user](https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md#get-a-single-notification-for-a-user).
81    pub async fn get(&self, notification_id: u64) -> Result<Notification, Error> {
82        tracing::debug!(notification_id, "Fetching notification details");
83        let api_path = ApiPath::ocs(format!("{NOTIFICATIONS_API_PATH}/{notification_id}"))?;
84        let headers = self.authenticated_headers();
85        let request = Request::get(api_path.clone()).with_headers(headers);
86
87        let response = self.transport.execute(request).await?;
88        self.decode_single(api_path, response)
89    }
90
91    /// Deletes/dismisses a single notification by its `notification_id`.
92    ///
93    /// Sends `DELETE /ocs/v2.php/apps/notifications/api/v2/notifications/{id}`.
94    ///
95    /// See [Deleting a notification for a user](https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md#deleting-a-notification-for-a-user).
96    pub async fn delete(&self, notification_id: u64) -> Result<(), Error> {
97        tracing::info!(notification_id, "Deleting notification");
98        let api_path = ApiPath::ocs(format!("{NOTIFICATIONS_API_PATH}/{notification_id}"))?;
99        let headers = self.authenticated_headers();
100        let request = Request::new(HttpMethod::Delete, api_path.clone()).with_headers(headers);
101
102        let response = self.transport.execute(request).await?;
103        self.decode_empty(HttpMethod::Delete, api_path, response)
104    }
105
106    /// Deletes/dismisses all notifications for the authenticated user.
107    ///
108    /// Sends `DELETE /ocs/v2.php/apps/notifications/api/v2/notifications`.
109    ///
110    /// See [Deleting all notifications for a user](https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md#deleting-all-notifications-for-a-user).
111    pub async fn delete_all(&self) -> Result<(), Error> {
112        tracing::info!("Deleting all user notifications");
113        let api_path = ApiPath::ocs(NOTIFICATIONS_API_PATH)?;
114        let headers = self.authenticated_headers();
115        let request = Request::new(HttpMethod::Delete, api_path.clone()).with_headers(headers);
116
117        let response = self.transport.execute(request).await?;
118        self.decode_empty(HttpMethod::Delete, api_path, response)
119    }
120
121    fn authenticated_headers(&self) -> Headers {
122        let mut headers = Headers::new();
123        headers.set(OCS_REQUEST_HEADER, "true");
124        headers.set(ACCEPT_HEADER, ACCEPT_JSON);
125        self.auth.authenticate(&mut headers);
126        headers
127    }
128
129    fn decode_list(&self, path: ApiPath, response: Response) -> Result<Vec<Notification>, Error> {
130        if response.status() == StatusCode::new(204) {
131            return Ok(Vec::new());
132        }
133
134        if !response.status().is_success() {
135            return Err(Error::Status(StatusError::new(
136                HttpMethod::Get.as_str(),
137                path,
138                response.status(),
139                response.body(),
140            )));
141        }
142
143        if response.body().is_empty() {
144            return Ok(Vec::new());
145        }
146
147        let envelope: OcsEnvelope<Option<Vec<Notification>>> =
148            serde_json::from_slice(response.body())
149                .map_err(|cause| {
150                    DecodeError::new(
151                        std::any::type_name::<OcsEnvelope<Vec<Notification>>>(),
152                        cause,
153                    )
154                })
155                .map_err(Error::Decode)?;
156
157        if envelope.meta().verdict() == Some(OcsStatus::Failure) {
158            return Err(Error::Status(StatusError::new(
159                HttpMethod::Get.as_str(),
160                path,
161                response.status(),
162                response.body(),
163            )));
164        }
165
166        Ok(envelope.into_data().unwrap_or_default())
167    }
168
169    fn decode_single(&self, path: ApiPath, response: Response) -> Result<Notification, Error> {
170        if !response.status().is_success() {
171            return Err(Error::Status(StatusError::new(
172                HttpMethod::Get.as_str(),
173                path,
174                response.status(),
175                response.body(),
176            )));
177        }
178
179        let envelope: OcsEnvelope<NotificationOrList> = serde_json::from_slice(response.body())
180            .map_err(|cause| {
181                DecodeError::new(std::any::type_name::<OcsEnvelope<Notification>>(), cause)
182            })
183            .map_err(Error::Decode)?;
184
185        if envelope.meta().verdict() == Some(OcsStatus::Failure) {
186            return Err(Error::Status(StatusError::new(
187                HttpMethod::Get.as_str(),
188                path,
189                response.status(),
190                response.body(),
191            )));
192        }
193
194        envelope.into_data().into_notification()
195    }
196
197    fn decode_empty(
198        &self,
199        method: HttpMethod,
200        path: ApiPath,
201        response: Response,
202    ) -> Result<(), Error> {
203        if !response.status().is_success() {
204            return Err(Error::Status(StatusError::new(
205                method.as_str(),
206                path,
207                response.status(),
208                response.body(),
209            )));
210        }
211
212        if response.body().is_empty() {
213            return Ok(());
214        }
215
216        let envelope: OcsEnvelope<serde_json::Value> = serde_json::from_slice(response.body())
217            .map_err(|cause| {
218                DecodeError::new(
219                    std::any::type_name::<OcsEnvelope<serde_json::Value>>(),
220                    cause,
221                )
222            })
223            .map_err(Error::Decode)?;
224
225        if envelope.meta().verdict() == Some(OcsStatus::Failure) {
226            return Err(Error::Status(StatusError::new(
227                method.as_str(),
228                path,
229                response.status(),
230                response.body(),
231            )));
232        }
233
234        Ok(())
235    }
236}
237
238/// Helper to decode responses where `data` may be either a single notification
239/// object or a single-item array.
240#[derive(Deserialize)]
241#[serde(untagged)]
242enum NotificationOrList {
243    Single(Notification),
244    List(Vec<Notification>),
245}
246
247impl NotificationOrList {
248    fn into_notification(self) -> Result<Notification, Error> {
249        match self {
250            Self::Single(notification) => Ok(notification),
251            Self::List(mut list) => list.pop().ok_or_else(|| {
252                Error::Decode(DecodeError::new(
253                    std::any::type_name::<Notification>(),
254                    std::io::Error::new(
255                        std::io::ErrorKind::NotFound,
256                        "notification response list was empty",
257                    ),
258                ))
259            }),
260        }
261    }
262}