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, NoPagination, 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 {}
108impl NoPagination for GetProjectMembership {}
109
110impl GetProjectMembership {
111    /// Create a builder for the endpoint.
112    #[must_use]
113    pub fn builder() -> GetProjectMembershipBuilder {
114        GetProjectMembershipBuilder::default()
115    }
116}
117
118impl Endpoint for GetProjectMembership {
119    fn method(&self) -> Method {
120        Method::GET
121    }
122
123    fn endpoint(&self) -> Cow<'static, str> {
124        format!("memberships/{}.json", &self.id).into()
125    }
126}
127
128/// The endpoint to create a Redmine project membership (add a user or group to a project)
129#[serde_with::skip_serializing_none]
130#[derive(Debug, Clone, Builder, Serialize)]
131#[builder(setter(strip_option))]
132pub struct CreateProjectMembership<'a> {
133    /// project id or name as it appears in the URL
134    #[builder(setter(into))]
135    #[serde(skip_serializing)]
136    project_id_or_name: Cow<'a, str>,
137    /// user to add to the project
138    user_id: u64,
139    /// roles for the user to add to the project
140    role_ids: Vec<u64>,
141}
142
143impl ReturnsJsonResponse for CreateProjectMembership<'_> {}
144impl NoPagination for CreateProjectMembership<'_> {}
145
146impl<'a> CreateProjectMembership<'a> {
147    /// Create a builder for the endpoint.
148    #[must_use]
149    pub fn builder() -> CreateProjectMembershipBuilder<'a> {
150        CreateProjectMembershipBuilder::default()
151    }
152}
153
154impl Endpoint for CreateProjectMembership<'_> {
155    fn method(&self) -> Method {
156        Method::POST
157    }
158
159    fn endpoint(&self) -> Cow<'static, str> {
160        format!("projects/{}/memberships.json", self.project_id_or_name).into()
161    }
162
163    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
164        Ok(Some((
165            "application/json",
166            serde_json::to_vec(&MembershipWrapper::<CreateProjectMembership> {
167                membership: (*self).to_owned(),
168            })?,
169        )))
170    }
171}
172
173/// The endpoint to update an existing Redmine project membership (change roles)
174#[serde_with::skip_serializing_none]
175#[derive(Debug, Clone, Builder, Serialize)]
176#[builder(setter(strip_option))]
177pub struct UpdateProjectMembership {
178    /// id of the project membership to update
179    #[serde(skip_serializing)]
180    id: u64,
181    /// roles for the user to add to the project
182    role_ids: Vec<u64>,
183}
184
185impl UpdateProjectMembership {
186    /// Create a builder for the endpoint.
187    #[must_use]
188    pub fn builder() -> UpdateProjectMembershipBuilder {
189        UpdateProjectMembershipBuilder::default()
190    }
191}
192
193impl Endpoint for UpdateProjectMembership {
194    fn method(&self) -> Method {
195        Method::PUT
196    }
197
198    fn endpoint(&self) -> Cow<'static, str> {
199        format!("memberships/{}.json", self.id).into()
200    }
201
202    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
203        Ok(Some((
204            "application/json",
205            serde_json::to_vec(&MembershipWrapper::<UpdateProjectMembership> {
206                membership: (*self).to_owned(),
207            })?,
208        )))
209    }
210}
211
212/// The endpoint to delete a membership in a Redmine project
213#[derive(Debug, Clone, Builder)]
214#[builder(setter(strip_option))]
215pub struct DeleteProjectMembership {
216    /// id of the project membership to delete
217    id: u64,
218}
219
220impl DeleteProjectMembership {
221    /// Create a builder for the endpoint.
222    #[must_use]
223    pub fn builder() -> DeleteProjectMembershipBuilder {
224        DeleteProjectMembershipBuilder::default()
225    }
226}
227
228impl Endpoint for DeleteProjectMembership {
229    fn method(&self) -> Method {
230        Method::DELETE
231    }
232
233    fn endpoint(&self) -> Cow<'static, str> {
234        format!("memberships/{}.json", &self.id).into()
235    }
236}
237
238/// helper struct for outer layers with a memberships field holding the inner data
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
240pub struct MembershipsWrapper<T> {
241    /// to parse JSON with memberships key
242    pub memberships: Vec<T>,
243}
244
245/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
246/// helper struct for outer layers with a membership field holding the inner data
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
248pub struct MembershipWrapper<T> {
249    /// to parse JSON with membership key
250    pub membership: T,
251}
252
253#[cfg(test)]
254mod test {
255    use super::*;
256    use crate::api::test_helpers::with_project;
257    use pretty_assertions::assert_eq;
258    use std::error::Error;
259    use tokio::sync::RwLock;
260    use tracing_test::traced_test;
261
262    /// needed so we do not get 404s when listing while
263    /// creating/deleting or creating/updating/deleting
264    static PROJECT_MEMBERSHIP_LOCK: RwLock<()> = RwLock::const_new(());
265
266    #[traced_test]
267    #[test]
268    fn test_list_project_memberships_first_page() -> Result<(), Box<dyn Error>> {
269        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
270        dotenvy::dotenv()?;
271        let redmine = crate::api::Redmine::from_env(
272            reqwest::blocking::Client::builder()
273                .use_rustls_tls()
274                .build()?,
275        )?;
276        let endpoint = ListProjectMemberships::builder()
277            .project_id_or_name("sandbox")
278            .build()?;
279        redmine.json_response_body_page::<_, ProjectMembership>(&endpoint, 0, 25)?;
280        Ok(())
281    }
282
283    #[traced_test]
284    #[test]
285    fn test_list_project_memberships_all_pages() -> Result<(), Box<dyn Error>> {
286        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
287        dotenvy::dotenv()?;
288        let redmine = crate::api::Redmine::from_env(
289            reqwest::blocking::Client::builder()
290                .use_rustls_tls()
291                .build()?,
292        )?;
293        let endpoint = ListProjectMemberships::builder()
294            .project_id_or_name("sandbox")
295            .build()?;
296        redmine.json_response_body_all_pages::<_, ProjectMembership>(&endpoint)?;
297        Ok(())
298    }
299
300    #[traced_test]
301    #[test]
302    fn test_get_project_membership() -> Result<(), Box<dyn Error>> {
303        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
304        dotenvy::dotenv()?;
305        let redmine = crate::api::Redmine::from_env(
306            reqwest::blocking::Client::builder()
307                .use_rustls_tls()
308                .build()?,
309        )?;
310        let endpoint = GetProjectMembership::builder().id(238).build()?;
311        redmine.json_response_body::<_, MembershipWrapper<ProjectMembership>>(&endpoint)?;
312        Ok(())
313    }
314
315    #[function_name::named]
316    #[traced_test]
317    #[test]
318    fn test_create_project_membership() -> Result<(), Box<dyn Error>> {
319        let _w_project_memberships = PROJECT_MEMBERSHIP_LOCK.write();
320        let name = format!("unittest_{}", function_name!());
321        with_project(&name, |redmine, project_id, _| {
322            let create_endpoint = super::CreateProjectMembership::builder()
323                .project_id_or_name(project_id.to_string())
324                .user_id(1)
325                .role_ids(vec![8])
326                .build()?;
327            redmine
328                .json_response_body::<_, MembershipWrapper<ProjectMembership>>(&create_endpoint)?;
329            Ok(())
330        })?;
331        Ok(())
332    }
333
334    #[function_name::named]
335    #[traced_test]
336    #[test]
337    fn test_update_project_membership() -> Result<(), Box<dyn Error>> {
338        let _w_project_memberships = PROJECT_MEMBERSHIP_LOCK.write();
339        let name = format!("unittest_{}", function_name!());
340        with_project(&name, |redmine, project_id, _name| {
341            let create_endpoint = super::CreateProjectMembership::builder()
342                .project_id_or_name(project_id.to_string())
343                .user_id(1)
344                .role_ids(vec![8])
345                .build()?;
346            let MembershipWrapper { membership } = redmine
347                .json_response_body::<_, MembershipWrapper<ProjectMembership>>(&create_endpoint)?;
348            let update_endpoint = super::UpdateProjectMembership::builder()
349                .id(membership.id)
350                .role_ids(vec![9])
351                .build()?;
352            redmine.ignore_response_body::<_>(&update_endpoint)?;
353            Ok(())
354        })?;
355        Ok(())
356    }
357
358    /// this tests if any of the results contain a field we are not deserializing
359    ///
360    /// this will only catch fields we missed if they are part of the response but
361    /// it is better than nothing
362    #[traced_test]
363    #[test]
364    fn test_completeness_project_membership_type() -> Result<(), Box<dyn Error>> {
365        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.read();
366        dotenvy::dotenv()?;
367        let redmine = crate::api::Redmine::from_env(
368            reqwest::blocking::Client::builder()
369                .use_rustls_tls()
370                .build()?,
371        )?;
372        let endpoint = ListProjectMemberships::builder()
373            .project_id_or_name("sandbox")
374            .build()?;
375        let values: Vec<serde_json::Value> = redmine.json_response_body_all_pages(&endpoint)?;
376        for value in values {
377            let o: ProjectMembership = serde_json::from_value(value.clone())?;
378            let reserialized = serde_json::to_value(o)?;
379            assert_eq!(value, reserialized);
380        }
381        Ok(())
382    }
383}