Skip to main content

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::custom_fields::CustomField;
16use crate::api::custom_fields::CustomFieldEssentialsWithValue;
17use crate::api::projects::ProjectEssentials;
18use crate::api::{Endpoint, NoPagination, ReturnsJsonResponse};
19use serde::Serialize;
20
21/// a minimal type for Redmine versions included in
22/// other Redmine objects
23#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub struct VersionEssentials {
25    /// numeric id
26    pub id: u64,
27    /// display name
28    pub name: String,
29}
30
31impl From<Version> for VersionEssentials {
32    fn from(v: Version) -> Self {
33        VersionEssentials {
34            id: v.id,
35            name: v.name,
36        }
37    }
38}
39
40impl From<&Version> for VersionEssentials {
41    fn from(v: &Version) -> Self {
42        VersionEssentials {
43            id: v.id,
44            name: v.name.to_owned(),
45        }
46    }
47}
48
49/// a type for version to use as an API return type
50///
51/// alternatively you can use your own type limited to the fields you need
52#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
53pub struct Version {
54    /// numeric id
55    pub id: u64,
56    /// display name
57    pub name: String,
58    /// project
59    pub project: ProjectEssentials,
60    /// description
61    pub description: String,
62    /// version status
63    pub status: VersionStatus,
64    /// version due date
65    pub due_date: Option<time::Date>,
66    /// version sharing between projects
67    pub sharing: VersionSharing,
68    /// The time when this version was created
69    #[serde(
70        serialize_with = "crate::api::serialize_rfc3339",
71        deserialize_with = "crate::api::deserialize_rfc3339"
72    )]
73    pub created_on: time::OffsetDateTime,
74    /// The time when this version was last updated
75    #[serde(
76        serialize_with = "crate::api::serialize_rfc3339",
77        deserialize_with = "crate::api::deserialize_rfc3339"
78    )]
79    pub updated_on: time::OffsetDateTime,
80    /// The title of the wiki page for this version
81    #[serde(default)]
82    wiki_page_title: Option<String>,
83    /// custom fields with values
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
86}
87
88/// The endpoint for all versions in a Redmine project
89#[derive(Debug, Clone, Builder)]
90#[builder(setter(strip_option))]
91pub struct ListVersions<'a> {
92    /// The project Id or the project name as it appears in the URL for the project whose versions we want to list
93    #[builder(setter(into))]
94    project_id_or_name: Cow<'a, str>,
95}
96
97impl ReturnsJsonResponse for ListVersions<'_> {}
98impl NoPagination for ListVersions<'_> {}
99
100impl<'a> ListVersions<'a> {
101    /// Create a builder for the endpoint.
102    #[must_use]
103    pub fn builder() -> ListVersionsBuilder<'a> {
104        ListVersionsBuilder::default()
105    }
106}
107
108impl Endpoint for ListVersions<'_> {
109    fn method(&self) -> Method {
110        Method::GET
111    }
112
113    fn endpoint(&self) -> Cow<'static, str> {
114        format!("projects/{}/versions.json", self.project_id_or_name).into()
115    }
116}
117
118/// The endpoint for a specific Redmine project version
119#[derive(Debug, Clone, Builder)]
120#[builder(setter(strip_option))]
121pub struct GetVersion {
122    /// the id of the version to retrieve
123    id: u64,
124}
125
126impl ReturnsJsonResponse for GetVersion {}
127impl NoPagination for GetVersion {}
128
129impl GetVersion {
130    /// Create a builder for the endpoint.
131    #[must_use]
132    pub fn builder() -> GetVersionBuilder {
133        GetVersionBuilder::default()
134    }
135}
136
137impl Endpoint for GetVersion {
138    fn method(&self) -> Method {
139        Method::GET
140    }
141
142    fn endpoint(&self) -> Cow<'static, str> {
143        format!("versions/{}.json", &self.id).into()
144    }
145}
146
147/// The status of a version restricts if issues can be assigned to this
148/// version and if assigned issues can be reopened
149#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, Serialize)]
150#[serde(rename_all = "snake_case")]
151pub enum VersionStatus {
152    /// no restrictions, default
153    Open,
154    /// can not assign new issues to the version
155    Locked,
156    /// can not assign new issues and can not reopen assigned issues
157    Closed,
158}
159
160/// Version sharing determines the cross-project visibility of the version
161#[derive(Debug, Clone, serde::Deserialize, Serialize)]
162#[serde(rename_all = "snake_case")]
163pub enum VersionSharing {
164    /// default
165    None,
166    /// only descendant projects in the hierarchy can see the project's version
167    Descendants,
168    /// descendant projects and ancestor projects in the hierarchy can see the project's version
169    Hierarchy,
170    /// descendant projects, ancestor projects and other projects in the same tree can see the project's version
171    Tree,
172    /// versions can be seen by all projects in the Redmine instance
173    System,
174}
175
176/// Possible statuses for a version
177#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
178#[serde(untagged)]
179pub enum VersionStatusFilter {
180    /// Any version status
181    #[serde(serialize_with = "serialize_any_operator")]
182    Any,
183    /// No version status
184    #[serde(serialize_with = "serialize_none_operator")]
185    None,
186    /// These specific version statuses
187    TheseStatuses(Vec<VersionStatus>),
188    /// Not these specific version statuses
189    NotTheseStatuses(Vec<VersionStatus>),
190}
191
192/// Helper functions for serializing "Any" and "None"
193///
194/// # Errors
195///
196/// returns an error if the serializer does
197fn serialize_any_operator<S>(serializer: S) -> Result<S::Ok, S::Error>
198where
199    S: serde::Serializer,
200{
201    serializer.serialize_str("*")
202}
203
204/// Helper function to serialize `None` values as an empty string.
205///
206/// # Errors
207///
208/// returns an error if the serializer does
209fn serialize_none_operator<S>(serializer: S) -> Result<S::Ok, S::Error>
210where
211    S: serde::Serializer,
212{
213    serializer.serialize_str("!*")
214}
215/// The endpoint to create a Redmine project version
216#[serde_with::skip_serializing_none]
217#[derive(Debug, Clone, Builder, Serialize)]
218#[builder(setter(strip_option))]
219pub struct CreateVersion<'a> {
220    /// The project Id or the project name as it appears in the URL to add the version to
221    #[builder(setter(into))]
222    #[serde(skip_serializing)]
223    project_id_or_name: Cow<'a, str>,
224    /// display name
225    #[builder(setter(into))]
226    name: Cow<'a, str>,
227    /// the status of the version
228    #[builder(default)]
229    status: Option<VersionStatus>,
230    /// how the version is shared with other projects
231    #[builder(default)]
232    sharing: Option<VersionSharing>,
233    /// when the version is due to be released
234    #[builder(default)]
235    due_date: Option<time::Date>,
236    /// Description of the version
237    #[builder(default)]
238    description: Option<Cow<'a, str>>,
239    /// The title of the wiki page for this version
240    #[builder(default)]
241    wiki_page_title: Option<Cow<'a, str>>,
242    /// custom field values
243    #[builder(default)]
244    custom_fields: Option<Vec<CustomField<'a>>>,
245    /// set this version as the default for the project
246    #[builder(default)]
247    default_project_version: Option<bool>,
248}
249
250impl ReturnsJsonResponse for CreateVersion<'_> {}
251impl NoPagination for CreateVersion<'_> {}
252
253impl<'a> CreateVersion<'a> {
254    /// Create a builder for the endpoint.
255    #[must_use]
256    pub fn builder() -> CreateVersionBuilder<'a> {
257        CreateVersionBuilder::default()
258    }
259}
260
261impl Endpoint for CreateVersion<'_> {
262    fn method(&self) -> Method {
263        Method::POST
264    }
265
266    fn endpoint(&self) -> Cow<'static, str> {
267        format!("projects/{}/versions.json", self.project_id_or_name).into()
268    }
269
270    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
271        Ok(Some((
272            "application/json",
273            serde_json::to_vec(&VersionWrapper::<CreateVersion> {
274                version: (*self).to_owned(),
275            })?,
276        )))
277    }
278}
279
280/// The endpoint to update an existing Redmine project version
281#[serde_with::skip_serializing_none]
282#[derive(Debug, Clone, Builder, Serialize)]
283#[builder(setter(strip_option))]
284pub struct UpdateVersion<'a> {
285    /// The id of the version to update
286    #[serde(skip_serializing)]
287    id: u64,
288    /// display name
289    #[builder(default, setter(into))]
290    name: Option<Cow<'a, str>>,
291    /// the status of the version
292    #[builder(default)]
293    status: Option<VersionStatus>,
294    /// how the version is shared with other projects
295    #[builder(default)]
296    sharing: Option<VersionSharing>,
297    /// when the version is due to be released
298    #[builder(default)]
299    due_date: Option<time::Date>,
300    /// Description of the version
301    #[builder(default)]
302    description: Option<Cow<'a, str>>,
303    /// The title of the wiki page for this version
304    #[builder(default)]
305    wiki_page_title: Option<Cow<'a, str>>,
306    /// custom field values
307    #[builder(default)]
308    custom_fields: Option<Vec<CustomField<'a>>>,
309    /// set this version as the default for the project
310    #[builder(default)]
311    default_project_version: Option<bool>,
312}
313
314impl<'a> UpdateVersion<'a> {
315    /// Create a builder for the endpoint.
316    #[must_use]
317    pub fn builder() -> UpdateVersionBuilder<'a> {
318        UpdateVersionBuilder::default()
319    }
320}
321
322impl Endpoint for UpdateVersion<'_> {
323    fn method(&self) -> Method {
324        Method::PUT
325    }
326
327    fn endpoint(&self) -> Cow<'static, str> {
328        format!("versions/{}.json", self.id).into()
329    }
330
331    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
332        Ok(Some((
333            "application/json",
334            serde_json::to_vec(&VersionWrapper::<UpdateVersion> {
335                version: (*self).to_owned(),
336            })?,
337        )))
338    }
339}
340
341/// The endpoint to delete a version in a Redmine project
342#[derive(Debug, Clone, Builder)]
343#[builder(setter(strip_option))]
344pub struct DeleteVersion {
345    /// The id of the version to delete
346    id: u64,
347}
348
349impl DeleteVersion {
350    /// Create a builder for the endpoint.
351    #[must_use]
352    pub fn builder() -> DeleteVersionBuilder {
353        DeleteVersionBuilder::default()
354    }
355}
356
357impl Endpoint for DeleteVersion {
358    fn method(&self) -> Method {
359        Method::DELETE
360    }
361
362    fn endpoint(&self) -> Cow<'static, str> {
363        format!("versions/{}.json", &self.id).into()
364    }
365}
366
367/// The endpoint to close a version and move its open issues to the next open version
368#[derive(Debug, Clone, Builder)]
369#[builder(setter(strip_option))]
370pub struct CloseCompletedVersion {
371    /// The id of the version to close
372    id: u64,
373}
374
375impl CloseCompletedVersion {
376    /// Create a builder for the endpoint.
377    #[must_use]
378    pub fn builder() -> CloseCompletedVersionBuilder {
379        CloseCompletedVersionBuilder::default()
380    }
381}
382
383impl Endpoint for CloseCompletedVersion {
384    fn method(&self) -> Method {
385        Method::POST
386    }
387
388    fn endpoint(&self) -> Cow<'static, str> {
389        format!("versions/{}/close_completed.json", &self.id).into()
390    }
391}
392
393/// helper struct for outer layers with a versions field holding the inner data
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
395pub struct VersionsWrapper<T> {
396    /// to parse JSON with versions key
397    pub versions: Vec<T>,
398}
399
400/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
401/// helper struct for outer layers with a version field holding the inner data
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
403pub struct VersionWrapper<T> {
404    /// to parse JSON with version key
405    pub version: T,
406}
407
408#[cfg(test)]
409mod test {
410    use super::*;
411    use crate::api::custom_fields::{
412        CustomFieldDefinition, CustomFieldsWrapper, CustomizedType, ListCustomFields,
413    };
414    use crate::api::test_helpers::with_project;
415    use pretty_assertions::assert_eq;
416    use std::error::Error;
417    use tokio::sync::RwLock;
418    use tracing_test::traced_test;
419
420    /// needed so we do not get 404s when listing while
421    /// creating/deleting or creating/updating/deleting
422    static VERSION_LOCK: RwLock<()> = RwLock::const_new(());
423
424    #[traced_test]
425    #[test]
426    fn test_list_versions_no_pagination() -> Result<(), Box<dyn Error>> {
427        let _r_version = VERSION_LOCK.blocking_read();
428        dotenvy::dotenv()?;
429        let redmine = crate::api::Redmine::from_env(
430            reqwest::blocking::Client::builder()
431                .tls_backend_rustls()
432                .build()?,
433        )?;
434        let endpoint = ListVersions::builder().project_id_or_name("92").build()?;
435        redmine.json_response_body::<_, VersionsWrapper<Version>>(&endpoint)?;
436        Ok(())
437    }
438
439    #[traced_test]
440    #[test]
441    fn test_get_version() -> Result<(), Box<dyn Error>> {
442        let _r_version = VERSION_LOCK.blocking_read();
443        dotenvy::dotenv()?;
444        let redmine = crate::api::Redmine::from_env(
445            reqwest::blocking::Client::builder()
446                .tls_backend_rustls()
447                .build()?,
448        )?;
449        let endpoint = GetVersion::builder().id(1182).build()?;
450        redmine.json_response_body::<_, VersionWrapper<Version>>(&endpoint)?;
451        Ok(())
452    }
453
454    #[function_name::named]
455    #[traced_test]
456    #[test]
457    fn test_create_update_version_with_custom_fields() -> Result<(), Box<dyn Error>> {
458        let _w_version = VERSION_LOCK.blocking_write();
459        let name = format!("unittest_{}", function_name!());
460        with_project(&name, |redmine, project_id, _name| {
461            // Find a custom field for versions
462            let list_custom_fields_endpoint = ListCustomFields::builder().build()?;
463            let CustomFieldsWrapper { custom_fields } = redmine
464                .json_response_body::<_, CustomFieldsWrapper<CustomFieldDefinition>>(
465                    &list_custom_fields_endpoint,
466                )?;
467
468            let version_custom_field = custom_fields
469                .into_iter()
470                .find(|cf| cf.customized_type == CustomizedType::Version);
471
472            let custom_field_id = if let Some(cf) = version_custom_field {
473                cf.id
474            } else {
475                // If no custom field for versions is found, skip the test
476                eprintln!("No custom field of type Version found. Skipping test.");
477                return Ok(());
478            };
479
480            let create_endpoint = CreateVersion::builder()
481                .project_id_or_name(project_id.to_string())
482                .name("Test Version with Custom Fields")
483                .custom_fields(vec![CustomField {
484                    id: custom_field_id,
485                    name: Some(Cow::Borrowed("VersionCustomField")),
486                    value: Cow::Borrowed("Custom Value 1"),
487                }])
488                .build()?;
489            let VersionWrapper { version } =
490                redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
491
492            assert_eq!(version.name, "Test Version with Custom Fields");
493            assert_eq!(
494                version.custom_fields.unwrap()[0].value.as_ref().unwrap()[0],
495                "Custom Value 1"
496            );
497
498            let update_endpoint = UpdateVersion::builder()
499                .id(version.id)
500                .name("Updated Test Version with Custom Fields")
501                .custom_fields(vec![CustomField {
502                    id: custom_field_id,
503                    name: Some(Cow::Borrowed("VersionCustomField")),
504                    value: Cow::Borrowed("Updated Custom Value 1"),
505                }])
506                .build()?;
507            redmine.ignore_response_body::<_>(&update_endpoint)?;
508
509            let get_endpoint = GetVersion::builder().id(version.id).build()?;
510            let VersionWrapper {
511                version: updated_version,
512            } = redmine.json_response_body::<_, VersionWrapper<Version>>(&get_endpoint)?;
513
514            assert_eq!(
515                updated_version.name,
516                "Updated Test Version with Custom Fields"
517            );
518            assert_eq!(
519                updated_version.custom_fields.unwrap()[0]
520                    .value
521                    .as_ref()
522                    .unwrap()[0],
523                "Updated Custom Value 1"
524            );
525            Ok(())
526        })?;
527        Ok(())
528    }
529
530    #[function_name::named]
531    #[traced_test]
532    #[test]
533    fn test_create_version_with_default_project_version() -> Result<(), Box<dyn Error>> {
534        let _w_version = VERSION_LOCK.blocking_write();
535        let name = format!("unittest_{}", function_name!());
536        with_project(&name, |redmine, project_id, name| {
537            let create_endpoint = CreateVersion::builder()
538                .project_id_or_name(name)
539                .name("Default Version")
540                .default_project_version(true)
541                .build()?;
542            redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
543
544            let project_endpoint = crate::api::projects::GetProject::builder()
545                .project_id_or_name(project_id.to_string())
546                .build()?;
547            let project_wrapper: crate::api::projects::ProjectWrapper<
548                crate::api::projects::Project,
549            > = redmine.json_response_body(&project_endpoint)?;
550            assert_eq!(
551                project_wrapper.project.default_version.unwrap().name,
552                "Default Version"
553            );
554            Ok(())
555        })?;
556        Ok(())
557    }
558
559    #[function_name::named]
560    #[traced_test]
561    #[test]
562    fn test_update_version_with_default_project_version() -> Result<(), Box<dyn Error>> {
563        let _w_version = VERSION_LOCK.blocking_write();
564        let name = format!("unittest_{}", function_name!());
565        with_project(&name, |redmine, project_id, name| {
566            let create_endpoint = CreateVersion::builder()
567                .project_id_or_name(name)
568                .name("Non-Default Version")
569                .build()?;
570            let VersionWrapper { version } =
571                redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
572
573            let update_endpoint = super::UpdateVersion::builder()
574                .id(version.id)
575                .default_project_version(true)
576                .build()?;
577            redmine.ignore_response_body::<_>(&update_endpoint)?;
578
579            let project_endpoint = crate::api::projects::GetProject::builder()
580                .project_id_or_name(project_id.to_string())
581                .build()?;
582            let project_wrapper: crate::api::projects::ProjectWrapper<
583                crate::api::projects::Project,
584            > = redmine.json_response_body(&project_endpoint)?;
585            assert_eq!(
586                project_wrapper.project.default_version.unwrap().name,
587                "Non-Default Version"
588            );
589            Ok(())
590        })?;
591        Ok(())
592    }
593}