redmine_api/api/
groups.rs

1//! Groups Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_Groups)
4//!
5//! - [x] all groups endpoint
6//! - [x] specific group endpoint
7//! - [x] create group endpoint
8//! - [x] update group endpoint
9//! - [x] delete group endpoint
10//! - [x] add user to group endpoint
11//! - [x] remove user from group endpoint
12
13use derive_builder::Builder;
14use reqwest::Method;
15use std::borrow::Cow;
16
17use crate::api::project_memberships::GroupProjectMembership;
18use crate::api::users::UserEssentials;
19use crate::api::{Endpoint, NoPagination, QueryParams, ReturnsJsonResponse};
20use serde::Serialize;
21
22/// a minimal type for Redmine groups used in lists of groups included in
23/// other Redmine objects
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25pub struct GroupEssentials {
26    /// numeric id
27    pub id: u64,
28    /// display name
29    pub name: String,
30}
31
32impl From<Group> for GroupEssentials {
33    fn from(v: Group) -> Self {
34        GroupEssentials {
35            id: v.id,
36            name: v.name,
37        }
38    }
39}
40
41impl From<&Group> for GroupEssentials {
42    fn from(v: &Group) -> Self {
43        GroupEssentials {
44            id: v.id,
45            name: v.name.to_owned(),
46        }
47    }
48}
49
50/// a type for groups to use as an API return type
51///
52/// alternatively you can use your own type limited to the fields you need
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
54pub struct Group {
55    /// numeric id
56    pub id: u64,
57    /// display name
58    pub name: String,
59    /// users (only with include parameter)
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub users: Option<Vec<UserEssentials>>,
62    /// project memberships (only with include parameter)
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub memberships: Option<Vec<GroupProjectMembership>>,
65}
66
67/// The endpoint for all Redmine groups
68#[derive(Debug, Clone, Builder)]
69#[builder(setter(strip_option))]
70pub struct ListGroups {}
71
72impl ReturnsJsonResponse for ListGroups {}
73impl NoPagination for ListGroups {}
74
75impl ListGroups {
76    /// Create a builder for the endpoint.
77    #[must_use]
78    pub fn builder() -> ListGroupsBuilder {
79        ListGroupsBuilder::default()
80    }
81}
82
83impl Endpoint for ListGroups {
84    fn method(&self) -> Method {
85        Method::GET
86    }
87
88    fn endpoint(&self) -> Cow<'static, str> {
89        "groups.json".into()
90    }
91}
92
93/// The types of associated data which can be fetched along with a group
94#[derive(Debug, Clone)]
95pub enum GroupInclude {
96    /// The group members
97    Users,
98    /// The project memberships for this group
99    Memberships,
100}
101
102impl std::fmt::Display for GroupInclude {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            Self::Users => {
106                write!(f, "users")
107            }
108            Self::Memberships => {
109                write!(f, "memberships")
110            }
111        }
112    }
113}
114
115/// The endpoint for a specific Redmine group
116#[derive(Debug, Clone, Builder)]
117#[builder(setter(strip_option))]
118pub struct GetGroup {
119    /// id of the group
120    id: u64,
121    /// associated data to include
122    #[builder(default)]
123    include: Option<Vec<GroupInclude>>,
124}
125
126impl ReturnsJsonResponse for GetGroup {}
127impl NoPagination for GetGroup {}
128
129impl GetGroup {
130    /// Create a builder for the endpoint.
131    #[must_use]
132    pub fn builder() -> GetGroupBuilder {
133        GetGroupBuilder::default()
134    }
135}
136
137impl Endpoint for GetGroup {
138    fn method(&self) -> Method {
139        Method::GET
140    }
141
142    fn endpoint(&self) -> Cow<'static, str> {
143        format!("groups/{}.json", &self.id).into()
144    }
145
146    fn parameters(&self) -> QueryParams {
147        let mut params = QueryParams::default();
148        params.push_opt("include", self.include.as_ref());
149        params
150    }
151}
152
153/// The endpoint to create a Redmine group
154#[derive(Debug, Clone, Builder, Serialize)]
155#[builder(setter(strip_option))]
156pub struct CreateGroup<'a> {
157    /// name of the group
158    #[builder(setter(into))]
159    name: Cow<'a, str>,
160    /// user ids of users to put in the group initially
161    #[builder(default)]
162    user_ids: Option<Vec<u64>>,
163}
164
165impl ReturnsJsonResponse for CreateGroup<'_> {}
166impl NoPagination for CreateGroup<'_> {}
167
168impl<'a> CreateGroup<'a> {
169    /// Create a builder for the endpoint.
170    #[must_use]
171    pub fn builder() -> CreateGroupBuilder<'a> {
172        CreateGroupBuilder::default()
173    }
174}
175
176impl Endpoint for CreateGroup<'_> {
177    fn method(&self) -> Method {
178        Method::POST
179    }
180
181    fn endpoint(&self) -> Cow<'static, str> {
182        "groups.json".into()
183    }
184
185    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
186        Ok(Some((
187            "application/json",
188            serde_json::to_vec(&GroupWrapper::<CreateGroup> {
189                group: (*self).to_owned(),
190            })?,
191        )))
192    }
193}
194
195/// The endpoint to update an existing Redmine group
196#[derive(Debug, Clone, Builder, Serialize)]
197#[builder(setter(strip_option))]
198pub struct UpdateGroup<'a> {
199    /// id of the group to update
200    #[serde(skip_serializing)]
201    id: u64,
202    /// name of the group
203    #[builder(setter(into))]
204    name: Cow<'a, str>,
205    /// user ids of the group members
206    #[builder(default)]
207    user_ids: Option<Vec<u64>>,
208}
209
210impl<'a> UpdateGroup<'a> {
211    /// Create a builder for the endpoint.
212    #[must_use]
213    pub fn builder() -> UpdateGroupBuilder<'a> {
214        UpdateGroupBuilder::default()
215    }
216}
217
218impl Endpoint for UpdateGroup<'_> {
219    fn method(&self) -> Method {
220        Method::PUT
221    }
222
223    fn endpoint(&self) -> Cow<'static, str> {
224        format!("groups/{}.json", self.id).into()
225    }
226
227    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
228        Ok(Some((
229            "application/json",
230            serde_json::to_vec(&GroupWrapper::<UpdateGroup> {
231                group: (*self).to_owned(),
232            })?,
233        )))
234    }
235}
236
237/// The endpoint to delete a Redmine group
238#[derive(Debug, Clone, Builder)]
239#[builder(setter(strip_option))]
240pub struct DeleteGroup {
241    /// Id of the group to delete
242    id: u64,
243}
244
245impl DeleteGroup {
246    /// Create a builder for the endpoint.
247    #[must_use]
248    pub fn builder() -> DeleteGroupBuilder {
249        DeleteGroupBuilder::default()
250    }
251}
252
253impl Endpoint for DeleteGroup {
254    fn method(&self) -> Method {
255        Method::DELETE
256    }
257
258    fn endpoint(&self) -> Cow<'static, str> {
259        format!("groups/{}.json", &self.id).into()
260    }
261}
262
263/// The endpoint to add a Redmine user to a Redmine group
264#[derive(Debug, Clone, Builder, Serialize)]
265#[builder(setter(strip_option))]
266pub struct AddUserToGroup {
267    /// Group Id to add the user to
268    #[serde(skip_serializing)]
269    group_id: u64,
270    /// User to add to this group
271    user_id: u64,
272}
273
274impl AddUserToGroup {
275    /// Create a builder for the endpoint.
276    #[must_use]
277    pub fn builder() -> AddUserToGroupBuilder {
278        AddUserToGroupBuilder::default()
279    }
280}
281
282impl Endpoint for AddUserToGroup {
283    fn method(&self) -> Method {
284        Method::POST
285    }
286
287    fn endpoint(&self) -> Cow<'static, str> {
288        format!("groups/{}/users.json", &self.group_id).into()
289    }
290
291    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
292        Ok(Some(("application/json", serde_json::to_vec(self)?)))
293    }
294}
295
296/// The endpoint to remove a Redmine user from a Redmine group
297#[derive(Debug, Clone, Builder)]
298#[builder(setter(strip_option))]
299pub struct RemoveUserFromGroup {
300    /// Group Id to remove the user from
301    group_id: u64,
302    /// User to remove from the group
303    user_id: u64,
304}
305
306impl RemoveUserFromGroup {
307    /// Create a builder for the endpoint.
308    #[must_use]
309    pub fn builder() -> RemoveUserFromGroupBuilder {
310        RemoveUserFromGroupBuilder::default()
311    }
312}
313
314impl Endpoint for RemoveUserFromGroup {
315    fn method(&self) -> Method {
316        Method::DELETE
317    }
318
319    fn endpoint(&self) -> Cow<'static, str> {
320        format!("groups/{}/users/{}.json", &self.group_id, &self.user_id).into()
321    }
322}
323
324/// helper struct for outer layers with a groups field holding the inner data
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
326pub struct GroupsWrapper<T> {
327    /// to parse JSON with groups key
328    pub groups: Vec<T>,
329}
330
331/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
332/// helper struct for outer layers with a group field holding the inner data
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
334pub struct GroupWrapper<T> {
335    /// to parse JSON with group key
336    pub group: T,
337}
338
339#[cfg(test)]
340pub(crate) mod test {
341    use super::*;
342    use crate::api::test_helpers::with_group;
343    use pretty_assertions::assert_eq;
344    use std::error::Error;
345    use tokio::sync::RwLock;
346    use tracing_test::traced_test;
347
348    /// needed so we do not get 404s when listing while
349    /// creating/deleting or creating/updating/deleting
350    pub static GROUP_LOCK: RwLock<()> = RwLock::const_new(());
351
352    #[traced_test]
353    #[test]
354    fn test_list_groups_no_pagination() -> Result<(), Box<dyn Error>> {
355        let _r_groups = GROUP_LOCK.read();
356        dotenvy::dotenv()?;
357        let redmine = crate::api::Redmine::from_env()?;
358        let endpoint = ListGroups::builder().build()?;
359        redmine.json_response_body::<_, GroupsWrapper<Group>>(&endpoint)?;
360        Ok(())
361    }
362
363    #[traced_test]
364    #[test]
365    fn test_get_group() -> Result<(), Box<dyn Error>> {
366        let _r_groups = GROUP_LOCK.read();
367        dotenvy::dotenv()?;
368        let redmine = crate::api::Redmine::from_env()?;
369        let endpoint = GetGroup::builder().id(338).build()?;
370        redmine.json_response_body::<_, GroupWrapper<Group>>(&endpoint)?;
371        Ok(())
372    }
373
374    #[function_name::named]
375    #[traced_test]
376    #[test]
377    fn test_create_group() -> Result<(), Box<dyn Error>> {
378        let name = format!("unittest_{}", function_name!());
379        with_group(&name, |_, _, _| Ok(()))?;
380        Ok(())
381    }
382
383    #[function_name::named]
384    #[traced_test]
385    #[test]
386    fn test_update_project() -> Result<(), Box<dyn Error>> {
387        let name = format!("unittest_{}", function_name!());
388        with_group(&name, |redmine, id, _name| {
389            let update_endpoint = super::UpdateGroup::builder()
390                .id(id)
391                .name("unittest_rename_test")
392                .build()?;
393            redmine.ignore_response_body::<_>(&update_endpoint)?;
394            Ok(())
395        })?;
396        Ok(())
397    }
398
399    /// this tests if any of the results contain a field we are not deserializing
400    ///
401    /// this will only catch fields we missed if they are part of the response but
402    /// it is better than nothing
403    #[traced_test]
404    #[test]
405    fn test_completeness_group_type() -> Result<(), Box<dyn Error>> {
406        let _r_groups = GROUP_LOCK.read();
407        dotenvy::dotenv()?;
408        let redmine = crate::api::Redmine::from_env()?;
409        let endpoint = ListGroups::builder().build()?;
410        let GroupsWrapper { groups: values } =
411            redmine.json_response_body::<_, GroupsWrapper<serde_json::Value>>(&endpoint)?;
412        for value in values {
413            let o: Group = serde_json::from_value(value.clone())?;
414            let reserialized = serde_json::to_value(o)?;
415            assert_eq!(value, reserialized);
416        }
417        Ok(())
418    }
419
420    /// this tests if any of the results contain a field we are not deserializing
421    ///
422    /// this will only catch fields we missed if they are part of the response but
423    /// it is better than nothing
424    ///
425    /// this version of the test will load all groups and the individual
426    /// groups for each via GetGroup
427    #[traced_test]
428    #[test]
429    fn test_completeness_group_type_all_group_details() -> Result<(), Box<dyn Error>> {
430        let _r_groups = GROUP_LOCK.read();
431        dotenvy::dotenv()?;
432        let redmine = crate::api::Redmine::from_env()?;
433        let endpoint = ListGroups::builder().build()?;
434        let GroupsWrapper { groups } =
435            redmine.json_response_body::<_, GroupsWrapper<Group>>(&endpoint)?;
436        for group in groups {
437            let get_endpoint = GetGroup::builder()
438                .id(group.id)
439                .include(vec![GroupInclude::Users, GroupInclude::Memberships])
440                .build()?;
441            let GroupWrapper { group: value } =
442                redmine.json_response_body::<_, GroupWrapper<serde_json::Value>>(&get_endpoint)?;
443            let o: Group = serde_json::from_value(value.clone())?;
444            let reserialized = serde_json::to_value(o)?;
445            assert_eq!(value, reserialized);
446        }
447        Ok(())
448    }
449}