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