redmine_api/api/
project_memberships.rs

1//! Project Memberships Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_Memberships)
4//!
5//! - [x] list of project memberships endpoint
6//! - [x] get specific membership endpoint
7//! - [x] create project membership endpoint
8//! - [x] update specific membership endpoint
9//! - [x] delete specific membership endpoint
10
11use derive_builder::Builder;
12use reqwest::Method;
13use std::borrow::Cow;
14
15use crate::api::groups::GroupEssentials;
16use crate::api::projects::ProjectEssentials;
17use crate::api::roles::RoleEssentials;
18use crate::api::users::UserEssentials;
19use crate::api::{Endpoint, Pageable, ReturnsJsonResponse};
20use serde::Serialize;
21
22/// a minimal type for project memberships to be used in lists of memberships
23/// returned as part of the user
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
25pub struct UserProjectMembership {
26    /// numeric id
27    pub id: u64,
28    /// the project
29    pub project: ProjectEssentials,
30    /// the roles the user has in the project
31    pub roles: Vec<RoleEssentials>,
32}
33
34/// a minimal type for project memberships to be used in lists of memberships
35/// returned as part of the group
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
37pub struct GroupProjectMembership {
38    /// numeric id
39    pub id: u64,
40    /// the project
41    pub project: ProjectEssentials,
42    /// the roles the group has in the project
43    pub roles: Vec<RoleEssentials>,
44}
45
46/// a type for project memberships to use as an API return type
47///
48/// alternatively you can use your own type limited to the fields you need
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
50pub struct ProjectMembership {
51    /// numeric id
52    pub id: u64,
53    /// the project
54    pub project: ProjectEssentials,
55    /// the user (project member), optional because alternatively we could have a group
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub user: Option<UserEssentials>,
58    /// the group (project member), optional because alternatively we could have a user
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub group: Option<GroupEssentials>,
61    /// the roles the user or group has in the project
62    pub roles: Vec<RoleEssentials>,
63}
64
65/// The endpoint for all memberships in a Redmine project
66#[derive(Debug, Clone, Builder)]
67#[builder(setter(strip_option))]
68pub struct ListProjectMemberships<'a> {
69    /// project id or name as it appears in the URL
70    #[builder(setter(into))]
71    project_id_or_name: Cow<'a, str>,
72}
73
74impl ReturnsJsonResponse for ListProjectMemberships<'_> {}
75impl Pageable for ListProjectMemberships<'_> {
76    fn response_wrapper_key(&self) -> String {
77        "memberships".to_string()
78    }
79}
80
81impl<'a> ListProjectMemberships<'a> {
82    /// Create a builder for the endpoint.
83    #[must_use]
84    pub fn builder() -> ListProjectMembershipsBuilder<'a> {
85        ListProjectMembershipsBuilder::default()
86    }
87}
88
89impl Endpoint for ListProjectMemberships<'_> {
90    fn method(&self) -> Method {
91        Method::GET
92    }
93
94    fn endpoint(&self) -> Cow<'static, str> {
95        format!("projects/{}/memberships.json", self.project_id_or_name).into()
96    }
97}
98
99/// The endpoint for a specific Redmine project membership
100#[derive(Debug, Clone, Builder)]
101#[builder(setter(strip_option))]
102pub struct GetProjectMembership {
103    /// id of the project membership to retrieve
104    id: u64,
105}
106
107impl ReturnsJsonResponse for GetProjectMembership {}
108
109impl GetProjectMembership {
110    /// Create a builder for the endpoint.
111    #[must_use]
112    pub fn builder() -> GetProjectMembershipBuilder {
113        GetProjectMembershipBuilder::default()
114    }
115}
116
117impl Endpoint for GetProjectMembership {
118    fn method(&self) -> Method {
119        Method::GET
120    }
121
122    fn endpoint(&self) -> Cow<'static, str> {
123        format!("memberships/{}.json", &self.id).into()
124    }
125}
126
127/// The endpoint to create a Redmine project membership (add a user or group to a project)
128#[serde_with::skip_serializing_none]
129#[derive(Debug, Clone, Builder, Serialize)]
130#[builder(setter(strip_option))]
131pub struct CreateProjectMembership<'a> {
132    /// project id or name as it appears in the URL
133    #[builder(setter(into))]
134    #[serde(skip_serializing)]
135    project_id_or_name: Cow<'a, str>,
136    /// user to add to the project
137    user_id: u64,
138    /// roles for the user to add to the project
139    role_ids: Vec<u64>,
140}
141
142impl ReturnsJsonResponse for CreateProjectMembership<'_> {}
143
144impl<'a> CreateProjectMembership<'a> {
145    /// Create a builder for the endpoint.
146    #[must_use]
147    pub fn builder() -> CreateProjectMembershipBuilder<'a> {
148        CreateProjectMembershipBuilder::default()
149    }
150}
151
152impl Endpoint for CreateProjectMembership<'_> {
153    fn method(&self) -> Method {
154        Method::POST
155    }
156
157    fn endpoint(&self) -> Cow<'static, str> {
158        format!("projects/{}/memberships.json", self.project_id_or_name).into()
159    }
160
161    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
162        Ok(Some((
163            "application/json",
164            serde_json::to_vec(&MembershipWrapper::<CreateProjectMembership> {
165                membership: (*self).to_owned(),
166            })?,
167        )))
168    }
169}
170
171/// The endpoint to update an existing Redmine project membership (change roles)
172#[serde_with::skip_serializing_none]
173#[derive(Debug, Clone, Builder, Serialize)]
174#[builder(setter(strip_option))]
175pub struct UpdateProjectMembership {
176    /// id of the project membership to update
177    #[serde(skip_serializing)]
178    id: u64,
179    /// roles for the user to add to the project
180    role_ids: Vec<u64>,
181}
182
183impl UpdateProjectMembership {
184    /// Create a builder for the endpoint.
185    #[must_use]
186    pub fn builder() -> UpdateProjectMembershipBuilder {
187        UpdateProjectMembershipBuilder::default()
188    }
189}
190
191impl Endpoint for UpdateProjectMembership {
192    fn method(&self) -> Method {
193        Method::PUT
194    }
195
196    fn endpoint(&self) -> Cow<'static, str> {
197        format!("memberships/{}.json", self.id).into()
198    }
199
200    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
201        Ok(Some((
202            "application/json",
203            serde_json::to_vec(&MembershipWrapper::<UpdateProjectMembership> {
204                membership: (*self).to_owned(),
205            })?,
206        )))
207    }
208}
209
210/// The endpoint to delete a membership in a Redmine project
211#[derive(Debug, Clone, Builder)]
212#[builder(setter(strip_option))]
213pub struct DeleteProjectMembership {
214    /// id of the project membership to delete
215    id: u64,
216}
217
218impl DeleteProjectMembership {
219    /// Create a builder for the endpoint.
220    #[must_use]
221    pub fn builder() -> DeleteProjectMembershipBuilder {
222        DeleteProjectMembershipBuilder::default()
223    }
224}
225
226impl Endpoint for DeleteProjectMembership {
227    fn method(&self) -> Method {
228        Method::DELETE
229    }
230
231    fn endpoint(&self) -> Cow<'static, str> {
232        format!("memberships/{}.json", &self.id).into()
233    }
234}
235
236/// helper struct for outer layers with a memberships field holding the inner data
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
238pub struct MembershipsWrapper<T> {
239    /// to parse JSON with memberships key
240    pub memberships: Vec<T>,
241}
242
243/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
244/// helper struct for outer layers with a membership field holding the inner data
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
246pub struct MembershipWrapper<T> {
247    /// to parse JSON with membership key
248    pub membership: T,
249}
250
251#[cfg(test)]
252mod test {
253    use super::*;
254    use crate::api::test_helpers::with_project;
255    use pretty_assertions::assert_eq;
256    use std::error::Error;
257    use tokio::sync::RwLock;
258    use tracing_test::traced_test;
259
260    /// needed so we do not get 404s when listing while
261    /// creating/deleting or creating/updating/deleting
262    static PROJECT_MEMBERSHIP_LOCK: RwLock<()> = RwLock::const_new(());
263
264    #[traced_test]
265    #[test]
266    fn test_list_project_memberships_no_pagination() -> Result<(), Box<dyn Error>> {
267        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
268        dotenvy::dotenv()?;
269        let redmine = crate::api::Redmine::from_env()?;
270        let endpoint = ListProjectMemberships::builder()
271            .project_id_or_name("sandbox")
272            .build()?;
273        redmine.json_response_body::<_, MembershipsWrapper<ProjectMembership>>(&endpoint)?;
274        Ok(())
275    }
276
277    #[traced_test]
278    #[test]
279    fn test_list_project_memberships_first_page() -> Result<(), Box<dyn Error>> {
280        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
281        dotenvy::dotenv()?;
282        let redmine = crate::api::Redmine::from_env()?;
283        let endpoint = ListProjectMemberships::builder()
284            .project_id_or_name("sandbox")
285            .build()?;
286        redmine.json_response_body_page::<_, ProjectMembership>(&endpoint, 0, 25)?;
287        Ok(())
288    }
289
290    #[traced_test]
291    #[test]
292    fn test_list_project_memberships_all_pages() -> Result<(), Box<dyn Error>> {
293        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
294        dotenvy::dotenv()?;
295        let redmine = crate::api::Redmine::from_env()?;
296        let endpoint = ListProjectMemberships::builder()
297            .project_id_or_name("sandbox")
298            .build()?;
299        redmine.json_response_body_all_pages::<_, ProjectMembership>(&endpoint)?;
300        Ok(())
301    }
302
303    #[traced_test]
304    #[test]
305    fn test_get_project_membership() -> Result<(), Box<dyn Error>> {
306        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
307        dotenvy::dotenv()?;
308        let redmine = crate::api::Redmine::from_env()?;
309        let endpoint = GetProjectMembership::builder().id(238).build()?;
310        redmine.json_response_body::<_, MembershipWrapper<ProjectMembership>>(&endpoint)?;
311        Ok(())
312    }
313
314    #[function_name::named]
315    #[traced_test]
316    #[test]
317    fn test_create_project_membership() -> Result<(), Box<dyn Error>> {
318        let _w_project_memberships = PROJECT_MEMBERSHIP_LOCK.write();
319        let name = format!("unittest_{}", function_name!());
320        with_project(&name, |redmine, project_id, _| {
321            let create_endpoint = super::CreateProjectMembership::builder()
322                .project_id_or_name(project_id.to_string())
323                .user_id(1)
324                .role_ids(vec![8])
325                .build()?;
326            redmine
327                .json_response_body::<_, MembershipWrapper<ProjectMembership>>(&create_endpoint)?;
328            Ok(())
329        })?;
330        Ok(())
331    }
332
333    #[function_name::named]
334    #[traced_test]
335    #[test]
336    fn test_update_project_membership() -> Result<(), Box<dyn Error>> {
337        let _w_project_memberships = PROJECT_MEMBERSHIP_LOCK.write();
338        let name = format!("unittest_{}", function_name!());
339        with_project(&name, |redmine, project_id, _name| {
340            let create_endpoint = super::CreateProjectMembership::builder()
341                .project_id_or_name(project_id.to_string())
342                .user_id(1)
343                .role_ids(vec![8])
344                .build()?;
345            let MembershipWrapper { membership } = redmine
346                .json_response_body::<_, MembershipWrapper<ProjectMembership>>(&create_endpoint)?;
347            let update_endpoint = super::UpdateProjectMembership::builder()
348                .id(membership.id)
349                .role_ids(vec![9])
350                .build()?;
351            redmine.ignore_response_body::<_>(&update_endpoint)?;
352            Ok(())
353        })?;
354        Ok(())
355    }
356
357    /// this tests if any of the results contain a field we are not deserializing
358    ///
359    /// this will only catch fields we missed if they are part of the response but
360    /// it is better than nothing
361    #[traced_test]
362    #[test]
363    fn test_completeness_project_membership_type() -> Result<(), Box<dyn Error>> {
364        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
365        dotenvy::dotenv()?;
366        let redmine = crate::api::Redmine::from_env()?;
367        let endpoint = ListProjectMemberships::builder()
368            .project_id_or_name("sandbox")
369            .build()?;
370        let MembershipsWrapper {
371            memberships: values,
372        } = redmine.json_response_body::<_, MembershipsWrapper<serde_json::Value>>(&endpoint)?;
373        for value in values {
374            let o: ProjectMembership = serde_json::from_value(value.clone())?;
375            let reserialized = serde_json::to_value(o)?;
376            assert_eq!(value, reserialized);
377        }
378        Ok(())
379    }
380}