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, NoPagination, 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<'_> {}
74impl NoPagination for ListIssueCategories<'_> {}
75
76impl<'a> ListIssueCategories<'a> {
77    /// Create a builder for the endpoint.
78    #[must_use]
79    pub fn builder() -> ListIssueCategoriesBuilder<'a> {
80        ListIssueCategoriesBuilder::default()
81    }
82}
83
84impl Endpoint for ListIssueCategories<'_> {
85    fn method(&self) -> Method {
86        Method::GET
87    }
88
89    fn endpoint(&self) -> Cow<'static, str> {
90        format!("projects/{}/issue_categories.json", self.project_id_or_name).into()
91    }
92}
93
94/// The endpoint for a specific issue category
95#[derive(Debug, Clone, Builder)]
96#[builder(setter(strip_option))]
97pub struct GetIssueCategory {
98    /// the id of the issue category to retrieve
99    id: u64,
100}
101
102impl ReturnsJsonResponse for GetIssueCategory {}
103impl NoPagination for GetIssueCategory {}
104
105impl GetIssueCategory {
106    /// Create a builder for the endpoint.
107    #[must_use]
108    pub fn builder() -> GetIssueCategoryBuilder {
109        GetIssueCategoryBuilder::default()
110    }
111}
112
113impl Endpoint for GetIssueCategory {
114    fn method(&self) -> Method {
115        Method::GET
116    }
117
118    fn endpoint(&self) -> Cow<'static, str> {
119        format!("issue_categories/{}.json", &self.id).into()
120    }
121}
122
123/// The endpoint to create a Redmine issue category
124#[serde_with::skip_serializing_none]
125#[derive(Debug, Clone, Builder, Serialize)]
126#[builder(setter(strip_option))]
127pub struct CreateIssueCategory<'a> {
128    /// project id or name as it appears in the URL for the project where we want to create the new issue category
129    #[serde(skip_serializing)]
130    #[builder(setter(into))]
131    project_id_or_name: Cow<'a, str>,
132    /// the name of the new issue category
133    #[builder(setter(into))]
134    name: Cow<'a, str>,
135    /// Issues in this issue category are assigned to this user by default
136    #[builder(default)]
137    assigned_to_id: Option<u64>,
138}
139
140impl ReturnsJsonResponse for CreateIssueCategory<'_> {}
141impl NoPagination for CreateIssueCategory<'_> {}
142
143impl<'a> CreateIssueCategory<'a> {
144    /// Create a builder for the endpoint.
145    #[must_use]
146    pub fn builder() -> CreateIssueCategoryBuilder<'a> {
147        CreateIssueCategoryBuilder::default()
148    }
149}
150
151impl Endpoint for CreateIssueCategory<'_> {
152    fn method(&self) -> Method {
153        Method::POST
154    }
155
156    fn endpoint(&self) -> Cow<'static, str> {
157        format!("projects/{}/issue_categories.json", self.project_id_or_name).into()
158    }
159
160    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
161        Ok(Some((
162            "application/json",
163            serde_json::to_vec(&IssueCategoryWrapper::<CreateIssueCategory> {
164                issue_category: (*self).to_owned(),
165            })?,
166        )))
167    }
168}
169
170/// The endpoint to update an existing Redmine issue category
171#[serde_with::skip_serializing_none]
172#[derive(Debug, Clone, Builder, Serialize)]
173#[builder(setter(strip_option))]
174pub struct UpdateIssueCategory<'a> {
175    /// the id of the issue category to update
176    #[serde(skip_serializing)]
177    id: u64,
178    /// the name of the issue category
179    #[builder(setter(into), default)]
180    name: Option<Cow<'a, str>>,
181    /// Issues in this issue category are assigned to this user by default
182    #[builder(default)]
183    assigned_to_id: Option<u64>,
184}
185
186impl<'a> UpdateIssueCategory<'a> {
187    /// Create a builder for the endpoint.
188    #[must_use]
189    pub fn builder() -> UpdateIssueCategoryBuilder<'a> {
190        UpdateIssueCategoryBuilder::default()
191    }
192}
193
194impl Endpoint for UpdateIssueCategory<'_> {
195    fn method(&self) -> Method {
196        Method::PUT
197    }
198
199    fn endpoint(&self) -> Cow<'static, str> {
200        format!("issue_categories/{}.json", self.id).into()
201    }
202
203    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
204        Ok(Some((
205            "application/json",
206            serde_json::to_vec(&IssueCategoryWrapper::<UpdateIssueCategory> {
207                issue_category: (*self).to_owned(),
208            })?,
209        )))
210    }
211}
212
213/// The endpoint to delete a Redmine issue category
214#[derive(Debug, Clone, Builder)]
215#[builder(setter(strip_option))]
216pub struct DeleteIssueCategory {
217    /// the id of the issue category to delete
218    id: u64,
219}
220
221impl DeleteIssueCategory {
222    /// Create a builder for the endpoint.
223    #[must_use]
224    pub fn builder() -> DeleteIssueCategoryBuilder {
225        DeleteIssueCategoryBuilder::default()
226    }
227}
228
229impl Endpoint for DeleteIssueCategory {
230    fn method(&self) -> Method {
231        Method::DELETE
232    }
233
234    fn endpoint(&self) -> Cow<'static, str> {
235        format!("issue_categories/{}.json", &self.id).into()
236    }
237}
238
239/// helper struct for outer layers with a issue_categories field holding the inner data
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
241pub struct IssueCategoriesWrapper<T> {
242    /// to parse JSON with issue_categories key
243    pub issue_categories: Vec<T>,
244}
245
246/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
247/// helper struct for outer layers with a issue_category field holding the inner data
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
249pub struct IssueCategoryWrapper<T> {
250    /// to parse JSON with an issue_category key
251    pub issue_category: T,
252}
253
254#[cfg(test)]
255mod test {
256    use super::*;
257    use crate::api::test_helpers::with_project;
258    use pretty_assertions::assert_eq;
259    use std::error::Error;
260    use tokio::sync::RwLock;
261    use tracing_test::traced_test;
262
263    /// needed so we do not get 404s when listing while
264    /// creating/deleting or creating/updating/deleting
265    static ISSUE_CATEGORY_LOCK: RwLock<()> = RwLock::const_new(());
266
267    #[traced_test]
268    #[test]
269    fn test_list_issue_categories_no_pagination() -> Result<(), Box<dyn Error>> {
270        let _r_issue_category = ISSUE_CATEGORY_LOCK.read();
271        dotenvy::dotenv()?;
272        let redmine = crate::api::Redmine::from_env()?;
273        let endpoint = ListIssueCategories::builder()
274            .project_id_or_name("336")
275            .build()?;
276        redmine.json_response_body::<_, IssueCategoriesWrapper<IssueCategory>>(&endpoint)?;
277        Ok(())
278    }
279
280    #[traced_test]
281    #[test]
282    fn test_get_issue_category() -> Result<(), Box<dyn Error>> {
283        let _r_issue_category = ISSUE_CATEGORY_LOCK.read();
284        dotenvy::dotenv()?;
285        let redmine = crate::api::Redmine::from_env()?;
286        let endpoint = GetIssueCategory::builder().id(10).build()?;
287        redmine.json_response_body::<_, IssueCategoryWrapper<IssueCategory>>(&endpoint)?;
288        Ok(())
289    }
290
291    #[function_name::named]
292    #[traced_test]
293    #[test]
294    fn test_create_issue_category() -> Result<(), Box<dyn Error>> {
295        let _w_issue_category = ISSUE_CATEGORY_LOCK.write();
296        let name = format!("unittest_{}", function_name!());
297        with_project(&name, |redmine, _id, name| {
298            let create_endpoint = super::CreateIssueCategory::builder()
299                .project_id_or_name(name)
300                .name("Unittest Issue Category")
301                .build()?;
302            redmine.ignore_response_body::<_>(&create_endpoint)?;
303            Ok(())
304        })?;
305        Ok(())
306    }
307
308    #[function_name::named]
309    #[traced_test]
310    #[test]
311    fn test_update_issue_category() -> Result<(), Box<dyn Error>> {
312        let _w_issue_category = ISSUE_CATEGORY_LOCK.write();
313        let name = format!("unittest_{}", function_name!());
314        with_project(&name, |redmine, _id, name| {
315            let create_endpoint = super::CreateIssueCategory::builder()
316                .project_id_or_name(name)
317                .name("Unittest Issue Category")
318                .build()?;
319            let IssueCategoryWrapper { issue_category }: IssueCategoryWrapper<IssueCategory> =
320                redmine.json_response_body::<_, _>(&create_endpoint)?;
321            let id = issue_category.id;
322            let update_endpoint = super::UpdateIssueCategory::builder()
323                .id(id)
324                .name("Renamed Unit-Test name")
325                .build()?;
326            redmine.ignore_response_body::<_>(&update_endpoint)?;
327            Ok(())
328        })?;
329        Ok(())
330    }
331
332    #[function_name::named]
333    #[traced_test]
334    #[test]
335    fn test_delete_issue_category() -> Result<(), Box<dyn Error>> {
336        let _w_issue_category = ISSUE_CATEGORY_LOCK.write();
337        let name = format!("unittest_{}", function_name!());
338        with_project(&name, |redmine, _id, name| {
339            let create_endpoint = super::CreateIssueCategory::builder()
340                .project_id_or_name(name)
341                .name("Unittest Issue Category")
342                .build()?;
343            let IssueCategoryWrapper { issue_category }: IssueCategoryWrapper<IssueCategory> =
344                redmine.json_response_body::<_, _>(&create_endpoint)?;
345            let id = issue_category.id;
346            let delete_endpoint = super::DeleteIssueCategory::builder().id(id).build()?;
347            redmine.ignore_response_body::<_>(&delete_endpoint)?;
348            Ok(())
349        })?;
350        Ok(())
351    }
352
353    /// this tests if any of the results contain a field we are not deserializing
354    ///
355    /// this will only catch fields we missed if they are part of the response but
356    /// it is better than nothing
357    #[traced_test]
358    #[test]
359    fn test_completeness_issue_category_type() -> Result<(), Box<dyn Error>> {
360        let _r_issue_category = ISSUE_CATEGORY_LOCK.read();
361        dotenvy::dotenv()?;
362        let redmine = crate::api::Redmine::from_env()?;
363        let endpoint = ListIssueCategories::builder()
364            .project_id_or_name("336")
365            .build()?;
366        let IssueCategoriesWrapper {
367            issue_categories: values,
368        } = redmine
369            .json_response_body::<_, IssueCategoriesWrapper<serde_json::Value>>(&endpoint)?;
370        for value in values {
371            let o: IssueCategory = serde_json::from_value(value.clone())?;
372            let reserialized = serde_json::to_value(o)?;
373            assert_eq!(value, reserialized);
374        }
375        Ok(())
376    }
377}