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            reqwest::blocking::Client::builder()
228                .use_rustls_tls()
229                .build()?,
230        )?;
231        let endpoint = ListIssueRelations::builder().issue_id(50017).build()?;
232        redmine.json_response_body::<_, RelationsWrapper<IssueRelation>>(&endpoint)?;
233        Ok(())
234    }
235
236    #[traced_test]
237    #[test]
238    fn test_get_issue_relation() -> Result<(), Box<dyn Error>> {
239        let _r_issue_relation = ISSUE_RELATION_LOCK.read();
240        dotenvy::dotenv()?;
241        let redmine = crate::api::Redmine::from_env(
242            reqwest::blocking::Client::builder()
243                .use_rustls_tls()
244                .build()?,
245        )?;
246        let endpoint = GetIssueRelation::builder().id(10).build()?;
247        redmine.json_response_body::<_, RelationWrapper<IssueRelation>>(&endpoint)?;
248        Ok(())
249    }
250
251    #[function_name::named]
252    #[traced_test]
253    #[test]
254    fn test_create_issue_relation() -> Result<(), Box<dyn Error>> {
255        let _w_issues = ISSUES_LOCK.write();
256        let _w_issue_relation = ISSUE_RELATION_LOCK.write();
257        let name = format!("unittest_{}", function_name!());
258        with_project(&name, |redmine, project_id, _name| {
259            let create_issue1_endpoint = CreateIssue::builder()
260                .project_id(project_id)
261                .subject("Test issue 1")
262                .build()?;
263            let IssueWrapper { issue: issue1 }: IssueWrapper<Issue> =
264                redmine.json_response_body::<_, _>(&create_issue1_endpoint)?;
265            let create_issue2_endpoint = CreateIssue::builder()
266                .project_id(project_id)
267                .subject("Test issue 2")
268                .build()?;
269            let IssueWrapper { issue: issue2 }: IssueWrapper<Issue> =
270                redmine.json_response_body::<_, _>(&create_issue2_endpoint)?;
271            let create_endpoint = super::CreateIssueRelation::builder()
272                .issue_id(issue1.id)
273                .issue_to_id(issue2.id)
274                .relation_type(IssueRelationType::Relates)
275                .build()?;
276            redmine.json_response_body::<_, RelationWrapper<IssueRelation>>(&create_endpoint)?;
277            Ok(())
278        })?;
279        Ok(())
280    }
281
282    #[function_name::named]
283    #[traced_test]
284    #[test]
285    fn test_delete_issue_relation() -> Result<(), Box<dyn Error>> {
286        let _w_issues = ISSUES_LOCK.write();
287        let _w_issue_relation = ISSUE_RELATION_LOCK.write();
288        let name = format!("unittest_{}", function_name!());
289        with_project(&name, |redmine, project_id, _name| {
290            let create_issue1_endpoint = CreateIssue::builder()
291                .project_id(project_id)
292                .subject("Test issue 1")
293                .build()?;
294            let IssueWrapper { issue: issue1 }: IssueWrapper<Issue> =
295                redmine.json_response_body::<_, _>(&create_issue1_endpoint)?;
296            let create_issue2_endpoint = CreateIssue::builder()
297                .project_id(project_id)
298                .subject("Test issue 2")
299                .build()?;
300            let IssueWrapper { issue: issue2 }: IssueWrapper<Issue> =
301                redmine.json_response_body::<_, _>(&create_issue2_endpoint)?;
302            let create_endpoint = super::CreateIssueRelation::builder()
303                .issue_id(issue1.id)
304                .issue_to_id(issue2.id)
305                .relation_type(IssueRelationType::Relates)
306                .build()?;
307            let RelationWrapper { relation }: RelationWrapper<IssueRelation> =
308                redmine.json_response_body::<_, _>(&create_endpoint)?;
309            let id = relation.id;
310            let delete_endpoint = super::DeleteIssueRelation::builder().id(id).build()?;
311            redmine.ignore_response_body::<_>(&delete_endpoint)?;
312            Ok(())
313        })?;
314        Ok(())
315    }
316
317    /// this tests if any of the results contain a field we are not deserializing
318    ///
319    /// this will only catch fields we missed if they are part of the response but
320    /// it is better than nothing
321    #[traced_test]
322    #[test]
323    fn test_completeness_issue_relation_type() -> Result<(), Box<dyn Error>> {
324        let _r_issue_relation = ISSUE_RELATION_LOCK.read();
325        dotenvy::dotenv()?;
326        let redmine = crate::api::Redmine::from_env(
327            reqwest::blocking::Client::builder()
328                .use_rustls_tls()
329                .build()?,
330        )?;
331        let endpoint = ListIssueRelations::builder().issue_id(50017).build()?;
332        let RelationsWrapper { relations: values } =
333            redmine.json_response_body::<_, RelationsWrapper<serde_json::Value>>(&endpoint)?;
334        for value in values {
335            let o: IssueRelation = serde_json::from_value(value.clone())?;
336            let reserialized = serde_json::to_value(o)?;
337            assert_eq!(value, reserialized);
338        }
339        Ok(())
340    }
341}