redmine_api/api/
versions.rs

1//! Versions Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_Versions)
4//!
5//! - [x] project specific versions endpoint
6//! - [x] specific version endpoint
7//! - [x] create version endpoint
8//! - [x] update version endpoint
9//! - [x] delete version endpoint
10
11use derive_builder::Builder;
12use reqwest::Method;
13use std::borrow::Cow;
14
15use crate::api::projects::ProjectEssentials;
16use crate::api::{Endpoint, NoPagination, ReturnsJsonResponse};
17use serde::Serialize;
18
19/// a minimal type for Redmine versions included in
20/// other Redmine objects
21#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22pub struct VersionEssentials {
23    /// numeric id
24    pub id: u64,
25    /// display name
26    pub name: String,
27}
28
29impl From<Version> for VersionEssentials {
30    fn from(v: Version) -> Self {
31        VersionEssentials {
32            id: v.id,
33            name: v.name,
34        }
35    }
36}
37
38impl From<&Version> for VersionEssentials {
39    fn from(v: &Version) -> Self {
40        VersionEssentials {
41            id: v.id,
42            name: v.name.to_owned(),
43        }
44    }
45}
46
47/// a type for version to use as an API return type
48///
49/// alternatively you can use your own type limited to the fields you need
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51pub struct Version {
52    /// numeric id
53    pub id: u64,
54    /// display name
55    pub name: String,
56    /// project
57    pub project: ProjectEssentials,
58    /// description
59    pub description: String,
60    /// version status
61    pub status: VersionStatus,
62    /// version due date
63    pub due_date: Option<time::Date>,
64    /// version sharing between projects
65    pub sharing: VersionSharing,
66    /// The time when this version was created
67    #[serde(
68        serialize_with = "crate::api::serialize_rfc3339",
69        deserialize_with = "crate::api::deserialize_rfc3339"
70    )]
71    pub created_on: time::OffsetDateTime,
72    /// The time when this version was last updated
73    #[serde(
74        serialize_with = "crate::api::serialize_rfc3339",
75        deserialize_with = "crate::api::deserialize_rfc3339"
76    )]
77    pub updated_on: time::OffsetDateTime,
78    /// The title of the wiki page for this version
79    #[serde(default)]
80    wiki_page_title: Option<String>,
81}
82
83/// The endpoint for all versions in a Redmine project
84#[derive(Debug, Clone, Builder)]
85#[builder(setter(strip_option))]
86pub struct ListVersions<'a> {
87    /// The project Id or the project name as it appears in the URL for the project whose versions we want to list
88    #[builder(setter(into))]
89    project_id_or_name: Cow<'a, str>,
90}
91
92impl ReturnsJsonResponse for ListVersions<'_> {}
93impl NoPagination for ListVersions<'_> {}
94
95impl<'a> ListVersions<'a> {
96    /// Create a builder for the endpoint.
97    #[must_use]
98    pub fn builder() -> ListVersionsBuilder<'a> {
99        ListVersionsBuilder::default()
100    }
101}
102
103impl Endpoint for ListVersions<'_> {
104    fn method(&self) -> Method {
105        Method::GET
106    }
107
108    fn endpoint(&self) -> Cow<'static, str> {
109        format!("projects/{}/versions.json", self.project_id_or_name).into()
110    }
111}
112
113/// The endpoint for a specific Redmine project version
114#[derive(Debug, Clone, Builder)]
115#[builder(setter(strip_option))]
116pub struct GetVersion {
117    /// the id of the version to retrieve
118    id: u64,
119}
120
121impl ReturnsJsonResponse for GetVersion {}
122impl NoPagination for GetVersion {}
123
124impl GetVersion {
125    /// Create a builder for the endpoint.
126    #[must_use]
127    pub fn builder() -> GetVersionBuilder {
128        GetVersionBuilder::default()
129    }
130}
131
132impl Endpoint for GetVersion {
133    fn method(&self) -> Method {
134        Method::GET
135    }
136
137    fn endpoint(&self) -> Cow<'static, str> {
138        format!("versions/{}.json", &self.id).into()
139    }
140}
141
142/// The status of a version restricts if issues can be assigned to this
143/// version and if assigned issues can be reopened
144#[derive(Debug, Clone, serde::Deserialize, Serialize)]
145#[serde(rename_all = "snake_case")]
146pub enum VersionStatus {
147    /// no restrictions, default
148    Open,
149    /// can not assign new issues to the version
150    Locked,
151    /// can not assign new issues and can not reopen assigned issues
152    Closed,
153}
154
155/// Version sharing determines the cross-project visibility of the version
156#[derive(Debug, Clone, serde::Deserialize, Serialize)]
157#[serde(rename_all = "snake_case")]
158pub enum VersionSharing {
159    /// default
160    None,
161    /// only descendant projects in the hierarchy can see the project's version
162    Descendants,
163    /// descendant projects and ancestor projects in the hierarchy can see the project's version
164    Hierarchy,
165    /// descendant projects, ancestor projects and other projects in the same tree can see the project's version
166    Tree,
167    /// versions can be seen by all projects in the Redmine instance
168    System,
169}
170
171/// The endpoint to create a Redmine project version
172#[serde_with::skip_serializing_none]
173#[derive(Debug, Clone, Builder, Serialize)]
174#[builder(setter(strip_option))]
175pub struct CreateVersion<'a> {
176    /// The project Id or the project name as it appears in the URL to add the version to
177    #[builder(setter(into))]
178    #[serde(skip_serializing)]
179    project_id_or_name: Cow<'a, str>,
180    /// display name
181    #[builder(setter(into))]
182    name: Cow<'a, str>,
183    /// the status of the version
184    #[builder(default)]
185    status: Option<VersionStatus>,
186    /// how the version is shared with other projects
187    #[builder(default)]
188    sharing: Option<VersionSharing>,
189    /// when the version is due to be released
190    #[builder(default)]
191    due_date: Option<time::Date>,
192    /// Description of the version
193    #[builder(default)]
194    description: Option<Cow<'a, str>>,
195    /// The title of the wiki page for this version
196    #[builder(default)]
197    wiki_page_title: Option<Cow<'a, str>>,
198}
199
200impl ReturnsJsonResponse for CreateVersion<'_> {}
201impl NoPagination for CreateVersion<'_> {}
202
203impl<'a> CreateVersion<'a> {
204    /// Create a builder for the endpoint.
205    #[must_use]
206    pub fn builder() -> CreateVersionBuilder<'a> {
207        CreateVersionBuilder::default()
208    }
209}
210
211impl Endpoint for CreateVersion<'_> {
212    fn method(&self) -> Method {
213        Method::POST
214    }
215
216    fn endpoint(&self) -> Cow<'static, str> {
217        format!("projects/{}/versions.json", self.project_id_or_name).into()
218    }
219
220    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
221        Ok(Some((
222            "application/json",
223            serde_json::to_vec(&VersionWrapper::<CreateVersion> {
224                version: (*self).to_owned(),
225            })?,
226        )))
227    }
228}
229
230/// The endpoint to update an existing Redmine project version
231#[serde_with::skip_serializing_none]
232#[derive(Debug, Clone, Builder, Serialize)]
233#[builder(setter(strip_option))]
234pub struct UpdateVersion<'a> {
235    /// The id of the version to update
236    #[serde(skip_serializing)]
237    id: u64,
238    /// display name
239    #[builder(default, setter(into))]
240    name: Option<Cow<'a, str>>,
241    /// the status of the version
242    #[builder(default)]
243    status: Option<VersionStatus>,
244    /// how the version is shared with other projects
245    #[builder(default)]
246    sharing: Option<VersionSharing>,
247    /// when the version is due to be released
248    #[builder(default)]
249    due_date: Option<time::Date>,
250    /// Description of the version
251    #[builder(default)]
252    description: Option<Cow<'a, str>>,
253    /// The title of the wiki page for this version
254    #[builder(default)]
255    wiki_page_title: Option<Cow<'a, str>>,
256}
257
258impl<'a> UpdateVersion<'a> {
259    /// Create a builder for the endpoint.
260    #[must_use]
261    pub fn builder() -> UpdateVersionBuilder<'a> {
262        UpdateVersionBuilder::default()
263    }
264}
265
266impl Endpoint for UpdateVersion<'_> {
267    fn method(&self) -> Method {
268        Method::PUT
269    }
270
271    fn endpoint(&self) -> Cow<'static, str> {
272        format!("versions/{}.json", self.id).into()
273    }
274
275    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
276        Ok(Some((
277            "application/json",
278            serde_json::to_vec(&VersionWrapper::<UpdateVersion> {
279                version: (*self).to_owned(),
280            })?,
281        )))
282    }
283}
284
285/// The endpoint to delete a version in a Redmine project
286#[derive(Debug, Clone, Builder)]
287#[builder(setter(strip_option))]
288pub struct DeleteVersion {
289    /// The id of the version to delete
290    id: u64,
291}
292
293impl DeleteVersion {
294    /// Create a builder for the endpoint.
295    #[must_use]
296    pub fn builder() -> DeleteVersionBuilder {
297        DeleteVersionBuilder::default()
298    }
299}
300
301impl Endpoint for DeleteVersion {
302    fn method(&self) -> Method {
303        Method::DELETE
304    }
305
306    fn endpoint(&self) -> Cow<'static, str> {
307        format!("versions/{}.json", &self.id).into()
308    }
309}
310
311/// helper struct for outer layers with a versions field holding the inner data
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
313pub struct VersionsWrapper<T> {
314    /// to parse JSON with versions key
315    pub versions: Vec<T>,
316}
317
318/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
319/// helper struct for outer layers with a version field holding the inner data
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
321pub struct VersionWrapper<T> {
322    /// to parse JSON with version key
323    pub version: T,
324}
325
326#[cfg(test)]
327mod test {
328    use super::*;
329    use crate::api::test_helpers::with_project;
330    use pretty_assertions::assert_eq;
331    use std::error::Error;
332    use tokio::sync::RwLock;
333    use tracing_test::traced_test;
334
335    /// needed so we do not get 404s when listing while
336    /// creating/deleting or creating/updating/deleting
337    static VERSION_LOCK: RwLock<()> = RwLock::const_new(());
338
339    #[traced_test]
340    #[test]
341    fn test_list_versions_no_pagination() -> Result<(), Box<dyn Error>> {
342        let _r_versions = VERSION_LOCK.read();
343        dotenvy::dotenv()?;
344        let redmine = crate::api::Redmine::from_env(
345            reqwest::blocking::Client::builder()
346                .use_rustls_tls()
347                .build()?,
348        )?;
349        let endpoint = ListVersions::builder().project_id_or_name("92").build()?;
350        redmine.json_response_body::<_, VersionsWrapper<Version>>(&endpoint)?;
351        Ok(())
352    }
353
354    #[traced_test]
355    #[test]
356    fn test_get_version() -> Result<(), Box<dyn Error>> {
357        let _r_versions = VERSION_LOCK.read();
358        dotenvy::dotenv()?;
359        let redmine = crate::api::Redmine::from_env(
360            reqwest::blocking::Client::builder()
361                .use_rustls_tls()
362                .build()?,
363        )?;
364        let endpoint = GetVersion::builder().id(1182).build()?;
365        redmine.json_response_body::<_, VersionWrapper<Version>>(&endpoint)?;
366        Ok(())
367    }
368
369    #[function_name::named]
370    #[traced_test]
371    #[test]
372    fn test_create_version() -> Result<(), Box<dyn Error>> {
373        let _w_versions = VERSION_LOCK.write();
374        let name = format!("unittest_{}", function_name!());
375        with_project(&name, |redmine, _, name| {
376            let create_endpoint = CreateVersion::builder()
377                .project_id_or_name(name)
378                .name("Test Version")
379                .build()?;
380            redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
381            Ok(())
382        })?;
383        Ok(())
384    }
385
386    #[function_name::named]
387    #[traced_test]
388    #[test]
389    fn test_update_version() -> Result<(), Box<dyn Error>> {
390        let _w_versions = VERSION_LOCK.write();
391        let name = format!("unittest_{}", function_name!());
392        with_project(&name, |redmine, _, name| {
393            let create_endpoint = CreateVersion::builder()
394                .project_id_or_name(name)
395                .name("Test Version")
396                .build()?;
397            let VersionWrapper { version } =
398                redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
399            let update_endpoint = super::UpdateVersion::builder()
400                .id(version.id)
401                .name("Neue Test-Version")
402                .build()?;
403            redmine.ignore_response_body::<_>(&update_endpoint)?;
404            Ok(())
405        })?;
406        Ok(())
407    }
408
409    /// this tests if any of the results contain a field we are not deserializing
410    ///
411    /// this will only catch fields we missed if they are part of the response but
412    /// it is better than nothing
413    #[traced_test]
414    #[test]
415    fn test_completeness_version_type() -> Result<(), Box<dyn Error>> {
416        let _r_versions = VERSION_LOCK.read();
417        dotenvy::dotenv()?;
418        let redmine = crate::api::Redmine::from_env(
419            reqwest::blocking::Client::builder()
420                .use_rustls_tls()
421                .build()?,
422        )?;
423        let endpoint = ListVersions::builder().project_id_or_name("92").build()?;
424        let VersionsWrapper { versions: values } =
425            redmine.json_response_body::<_, VersionsWrapper<serde_json::Value>>(&endpoint)?;
426        for value in values {
427            let o: Version = serde_json::from_value(value.clone())?;
428            let reserialized = serde_json::to_value(o)?;
429            assert_eq!(value, reserialized);
430        }
431        Ok(())
432    }
433}