redmine_api/api/
issue_relations.rs

1//! Issue Relations Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_IssueRelations)
4//!
5//! - [x] issue specific issue relations endpoint
6//! - [x] create issue relation endpoint
7//!   - [x] normal relations
8//!   - [x] delay in precedes/follows
9//! - [x] specific issue relation endpoint
10//! - [x] delete issue relation endpoint
11
12use derive_builder::Builder;
13use reqwest::Method;
14use std::borrow::Cow;
15
16use crate::api::{Endpoint, NoPagination, ReturnsJsonResponse};
17use serde::Serialize;
18
19/// a type for issue relations to use as an API return type
20///
21/// alternatively you can use your own type limited to the fields you need
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
23pub struct IssueRelation {
24    /// numeric id
25    pub id: u64,
26    /// issue on which this relation is created
27    pub issue_id: u64,
28    /// issue to which it is related
29    pub issue_to_id: u64,
30    /// type of relation
31    pub relation_type: IssueRelationType,
32    /// Delay in days for the precedes and follows relation types
33    pub delay: Option<u64>,
34}
35
36/// The endpoint for all issue relations in a Redmine issue
37#[derive(Debug, Clone, Builder)]
38#[builder(setter(strip_option))]
39pub struct ListIssueRelations {
40    /// the id of the issue for which we want to retrieve all issue relations
41    issue_id: u64,
42}
43
44impl ReturnsJsonResponse for ListIssueRelations {}
45impl NoPagination for ListIssueRelations {}
46
47impl ListIssueRelations {
48    /// Create a builder for the endpoint.
49    #[must_use]
50    pub fn builder() -> ListIssueRelationsBuilder {
51        ListIssueRelationsBuilder::default()
52    }
53}
54
55impl Endpoint for ListIssueRelations {
56    fn method(&self) -> Method {
57        Method::GET
58    }
59
60    fn endpoint(&self) -> Cow<'static, str> {
61        format!("issues/{}/relations.json", self.issue_id).into()
62    }
63}
64
65/// The endpoint for a specific issue relation
66#[derive(Debug, Clone, Builder)]
67#[builder(setter(strip_option))]
68pub struct GetIssueRelation {
69    /// the id of the issue relation to retrieve
70    id: u64,
71}
72
73impl ReturnsJsonResponse for GetIssueRelation {}
74impl NoPagination for GetIssueRelation {}
75
76impl GetIssueRelation {
77    /// Create a builder for the endpoint.
78    #[must_use]
79    pub fn builder() -> GetIssueRelationBuilder {
80        GetIssueRelationBuilder::default()
81    }
82}
83
84impl Endpoint for GetIssueRelation {
85    fn method(&self) -> Method {
86        Method::GET
87    }
88
89    fn endpoint(&self) -> Cow<'static, str> {
90        format!("relations/{}.json", self.id).into()
91    }
92}
93
94/// Type of issue relation
95#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, Serialize)]
96#[serde(rename_all = "snake_case")]
97pub enum IssueRelationType {
98    /// The most general type of issue relation
99    Relates,
100    /// Indicates that the issue duplicates another issue
101    Duplicates,
102    /// Indicates that the issue is duplicated by another issue
103    Duplicated,
104    /// Indicates that the issue blocks another issue
105    Blocks,
106    /// Indicates that the issue is blocked by another issue
107    Blocked,
108    /// Indicates that the issue precedes another issue
109    Precedes,
110    /// Indicates that the issue follows another issue
111    Follows,
112    /// Indicates that the issue was copied to another issue
113    CopiedTo,
114    /// Indicates that the issue was copied from another issue
115    CopiedFrom,
116}
117
118/// The endpoint to create an issue relation
119#[serde_with::skip_serializing_none]
120#[derive(Debug, Clone, Builder, Serialize)]
121#[builder(setter(strip_option))]
122pub struct CreateIssueRelation {
123    /// id of the issue where the relation is created
124    #[serde(skip_serializing)]
125    issue_id: u64,
126    /// id of the issue the relation is created to
127    issue_to_id: u64,
128    /// the type of issue relation to create
129    relation_type: IssueRelationType,
130    /// Delay in days for the precedes and follows relation types
131    #[builder(default)]
132    delay: Option<u64>,
133}
134
135impl ReturnsJsonResponse for CreateIssueRelation {}
136impl NoPagination for CreateIssueRelation {}
137
138impl CreateIssueRelation {
139    /// Create a builder for the endpoint.
140    #[must_use]
141    pub fn builder() -> CreateIssueRelationBuilder {
142        CreateIssueRelationBuilder::default()
143    }
144}
145
146impl Endpoint for CreateIssueRelation {
147    fn method(&self) -> Method {
148        Method::POST
149    }
150
151    fn endpoint(&self) -> Cow<'static, str> {
152        format!("issues/{}/relations.json", self.issue_id).into()
153    }
154
155    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
156        Ok(Some((
157            "application/json",
158            serde_json::to_vec(&RelationWrapper::<CreateIssueRelation> {
159                relation: (*self).to_owned(),
160            })?,
161        )))
162    }
163}
164
165/// The endpoint to delete an issue relation
166#[derive(Debug, Clone, Builder)]
167#[builder(setter(strip_option))]
168pub struct DeleteIssueRelation {
169    /// the id of the issue relation to delete
170    id: u64,
171}
172
173impl DeleteIssueRelation {
174    /// Create a builder for the endpoint.
175    #[must_use]
176    pub fn builder() -> DeleteIssueRelationBuilder {
177        DeleteIssueRelationBuilder::default()
178    }
179}
180
181impl Endpoint for DeleteIssueRelation {
182    fn method(&self) -> Method {
183        Method::DELETE
184    }
185
186    fn endpoint(&self) -> Cow<'static, str> {
187        format!("relations/{}.json", self.id).into()
188    }
189}
190
191/// helper struct for outer layers with a relations field holding the inner data
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
193pub struct RelationsWrapper<T> {
194    /// to parse JSON with relations key
195    pub relations: Vec<T>,
196}
197
198/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
199/// helper struct for outer layers with a relation field holding the inner data
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
201pub struct RelationWrapper<T> {
202    /// to parse JSON with an relation key
203    pub relation: T,
204}
205
206#[cfg(test)]
207mod test {
208    use super::*;
209    use crate::api::issues::test::ISSUES_LOCK;
210    use crate::api::issues::{CreateIssue, Issue, IssueWrapper};
211    use crate::api::test_helpers::with_project;
212    use pretty_assertions::assert_eq;
213    use std::error::Error;
214    use tokio::sync::RwLock;
215    use tracing_test::traced_test;
216
217    /// needed so we do not get 404s when listing while
218    /// creating/deleting or creating/updating/deleting
219    static ISSUE_RELATION_LOCK: RwLock<()> = RwLock::const_new(());
220
221    #[traced_test]
222    #[test]
223    fn test_list_issue_relations_no_pagination() -> Result<(), Box<dyn Error>> {
224        let _r_issue_relation = ISSUE_RELATION_LOCK.read();
225        dotenvy::dotenv()?;
226        let redmine = crate::api::Redmine::from_env()?;
227        let endpoint = ListIssueRelations::builder().issue_id(50017).build()?;
228        redmine.json_response_body::<_, RelationsWrapper<IssueRelation>>(&endpoint)?;
229        Ok(())
230    }
231
232    #[traced_test]
233    #[test]
234    fn test_get_issue_relation() -> Result<(), Box<dyn Error>> {
235        let _r_issue_relation = ISSUE_RELATION_LOCK.read();
236        dotenvy::dotenv()?;
237        let redmine = crate::api::Redmine::from_env()?;
238        let endpoint = GetIssueRelation::builder().id(10).build()?;
239        redmine.json_response_body::<_, RelationWrapper<IssueRelation>>(&endpoint)?;
240        Ok(())
241    }
242
243    #[function_name::named]
244    #[traced_test]
245    #[test]
246    fn test_create_issue_relation() -> Result<(), Box<dyn Error>> {
247        let _w_issues = ISSUES_LOCK.write();
248        let _w_issue_relation = ISSUE_RELATION_LOCK.write();
249        let name = format!("unittest_{}", function_name!());
250        with_project(&name, |redmine, project_id, _name| {
251            let create_issue1_endpoint = CreateIssue::builder()
252                .project_id(project_id)
253                .subject("Test issue 1")
254                .build()?;
255            let IssueWrapper { issue: issue1 }: IssueWrapper<Issue> =
256                redmine.json_response_body::<_, _>(&create_issue1_endpoint)?;
257            let create_issue2_endpoint = CreateIssue::builder()
258                .project_id(project_id)
259                .subject("Test issue 2")
260                .build()?;
261            let IssueWrapper { issue: issue2 }: IssueWrapper<Issue> =
262                redmine.json_response_body::<_, _>(&create_issue2_endpoint)?;
263            let create_endpoint = super::CreateIssueRelation::builder()
264                .issue_id(issue1.id)
265                .issue_to_id(issue2.id)
266                .relation_type(IssueRelationType::Relates)
267                .build()?;
268            redmine.json_response_body::<_, RelationWrapper<IssueRelation>>(&create_endpoint)?;
269            Ok(())
270        })?;
271        Ok(())
272    }
273
274    #[function_name::named]
275    #[traced_test]
276    #[test]
277    fn test_delete_issue_relation() -> Result<(), Box<dyn Error>> {
278        let _w_issues = ISSUES_LOCK.write();
279        let _w_issue_relation = ISSUE_RELATION_LOCK.write();
280        let name = format!("unittest_{}", function_name!());
281        with_project(&name, |redmine, project_id, _name| {
282            let create_issue1_endpoint = CreateIssue::builder()
283                .project_id(project_id)
284                .subject("Test issue 1")
285                .build()?;
286            let IssueWrapper { issue: issue1 }: IssueWrapper<Issue> =
287                redmine.json_response_body::<_, _>(&create_issue1_endpoint)?;
288            let create_issue2_endpoint = CreateIssue::builder()
289                .project_id(project_id)
290                .subject("Test issue 2")
291                .build()?;
292            let IssueWrapper { issue: issue2 }: IssueWrapper<Issue> =
293                redmine.json_response_body::<_, _>(&create_issue2_endpoint)?;
294            let create_endpoint = super::CreateIssueRelation::builder()
295                .issue_id(issue1.id)
296                .issue_to_id(issue2.id)
297                .relation_type(IssueRelationType::Relates)
298                .build()?;
299            let RelationWrapper { relation }: RelationWrapper<IssueRelation> =
300                redmine.json_response_body::<_, _>(&create_endpoint)?;
301            let id = relation.id;
302            let delete_endpoint = super::DeleteIssueRelation::builder().id(id).build()?;
303            redmine.ignore_response_body::<_>(&delete_endpoint)?;
304            Ok(())
305        })?;
306        Ok(())
307    }
308
309    /// this tests if any of the results contain a field we are not deserializing
310    ///
311    /// this will only catch fields we missed if they are part of the response but
312    /// it is better than nothing
313    #[traced_test]
314    #[test]
315    fn test_completeness_issue_relation_type() -> Result<(), Box<dyn Error>> {
316        let _r_issue_relation = ISSUE_RELATION_LOCK.read();
317        dotenvy::dotenv()?;
318        let redmine = crate::api::Redmine::from_env()?;
319        let endpoint = ListIssueRelations::builder().issue_id(50017).build()?;
320        let RelationsWrapper { relations: values } =
321            redmine.json_response_body::<_, RelationsWrapper<serde_json::Value>>(&endpoint)?;
322        for value in values {
323            let o: IssueRelation = serde_json::from_value(value.clone())?;
324            let reserialized = serde_json::to_value(o)?;
325            assert_eq!(value, reserialized);
326        }
327        Ok(())
328    }
329}