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, QueryParams, 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    /// reassign issues to this category
220    #[builder(default)]
221    reassign_to_id: Option<u64>,
222    /// what to do with issues in the category being deleted
223    #[builder(default)]
224    todo: Option<String>,
225}
226
227impl DeleteIssueCategory {
228    /// Create a builder for the endpoint.
229    #[must_use]
230    pub fn builder() -> DeleteIssueCategoryBuilder {
231        DeleteIssueCategoryBuilder::default()
232    }
233}
234
235impl Endpoint for DeleteIssueCategory {
236    fn method(&self) -> Method {
237        Method::DELETE
238    }
239
240    fn endpoint(&self) -> Cow<'static, str> {
241        format!("issue_categories/{}.json", &self.id).into()
242    }
243
244    fn parameters(&self) -> QueryParams<'_> {
245        let mut params = QueryParams::default();
246        params.push_opt("reassign_to_id", self.reassign_to_id);
247        params.push_opt("todo", self.todo.as_ref());
248        params
249    }
250}
251
252/// helper struct for outer layers with a issue_categories field holding the inner data
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
254pub struct IssueCategoriesWrapper<T> {
255    /// to parse JSON with issue_categories key
256    pub issue_categories: Vec<T>,
257}
258
259/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
260/// helper struct for outer layers with a issue_category field holding the inner data
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
262pub struct IssueCategoryWrapper<T> {
263    /// to parse JSON with an issue_category key
264    pub issue_category: T,
265}
266
267#[cfg(test)]
268mod test {
269    use super::*;
270    use crate::api::test_helpers::with_project;
271    use pretty_assertions::assert_eq;
272    use std::error::Error;
273    use tokio::sync::RwLock;
274    use tracing_test::traced_test;
275
276    /// needed so we do not get 404s when listing while
277    /// creating/deleting or creating/updating/deleting
278    static ISSUE_CATEGORY_LOCK: RwLock<()> = RwLock::const_new(());
279
280    #[traced_test]
281    #[test]
282    fn test_list_issue_categories_no_pagination() -> Result<(), Box<dyn Error>> {
283        let _r_issue_category = ISSUE_CATEGORY_LOCK.blocking_read();
284        dotenvy::dotenv()?;
285        let redmine = crate::api::Redmine::from_env(
286            reqwest::blocking::Client::builder()
287                .use_rustls_tls()
288                .build()?,
289        )?;
290        let endpoint = ListIssueCategories::builder()
291            .project_id_or_name("336")
292            .build()?;
293        redmine.json_response_body::<_, IssueCategoriesWrapper<IssueCategory>>(&endpoint)?;
294        Ok(())
295    }
296
297    #[traced_test]
298    #[test]
299    fn test_get_issue_category() -> Result<(), Box<dyn Error>> {
300        let _r_issue_category = ISSUE_CATEGORY_LOCK.blocking_read();
301        dotenvy::dotenv()?;
302        let redmine = crate::api::Redmine::from_env(
303            reqwest::blocking::Client::builder()
304                .use_rustls_tls()
305                .build()?,
306        )?;
307        let endpoint = GetIssueCategory::builder().id(10).build()?;
308        redmine.json_response_body::<_, IssueCategoryWrapper<IssueCategory>>(&endpoint)?;
309        Ok(())
310    }
311
312    #[function_name::named]
313    #[traced_test]
314    #[test]
315    fn test_create_issue_category() -> Result<(), Box<dyn Error>> {
316        let _w_issue_category = ISSUE_CATEGORY_LOCK.blocking_write();
317        let name = format!("unittest_{}", function_name!());
318        with_project(&name, |redmine, _id, name| {
319            let create_endpoint = super::CreateIssueCategory::builder()
320                .project_id_or_name(name)
321                .name("Unittest Issue Category")
322                .build()?;
323            redmine.ignore_response_body::<_>(&create_endpoint)?;
324            Ok(())
325        })?;
326        Ok(())
327    }
328
329    #[function_name::named]
330    #[traced_test]
331    #[test]
332    fn test_update_issue_category() -> Result<(), Box<dyn Error>> {
333        let _w_issue_category = ISSUE_CATEGORY_LOCK.blocking_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 update_endpoint = super::UpdateIssueCategory::builder()
344                .id(id)
345                .name("Renamed Unit-Test name")
346                .build()?;
347            redmine.ignore_response_body::<_>(&update_endpoint)?;
348            Ok(())
349        })?;
350        Ok(())
351    }
352
353    #[function_name::named]
354    #[traced_test]
355    #[test]
356    fn test_delete_issue_category() -> Result<(), Box<dyn Error>> {
357        let _w_issue_category = ISSUE_CATEGORY_LOCK.blocking_write();
358        let name = format!("unittest_{}", function_name!());
359        with_project(&name, |redmine, _id, name| {
360            let create_endpoint = super::CreateIssueCategory::builder()
361                .project_id_or_name(name)
362                .name("Unittest Issue Category")
363                .build()?;
364            let IssueCategoryWrapper { issue_category }: IssueCategoryWrapper<IssueCategory> =
365                redmine.json_response_body::<_, _>(&create_endpoint)?;
366            let id = issue_category.id;
367            let delete_endpoint = super::DeleteIssueCategory::builder().id(id).build()?;
368            redmine.ignore_response_body::<_>(&delete_endpoint)?;
369            Ok(())
370        })?;
371        Ok(())
372    }
373
374    /// this tests if any of the results contain a field we are not deserializing
375    ///
376    /// this will only catch fields we missed if they are part of the response but
377    /// it is better than nothing
378    #[traced_test]
379    #[test]
380    fn test_completeness_issue_category_type() -> Result<(), Box<dyn Error>> {
381        let _r_issue_category = ISSUE_CATEGORY_LOCK.blocking_read();
382        dotenvy::dotenv()?;
383        let redmine = crate::api::Redmine::from_env(
384            reqwest::blocking::Client::builder()
385                .use_rustls_tls()
386                .build()?,
387        )?;
388        let endpoint = ListIssueCategories::builder()
389            .project_id_or_name("336")
390            .build()?;
391        let IssueCategoriesWrapper {
392            issue_categories: values,
393        } = redmine
394            .json_response_body::<_, IssueCategoriesWrapper<serde_json::Value>>(&endpoint)?;
395        for value in values {
396            let o: IssueCategory = serde_json::from_value(value.clone())?;
397            let reserialized = serde_json::to_value(o)?;
398            assert_eq!(value, reserialized);
399        }
400        Ok(())
401    }
402}