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