redmine_api/api/
issue_categories.rs

1//! Issue Categories Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_IssueCategories)
4//!
5//! - [x] project specific issue categories endpoint
6//! - [x] specific issue category endpoint
7//! - [x] create issue category endpoint
8//! - [x] update issue category endpoint
9//! - [x] delete issue category endpoint
10
11use derive_builder::Builder;
12use reqwest::Method;
13use std::borrow::Cow;
14
15use crate::api::issues::AssigneeEssentials;
16use crate::api::projects::ProjectEssentials;
17use crate::api::{Endpoint, ReturnsJsonResponse};
18use serde::Serialize;
19
20/// a minimal type for Redmine issue categories used in
21/// other Redmine objects (e.g. issue)
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub struct IssueCategoryEssentials {
24    /// numeric id
25    pub id: u64,
26    /// display name
27    pub name: String,
28}
29
30impl From<IssueCategory> for IssueCategoryEssentials {
31    fn from(v: IssueCategory) -> Self {
32        IssueCategoryEssentials {
33            id: v.id,
34            name: v.name,
35        }
36    }
37}
38
39impl From<&IssueCategory> for IssueCategoryEssentials {
40    fn from(v: &IssueCategory) -> Self {
41        IssueCategoryEssentials {
42            id: v.id,
43            name: v.name.to_owned(),
44        }
45    }
46}
47
48/// a type for issue categories to use as an API return type
49///
50/// alternatively you can use your own type limited to the fields you need
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
52pub struct IssueCategory {
53    /// numeric id
54    pub id: u64,
55    /// display name
56    pub name: String,
57    /// project
58    pub project: ProjectEssentials,
59    /// issues in this category are assigned to this user or group by default
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub assigned_to: Option<AssigneeEssentials>,
62}
63
64/// The endpoint for all issue categories in a Redmine project
65#[derive(Debug, Clone, Builder)]
66#[builder(setter(strip_option))]
67pub struct ListIssueCategories<'a> {
68    /// the project id or name as it appears in the URL
69    #[builder(setter(into))]
70    project_id_or_name: Cow<'a, str>,
71}
72
73impl ReturnsJsonResponse for ListIssueCategories<'_> {}
74
75impl<'a> ListIssueCategories<'a> {
76    /// Create a builder for the endpoint.
77    #[must_use]
78    pub fn builder() -> ListIssueCategoriesBuilder<'a> {
79        ListIssueCategoriesBuilder::default()
80    }
81}
82
83impl Endpoint for ListIssueCategories<'_> {
84    fn method(&self) -> Method {
85        Method::GET
86    }
87
88    fn endpoint(&self) -> Cow<'static, str> {
89        format!("projects/{}/issue_categories.json", self.project_id_or_name).into()
90    }
91}
92
93/// The endpoint for a specific issue category
94#[derive(Debug, Clone, Builder)]
95#[builder(setter(strip_option))]
96pub struct GetIssueCategory {
97    /// the id of the issue category to retrieve
98    id: u64,
99}
100
101impl ReturnsJsonResponse for GetIssueCategory {}
102
103impl GetIssueCategory {
104    /// Create a builder for the endpoint.
105    #[must_use]
106    pub fn builder() -> GetIssueCategoryBuilder {
107        GetIssueCategoryBuilder::default()
108    }
109}
110
111impl Endpoint for GetIssueCategory {
112    fn method(&self) -> Method {
113        Method::GET
114    }
115
116    fn endpoint(&self) -> Cow<'static, str> {
117        format!("issue_categories/{}.json", &self.id).into()
118    }
119}
120
121/// The endpoint to create a Redmine issue category
122#[serde_with::skip_serializing_none]
123#[derive(Debug, Clone, Builder, Serialize)]
124#[builder(setter(strip_option))]
125pub struct CreateIssueCategory<'a> {
126    /// project id or name as it appears in the URL for the project where we want to create the new issue category
127    #[serde(skip_serializing)]
128    #[builder(setter(into))]
129    project_id_or_name: Cow<'a, str>,
130    /// the name of the new issue category
131    #[builder(setter(into))]
132    name: Cow<'a, str>,
133    /// Issues in this issue category are assigned to this user by default
134    #[builder(default)]
135    assigned_to_id: Option<u64>,
136}
137
138impl ReturnsJsonResponse for CreateIssueCategory<'_> {}
139
140impl<'a> CreateIssueCategory<'a> {
141    /// Create a builder for the endpoint.
142    #[must_use]
143    pub fn builder() -> CreateIssueCategoryBuilder<'a> {
144        CreateIssueCategoryBuilder::default()
145    }
146}
147
148impl Endpoint for CreateIssueCategory<'_> {
149    fn method(&self) -> Method {
150        Method::POST
151    }
152
153    fn endpoint(&self) -> Cow<'static, str> {
154        format!("projects/{}/issue_categories.json", self.project_id_or_name).into()
155    }
156
157    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
158        Ok(Some((
159            "application/json",
160            serde_json::to_vec(&IssueCategoryWrapper::<CreateIssueCategory> {
161                issue_category: (*self).to_owned(),
162            })?,
163        )))
164    }
165}
166
167/// The endpoint to update an existing Redmine issue category
168#[serde_with::skip_serializing_none]
169#[derive(Debug, Clone, Builder, Serialize)]
170#[builder(setter(strip_option))]
171pub struct UpdateIssueCategory<'a> {
172    /// the id of the issue category to update
173    #[serde(skip_serializing)]
174    id: u64,
175    /// the name of the issue category
176    #[builder(setter(into), default)]
177    name: Option<Cow<'a, str>>,
178    /// Issues in this issue category are assigned to this user by default
179    #[builder(default)]
180    assigned_to_id: Option<u64>,
181}
182
183impl<'a> UpdateIssueCategory<'a> {
184    /// Create a builder for the endpoint.
185    #[must_use]
186    pub fn builder() -> UpdateIssueCategoryBuilder<'a> {
187        UpdateIssueCategoryBuilder::default()
188    }
189}
190
191impl Endpoint for UpdateIssueCategory<'_> {
192    fn method(&self) -> Method {
193        Method::PUT
194    }
195
196    fn endpoint(&self) -> Cow<'static, str> {
197        format!("issue_categories/{}.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(&IssueCategoryWrapper::<UpdateIssueCategory> {
204                issue_category: (*self).to_owned(),
205            })?,
206        )))
207    }
208}
209
210/// The endpoint to delete a Redmine issue category
211#[derive(Debug, Clone, Builder)]
212#[builder(setter(strip_option))]
213pub struct DeleteIssueCategory {
214    /// the id of the issue category to delete
215    id: u64,
216}
217
218impl DeleteIssueCategory {
219    /// Create a builder for the endpoint.
220    #[must_use]
221    pub fn builder() -> DeleteIssueCategoryBuilder {
222        DeleteIssueCategoryBuilder::default()
223    }
224}
225
226impl Endpoint for DeleteIssueCategory {
227    fn method(&self) -> Method {
228        Method::DELETE
229    }
230
231    fn endpoint(&self) -> Cow<'static, str> {
232        format!("issue_categories/{}.json", &self.id).into()
233    }
234}
235
236/// helper struct for outer layers with a issue_categories field holding the inner data
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
238pub struct IssueCategoriesWrapper<T> {
239    /// to parse JSON with issue_categories key
240    pub issue_categories: 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 issue_category field holding the inner data
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
246pub struct IssueCategoryWrapper<T> {
247    /// to parse JSON with an issue_category key
248    pub issue_category: 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 ISSUE_CATEGORY_LOCK: RwLock<()> = RwLock::const_new(());
263
264    #[traced_test]
265    #[test]
266    fn test_list_issue_categories_no_pagination() -> Result<(), Box<dyn Error>> {
267        let _r_issue_category = ISSUE_CATEGORY_LOCK.read();
268        dotenvy::dotenv()?;
269        let redmine = crate::api::Redmine::from_env()?;
270        let endpoint = ListIssueCategories::builder()
271            .project_id_or_name("336")
272            .build()?;
273        redmine.json_response_body::<_, IssueCategoriesWrapper<IssueCategory>>(&endpoint)?;
274        Ok(())
275    }
276
277    #[traced_test]
278    #[test]
279    fn test_get_issue_category() -> Result<(), Box<dyn Error>> {
280        let _r_issue_category = ISSUE_CATEGORY_LOCK.read();
281        dotenvy::dotenv()?;
282        let redmine = crate::api::Redmine::from_env()?;
283        let endpoint = GetIssueCategory::builder().id(10).build()?;
284        redmine.json_response_body::<_, IssueCategoryWrapper<IssueCategory>>(&endpoint)?;
285        Ok(())
286    }
287
288    #[function_name::named]
289    #[traced_test]
290    #[test]
291    fn test_create_issue_category() -> Result<(), Box<dyn Error>> {
292        let _w_issue_category = ISSUE_CATEGORY_LOCK.write();
293        let name = format!("unittest_{}", function_name!());
294        with_project(&name, |redmine, _id, name| {
295            let create_endpoint = super::CreateIssueCategory::builder()
296                .project_id_or_name(name)
297                .name("Unittest Issue Category")
298                .build()?;
299            redmine.ignore_response_body::<_>(&create_endpoint)?;
300            Ok(())
301        })?;
302        Ok(())
303    }
304
305    #[function_name::named]
306    #[traced_test]
307    #[test]
308    fn test_update_issue_category() -> Result<(), Box<dyn Error>> {
309        let _w_issue_category = ISSUE_CATEGORY_LOCK.write();
310        let name = format!("unittest_{}", function_name!());
311        with_project(&name, |redmine, _id, name| {
312            let create_endpoint = super::CreateIssueCategory::builder()
313                .project_id_or_name(name)
314                .name("Unittest Issue Category")
315                .build()?;
316            let IssueCategoryWrapper { issue_category }: IssueCategoryWrapper<IssueCategory> =
317                redmine.json_response_body::<_, _>(&create_endpoint)?;
318            let id = issue_category.id;
319            let update_endpoint = super::UpdateIssueCategory::builder()
320                .id(id)
321                .name("Renamed Unit-Test name")
322                .build()?;
323            redmine.ignore_response_body::<_>(&update_endpoint)?;
324            Ok(())
325        })?;
326        Ok(())
327    }
328
329    #[function_name::named]
330    #[traced_test]
331    #[test]
332    fn test_delete_issue_category() -> Result<(), Box<dyn Error>> {
333        let _w_issue_category = ISSUE_CATEGORY_LOCK.write();
334        let name = format!("unittest_{}", function_name!());
335        with_project(&name, |redmine, _id, name| {
336            let create_endpoint = super::CreateIssueCategory::builder()
337                .project_id_or_name(name)
338                .name("Unittest Issue Category")
339                .build()?;
340            let IssueCategoryWrapper { issue_category }: IssueCategoryWrapper<IssueCategory> =
341                redmine.json_response_body::<_, _>(&create_endpoint)?;
342            let id = issue_category.id;
343            let delete_endpoint = super::DeleteIssueCategory::builder().id(id).build()?;
344            redmine.ignore_response_body::<_>(&delete_endpoint)?;
345            Ok(())
346        })?;
347        Ok(())
348    }
349
350    /// this tests if any of the results contain a field we are not deserializing
351    ///
352    /// this will only catch fields we missed if they are part of the response but
353    /// it is better than nothing
354    #[traced_test]
355    #[test]
356    fn test_completeness_issue_category_type() -> Result<(), Box<dyn Error>> {
357        let _r_issue_category = ISSUE_CATEGORY_LOCK.read();
358        dotenvy::dotenv()?;
359        let redmine = crate::api::Redmine::from_env()?;
360        let endpoint = ListIssueCategories::builder()
361            .project_id_or_name("336")
362            .build()?;
363        let IssueCategoriesWrapper {
364            issue_categories: values,
365        } = redmine
366            .json_response_body::<_, IssueCategoriesWrapper<serde_json::Value>>(&endpoint)?;
367        for value in values {
368            let o: IssueCategory = serde_json::from_value(value.clone())?;
369            let reserialized = serde_json::to_value(o)?;
370            assert_eq!(value, reserialized);
371        }
372        Ok(())
373    }
374}