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