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        let api_path = ApiPath::ocs(NOTIFICATIONS_API_PATH)?;
68        let headers = self.authenticated_headers();
69        let request = Request::get(api_path.clone()).with_headers(headers);
70
71        let response = self.transport.execute(request).await?;
72        self.decode_list(api_path, response)
73    }
74
75    /// Retrieves a single notification by its `notification_id`.
76    ///
77    /// Sends `GET /ocs/v2.php/apps/notifications/api/v2/notifications/{id}`.
78    ///
79    /// 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).
80    pub async fn get(&self, notification_id: u64) -> Result<Notification, Error> {
81        let api_path = ApiPath::ocs(format!("{NOTIFICATIONS_API_PATH}/{notification_id}"))?;
82        let headers = self.authenticated_headers();
83        let request = Request::get(api_path.clone()).with_headers(headers);
84
85        let response = self.transport.execute(request).await?;
86        self.decode_single(api_path, response)
87    }
88
89    /// Deletes/dismisses a single notification by its `notification_id`.
90    ///
91    /// Sends `DELETE /ocs/v2.php/apps/notifications/api/v2/notifications/{id}`.
92    ///
93    /// 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).
94    pub async fn delete(&self, notification_id: u64) -> Result<(), Error> {
95        let api_path = ApiPath::ocs(format!("{NOTIFICATIONS_API_PATH}/{notification_id}"))?;
96        let headers = self.authenticated_headers();
97        let request = Request::new(HttpMethod::Delete, api_path.clone()).with_headers(headers);
98
99        let response = self.transport.execute(request).await?;
100        self.decode_empty(HttpMethod::Delete, api_path, response)
101    }
102
103    /// Deletes/dismisses all notifications for the authenticated user.
104    ///
105    /// Sends `DELETE /ocs/v2.php/apps/notifications/api/v2/notifications`.
106    ///
107    /// 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).
108    pub async fn delete_all(&self) -> Result<(), Error> {
109        let api_path = ApiPath::ocs(NOTIFICATIONS_API_PATH)?;
110        let headers = self.authenticated_headers();
111        let request = Request::new(HttpMethod::Delete, api_path.clone()).with_headers(headers);
112
113        let response = self.transport.execute(request).await?;
114        self.decode_empty(HttpMethod::Delete, api_path, response)
115    }
116
117    fn authenticated_headers(&self) -> Headers {
118        let mut headers = Headers::new();
119        headers.set(OCS_REQUEST_HEADER, "true");
120        headers.set(ACCEPT_HEADER, ACCEPT_JSON);
121        self.auth.authenticate(&mut headers);
122        headers
123    }
124
125    fn decode_list(&self, path: ApiPath, response: Response) -> Result<Vec<Notification>, Error> {
126        if response.status() == StatusCode::new(204) {
127            return Ok(Vec::new());
128        }
129
130        if !response.status().is_success() {
131            return Err(Error::Status(StatusError::new(
132                HttpMethod::Get.as_str(),
133                path,
134                response.status(),
135                response.body(),
136            )));
137        }
138
139        if response.body().is_empty() {
140            return Ok(Vec::new());
141        }
142
143        let envelope: OcsEnvelope<Option<Vec<Notification>>> =
144            serde_json::from_slice(response.body())
145                .map_err(|cause| {
146                    DecodeError::new(
147                        std::any::type_name::<OcsEnvelope<Vec<Notification>>>(),
148                        cause,
149                    )
150                })
151                .map_err(Error::Decode)?;
152
153        if envelope.meta().verdict() == Some(OcsStatus::Failure) {
154            return Err(Error::Status(StatusError::new(
155                HttpMethod::Get.as_str(),
156                path,
157                response.status(),
158                response.body(),
159            )));
160        }
161
162        Ok(envelope.into_data().unwrap_or_default())
163    }
164
165    fn decode_single(&self, path: ApiPath, response: Response) -> Result<Notification, Error> {
166        if !response.status().is_success() {
167            return Err(Error::Status(StatusError::new(
168                HttpMethod::Get.as_str(),
169                path,
170                response.status(),
171                response.body(),
172            )));
173        }
174
175        let envelope: OcsEnvelope<NotificationOrList> = serde_json::from_slice(response.body())
176            .map_err(|cause| {
177                DecodeError::new(std::any::type_name::<OcsEnvelope<Notification>>(), cause)
178            })
179            .map_err(Error::Decode)?;
180
181        if envelope.meta().verdict() == Some(OcsStatus::Failure) {
182            return Err(Error::Status(StatusError::new(
183                HttpMethod::Get.as_str(),
184                path,
185                response.status(),
186                response.body(),
187            )));
188        }
189
190        envelope.into_data().into_notification()
191    }
192
193    fn decode_empty(
194        &self,
195        method: HttpMethod,
196        path: ApiPath,
197        response: Response,
198    ) -> Result<(), Error> {
199        if !response.status().is_success() {
200            return Err(Error::Status(StatusError::new(
201                method.as_str(),
202                path,
203                response.status(),
204                response.body(),
205            )));
206        }
207
208        if response.body().is_empty() {
209            return Ok(());
210        }
211
212        let envelope: OcsEnvelope<serde_json::Value> = serde_json::from_slice(response.body())
213            .map_err(|cause| {
214                DecodeError::new(
215                    std::any::type_name::<OcsEnvelope<serde_json::Value>>(),
216                    cause,
217                )
218            })
219            .map_err(Error::Decode)?;
220
221        if envelope.meta().verdict() == Some(OcsStatus::Failure) {
222            return Err(Error::Status(StatusError::new(
223                method.as_str(),
224                path,
225                response.status(),
226                response.body(),
227            )));
228        }
229
230        Ok(())
231    }
232}
233
234/// Helper to decode responses where `data` may be either a single notification
235/// object or a single-item array.
236#[derive(Deserialize)]
237#[serde(untagged)]
238enum NotificationOrList {
239    Single(Notification),
240    List(Vec<Notification>),
241}
242
243impl NotificationOrList {
244    fn into_notification(self) -> Result<Notification, Error> {
245        match self {
246            Self::Single(notification) => Ok(notification),
247            Self::List(mut list) => list.pop().ok_or_else(|| {
248                Error::Decode(DecodeError::new(
249                    std::any::type_name::<Notification>(),
250                    std::io::Error::new(
251                        std::io::ErrorKind::NotFound,
252                        "notification response list was empty",
253                    ),
254                ))
255            }),
256        }
257    }
258}