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            reqwest::blocking::Client::builder()
359                .use_rustls_tls()
360                .build()?,
361        )?;
362        let endpoint = ListGroups::builder().build()?;
363        redmine.json_response_body::<_, GroupsWrapper<Group>>(&endpoint)?;
364        Ok(())
365    }
366
367    #[traced_test]
368    #[test]
369    fn test_get_group() -> Result<(), Box<dyn Error>> {
370        let _r_groups = GROUP_LOCK.read();
371        dotenvy::dotenv()?;
372        let redmine = crate::api::Redmine::from_env(
373            reqwest::blocking::Client::builder()
374                .use_rustls_tls()
375                .build()?,
376        )?;
377        let endpoint = GetGroup::builder().id(338).build()?;
378        redmine.json_response_body::<_, GroupWrapper<Group>>(&endpoint)?;
379        Ok(())
380    }
381
382    #[function_name::named]
383    #[traced_test]
384    #[test]
385    fn test_create_group() -> Result<(), Box<dyn Error>> {
386        let name = format!("unittest_{}", function_name!());
387        with_group(&name, |_, _, _| Ok(()))?;
388        Ok(())
389    }
390
391    #[function_name::named]
392    #[traced_test]
393    #[test]
394    fn test_update_project() -> Result<(), Box<dyn Error>> {
395        let name = format!("unittest_{}", function_name!());
396        with_group(&name, |redmine, id, _name| {
397            let update_endpoint = super::UpdateGroup::builder()
398                .id(id)
399                .name("unittest_rename_test")
400                .build()?;
401            redmine.ignore_response_body::<_>(&update_endpoint)?;
402            Ok(())
403        })?;
404        Ok(())
405    }
406
407    /// this tests if any of the results contain a field we are not deserializing
408    ///
409    /// this will only catch fields we missed if they are part of the response but
410    /// it is better than nothing
411    #[traced_test]
412    #[test]
413    fn test_completeness_group_type() -> Result<(), Box<dyn Error>> {
414        let _r_groups = GROUP_LOCK.read();
415        dotenvy::dotenv()?;
416        let redmine = crate::api::Redmine::from_env(
417            reqwest::blocking::Client::builder()
418                .use_rustls_tls()
419                .build()?,
420        )?;
421        let endpoint = ListGroups::builder().build()?;
422        let GroupsWrapper { groups: values } =
423            redmine.json_response_body::<_, GroupsWrapper<serde_json::Value>>(&endpoint)?;
424        for value in values {
425            let o: Group = serde_json::from_value(value.clone())?;
426            let reserialized = serde_json::to_value(o)?;
427            assert_eq!(value, reserialized);
428        }
429        Ok(())
430    }
431
432    /// this tests if any of the results contain a field we are not deserializing
433    ///
434    /// this will only catch fields we missed if they are part of the response but
435    /// it is better than nothing
436    ///
437    /// this version of the test will load all groups and the individual
438    /// groups for each via GetGroup
439    #[traced_test]
440    #[test]
441    fn test_completeness_group_type_all_group_details() -> Result<(), Box<dyn Error>> {
442        let _r_groups = GROUP_LOCK.read();
443        dotenvy::dotenv()?;
444        let redmine = crate::api::Redmine::from_env(
445            reqwest::blocking::Client::builder()
446                .use_rustls_tls()
447                .build()?,
448        )?;
449        let endpoint = ListGroups::builder().build()?;
450        let GroupsWrapper { groups } =
451            redmine.json_response_body::<_, GroupsWrapper<Group>>(&endpoint)?;
452        for group in groups {
453            let get_endpoint = GetGroup::builder()
454                .id(group.id)
455                .include(vec![GroupInclude::Users, GroupInclude::Memberships])
456                .build()?;
457            let GroupWrapper { group: value } =
458                redmine.json_response_body::<_, GroupWrapper<serde_json::Value>>(&get_endpoint)?;
459            let o: Group = serde_json::from_value(value.clone())?;
460            let reserialized = serde_json::to_value(o)?;
461            assert_eq!(value, reserialized);
462        }
463        Ok(())
464    }
465}