Skip to main content

nextcloud_activity/
client.rs

1//! Nextcloud Activity API client.
2//!
3//! Official documentation:
4//! - [Activity API](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html)
5//! - [Developer manual source](https://github.com/nextcloud/documentation/blob/master/developer_manual/client_apis/activity-api.rst)
6//! - [Activity endpoint specification](https://github.com/nextcloud/activity/blob/master/docs/endpoint-v2.md)
7
8use nextcloud_ocs::OcsClient;
9use nextcloud_sdk_core::{ApiPath, AuthProvider, Error, Transport};
10
11use crate::activity::Activity;
12use crate::query::ActivityFilter;
13
14/// Relative path to Nextcloud's Activity API beneath `/ocs/v2.php`.
15const ACTIVITY_API_PATH: &str = "apps/activity/api/v2/activity";
16
17/// Client for interacting with Nextcloud's Activity API v2.
18///
19/// Wraps an [`OcsClient`] by reference and provides high-level typed calls for:
20/// - Listing activities matching filter criteria (`/ocs/v2.php/apps/activity/api/v2/activity`)
21/// - Querying activities for a specific object type and ID
22///
23/// See the [Nextcloud Activity API documentation](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html#endpoints)
24/// and [developer_manual source](https://github.com/nextcloud/documentation/blob/master/developer_manual/client_apis/activity-api.rst).
25#[derive(Clone, Copy, Debug)]
26pub struct ActivityClient<'a, T: Transport, A: AuthProvider> {
27    ocs: &'a OcsClient<T, A>,
28}
29
30impl<'a, T: Transport, A: AuthProvider> ActivityClient<'a, T, A> {
31    /// Binds an activity client to an underlying [`OcsClient`].
32    pub const fn new(ocs: &'a OcsClient<T, A>) -> Self {
33        Self { ocs }
34    }
35
36    /// Borrows the underlying [`OcsClient`].
37    pub const fn ocs(&self) -> &'a OcsClient<T, A> {
38        self.ocs
39    }
40
41    /// Lists activities matching the provided [`ActivityFilter`].
42    ///
43    /// Sends `GET /ocs/v2.php/apps/activity/api/v2/activity` with query parameters.
44    ///
45    /// See [Endpoints](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html#endpoints)
46    /// and [Parameters](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html#parameters).
47    pub async fn list(&self, filter: &ActivityFilter) -> Result<Vec<Activity>, Error> {
48        tracing::debug!("Listing activity stream");
49        let path = ApiPath::ocs(ACTIVITY_API_PATH)?;
50        self.ocs.get(path, filter.to_query()).await
51    }
52
53    /// Lists activities related to a specific object type and object ID.
54    ///
55    /// Helper that constructs an [`ActivityFilter`] with `object_type` and `object_id`
56    /// and invokes [`Self::list`].
57    ///
58    /// Sends `GET /ocs/v2.php/apps/activity/api/v2/activity?format=json&object_type=...&object_id=...`.
59    ///
60    /// See [Parameters](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html#parameters).
61    pub async fn list_for_object(
62        &self,
63        object_type: &str,
64        object_id: u64,
65    ) -> Result<Vec<Activity>, Error> {
66        let filter = ActivityFilter::new().object(object_type, object_id);
67        self.list(&filter).await
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use std::future::Future;
74    use std::sync::{Arc, Mutex};
75    use std::task::{Context, Poll, Waker};
76
77    use nextcloud_ocs::OcsClient;
78    use nextcloud_sdk_core::{
79        AuthProvider, Error, Headers, Request, Response, StatusCode, Transport,
80    };
81
82    use super::ActivityClient;
83    use crate::query::ActivityFilter;
84
85    fn block_on<F: Future>(future: F) -> F::Output {
86        let mut future = std::pin::pin!(future);
87        let mut context = Context::from_waker(Waker::noop());
88        loop {
89            match future.as_mut().poll(&mut context) {
90                Poll::Ready(value) => return value,
91                Poll::Pending => std::thread::yield_now(),
92            }
93        }
94    }
95
96    #[derive(Clone, Debug)]
97    struct FakeTransport {
98        response: Response,
99        seen: Arc<Mutex<Option<Request>>>,
100    }
101
102    impl FakeTransport {
103        fn new(status: u16, body: &str) -> Self {
104            Self {
105                response: Response::new(StatusCode::new(status), Headers::new(), body.as_bytes()),
106                seen: Arc::new(Mutex::new(None)),
107            }
108        }
109
110        fn request(&self) -> Option<Request> {
111            self.seen.lock().ok().and_then(|seen| seen.clone())
112        }
113    }
114
115    impl Transport for FakeTransport {
116        async fn execute(&self, request: Request) -> Result<Response, Error> {
117            if let Ok(mut seen) = self.seen.lock() {
118                *seen = Some(request);
119            }
120            Ok(self.response.clone())
121        }
122    }
123
124    #[derive(Clone, Debug)]
125    struct FakeAuth;
126
127    impl AuthProvider for FakeAuth {
128        fn authenticate(&self, headers: &mut Headers) {
129            headers.set("Authorization", "Bearer test-activity-token");
130        }
131    }
132
133    const ACTIVITY_LIST_RESPONSE: &str = r#"{
134        "ocs": {
135            "meta": {
136                "status": "ok",
137                "statuscode": 200,
138                "message": "OK"
139            },
140            "data": [
141                {
142                    "activity_id": 1,
143                    "datetime": "2026-10-24T12:00:00+00:00",
144                    "app": "files",
145                    "type": "file_created",
146                    "user": "alice",
147                    "subject": "alice created document.odt",
148                    "message": null,
149                    "link": "https://example.com/apps/files/?dir=/test",
150                    "object_type": "files",
151                    "object_id": 10,
152                    "object_name": "/test/document.odt"
153                },
154                {
155                    "activity_id": 2,
156                    "datetime": "2026-10-24T12:05:00+00:00",
157                    "app": "comments",
158                    "type": "comment_added",
159                    "user": "bob",
160                    "subject": "bob commented on document.odt",
161                    "message": "Looks great!",
162                    "link": null,
163                    "object_type": "files",
164                    "object_id": 10,
165                    "object_name": "/test/document.odt"
166                }
167            ]
168        }
169    }"#;
170
171    const EMPTY_LIST_RESPONSE: &str = r#"{
172        "ocs": {
173            "meta": {
174                "status": "ok",
175                "statuscode": 200,
176                "message": "OK"
177            },
178            "data": []
179        }
180    }"#;
181
182    const OCS_FAILURE_RESPONSE: &str = r#"{
183        "ocs": {
184            "meta": {
185                "status": "failure",
186                "statuscode": 997,
187                "message": "App not installed"
188            },
189            "data": []
190        }
191    }"#;
192
193    #[test]
194    fn list_fetches_and_decodes_activities() {
195        let transport = FakeTransport::new(200, ACTIVITY_LIST_RESPONSE);
196        let ocs = OcsClient::new(transport.clone(), FakeAuth);
197        let client = ActivityClient::new(&ocs);
198
199        let filter = ActivityFilter::new().since(0).limit(10).sort("desc");
200        let activities = block_on(client.list(&filter)).expect("list call succeeds");
201
202        assert_eq!(activities.len(), 2);
203        assert_eq!(activities[0].activity_id, 1);
204        assert_eq!(activities[0].app, "files");
205        assert_eq!(activities[0].activity_type, "file_created");
206        assert_eq!(activities[0].user, "alice");
207        assert_eq!(activities[0].subject, "alice created document.odt");
208        assert_eq!(activities[0].message, None);
209        assert_eq!(activities[0].object_id, Some(10));
210
211        assert_eq!(activities[1].activity_id, 2);
212        assert_eq!(activities[1].app, "comments");
213        assert_eq!(activities[1].activity_type, "comment_added");
214        assert_eq!(activities[1].user, "bob");
215        assert_eq!(activities[1].message, Some("Looks great!".to_string()));
216
217        let req = transport.request().expect("request was recorded");
218        assert_eq!(
219            req.path().as_str(),
220            "/ocs/v2.php/apps/activity/api/v2/activity?format=json&since=0&limit=10&sort=desc"
221        );
222        assert_eq!(req.headers().get("OCS-APIRequest"), Some("true"));
223        assert_eq!(req.headers().get("Accept"), Some("application/json"));
224        assert_eq!(
225            req.headers().get("Authorization"),
226            Some("Bearer test-activity-token")
227        );
228    }
229
230    #[test]
231    fn list_for_object_encodes_object_parameters() {
232        let transport = FakeTransport::new(200, ACTIVITY_LIST_RESPONSE);
233        let ocs = OcsClient::new(transport.clone(), FakeAuth);
234        let client = ActivityClient::new(&ocs);
235
236        let activities =
237            block_on(client.list_for_object("files", 10)).expect("list_for_object succeeds");
238        assert_eq!(activities.len(), 2);
239
240        let req = transport.request().expect("request was recorded");
241        assert_eq!(
242            req.path().as_str(),
243            "/ocs/v2.php/apps/activity/api/v2/activity?format=json&object_type=files&object_id=10"
244        );
245    }
246
247    #[test]
248    fn empty_activity_list_handled_gracefully() {
249        let transport = FakeTransport::new(200, EMPTY_LIST_RESPONSE);
250        let ocs = OcsClient::new(transport, FakeAuth);
251        let client = ActivityClient::new(&ocs);
252
253        let activities =
254            block_on(client.list(&ActivityFilter::new())).expect("empty list succeeds");
255        assert!(activities.is_empty());
256    }
257
258    #[test]
259    fn ocs_failure_returns_status_error() {
260        let transport = FakeTransport::new(200, OCS_FAILURE_RESPONSE);
261        let ocs = OcsClient::new(transport, FakeAuth);
262        let client = ActivityClient::new(&ocs);
263
264        let result = block_on(client.list(&ActivityFilter::new()));
265        assert!(result.is_err());
266        match result {
267            Err(Error::Status(err)) => {
268                assert_eq!(err.status(), StatusCode::new(200));
269            }
270            other => panic!("expected Error::Status, got {other:?}"),
271        }
272    }
273}