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