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"
193fn serialize_any_operator<S>(serializer: S) -> Result<S::Ok, S::Error>
194where
195    S: serde::Serializer,
196{
197    serializer.serialize_str("*")
198}
199
200/// Helper function to serialize `None` values as an empty string.
201fn serialize_none_operator<S>(serializer: S) -> Result<S::Ok, S::Error>
202where
203    S: serde::Serializer,
204{
205    serializer.serialize_str("!*")
206}
207/// The endpoint to create a Redmine project version
208#[serde_with::skip_serializing_none]
209#[derive(Debug, Clone, Builder, Serialize)]
210#[builder(setter(strip_option))]
211pub struct CreateVersion<'a> {
212    /// The project Id or the project name as it appears in the URL to add the version to
213    #[builder(setter(into))]
214    #[serde(skip_serializing)]
215    project_id_or_name: Cow<'a, str>,
216    /// display name
217    #[builder(setter(into))]
218    name: Cow<'a, str>,
219    /// the status of the version
220    #[builder(default)]
221    status: Option<VersionStatus>,
222    /// how the version is shared with other projects
223    #[builder(default)]
224    sharing: Option<VersionSharing>,
225    /// when the version is due to be released
226    #[builder(default)]
227    due_date: Option<time::Date>,
228    /// Description of the version
229    #[builder(default)]
230    description: Option<Cow<'a, str>>,
231    /// The title of the wiki page for this version
232    #[builder(default)]
233    wiki_page_title: Option<Cow<'a, str>>,
234    /// custom field values
235    #[builder(default)]
236    custom_fields: Option<Vec<CustomField<'a>>>,
237    /// set this version as the default for the project
238    #[builder(default)]
239    default_project_version: Option<bool>,
240}
241
242impl ReturnsJsonResponse for CreateVersion<'_> {}
243impl NoPagination for CreateVersion<'_> {}
244
245impl<'a> CreateVersion<'a> {
246    /// Create a builder for the endpoint.
247    #[must_use]
248    pub fn builder() -> CreateVersionBuilder<'a> {
249        CreateVersionBuilder::default()
250    }
251}
252
253impl Endpoint for CreateVersion<'_> {
254    fn method(&self) -> Method {
255        Method::POST
256    }
257
258    fn endpoint(&self) -> Cow<'static, str> {
259        format!("projects/{}/versions.json", self.project_id_or_name).into()
260    }
261
262    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
263        Ok(Some((
264            "application/json",
265            serde_json::to_vec(&VersionWrapper::<CreateVersion> {
266                version: (*self).to_owned(),
267            })?,
268        )))
269    }
270}
271
272/// The endpoint to update an existing Redmine project version
273#[serde_with::skip_serializing_none]
274#[derive(Debug, Clone, Builder, Serialize)]
275#[builder(setter(strip_option))]
276pub struct UpdateVersion<'a> {
277    /// The id of the version to update
278    #[serde(skip_serializing)]
279    id: u64,
280    /// display name
281    #[builder(default, setter(into))]
282    name: Option<Cow<'a, str>>,
283    /// the status of the version
284    #[builder(default)]
285    status: Option<VersionStatus>,
286    /// how the version is shared with other projects
287    #[builder(default)]
288    sharing: Option<VersionSharing>,
289    /// when the version is due to be released
290    #[builder(default)]
291    due_date: Option<time::Date>,
292    /// Description of the version
293    #[builder(default)]
294    description: Option<Cow<'a, str>>,
295    /// The title of the wiki page for this version
296    #[builder(default)]
297    wiki_page_title: Option<Cow<'a, str>>,
298    /// custom field values
299    #[builder(default)]
300    custom_fields: Option<Vec<CustomField<'a>>>,
301    /// set this version as the default for the project
302    #[builder(default)]
303    default_project_version: Option<bool>,
304}
305
306impl<'a> UpdateVersion<'a> {
307    /// Create a builder for the endpoint.
308    #[must_use]
309    pub fn builder() -> UpdateVersionBuilder<'a> {
310        UpdateVersionBuilder::default()
311    }
312}
313
314impl Endpoint for UpdateVersion<'_> {
315    fn method(&self) -> Method {
316        Method::PUT
317    }
318
319    fn endpoint(&self) -> Cow<'static, str> {
320        format!("versions/{}.json", self.id).into()
321    }
322
323    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
324        Ok(Some((
325            "application/json",
326            serde_json::to_vec(&VersionWrapper::<UpdateVersion> {
327                version: (*self).to_owned(),
328            })?,
329        )))
330    }
331}
332
333/// The endpoint to delete a version in a Redmine project
334#[derive(Debug, Clone, Builder)]
335#[builder(setter(strip_option))]
336pub struct DeleteVersion {
337    /// The id of the version to delete
338    id: u64,
339}
340
341impl DeleteVersion {
342    /// Create a builder for the endpoint.
343    #[must_use]
344    pub fn builder() -> DeleteVersionBuilder {
345        DeleteVersionBuilder::default()
346    }
347}
348
349impl Endpoint for DeleteVersion {
350    fn method(&self) -> Method {
351        Method::DELETE
352    }
353
354    fn endpoint(&self) -> Cow<'static, str> {
355        format!("versions/{}.json", &self.id).into()
356    }
357}
358
359/// The endpoint to close a version and move its open issues to the next open version
360#[derive(Debug, Clone, Builder)]
361#[builder(setter(strip_option))]
362pub struct CloseCompletedVersion {
363    /// The id of the version to close
364    id: u64,
365}
366
367impl CloseCompletedVersion {
368    /// Create a builder for the endpoint.
369    #[must_use]
370    pub fn builder() -> CloseCompletedVersionBuilder {
371        CloseCompletedVersionBuilder::default()
372    }
373}
374
375impl Endpoint for CloseCompletedVersion {
376    fn method(&self) -> Method {
377        Method::POST
378    }
379
380    fn endpoint(&self) -> Cow<'static, str> {
381        format!("versions/{}/close_completed.json", &self.id).into()
382    }
383}
384
385/// helper struct for outer layers with a versions field holding the inner data
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
387pub struct VersionsWrapper<T> {
388    /// to parse JSON with versions key
389    pub versions: Vec<T>,
390}
391
392/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
393/// helper struct for outer layers with a version field holding the inner data
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
395pub struct VersionWrapper<T> {
396    /// to parse JSON with version key
397    pub version: T,
398}
399
400#[cfg(test)]
401mod test {
402    use super::*;
403    use crate::api::custom_fields::{
404        CustomFieldDefinition, CustomFieldsWrapper, CustomizedType, ListCustomFields,
405    };
406    use crate::api::test_helpers::with_project;
407    use pretty_assertions::assert_eq;
408    use std::error::Error;
409    use tokio::sync::RwLock;
410    use tracing_test::traced_test;
411
412    /// needed so we do not get 404s when listing while
413    /// creating/deleting or creating/updating/deleting
414    static VERSION_LOCK: RwLock<()> = RwLock::const_new(());
415
416    #[traced_test]
417    #[test]
418    fn test_list_versions_no_pagination() -> Result<(), Box<dyn Error>> {
419        let _r_version = VERSION_LOCK.blocking_read();
420        dotenvy::dotenv()?;
421        let redmine = crate::api::Redmine::from_env(
422            reqwest::blocking::Client::builder()
423                .use_rustls_tls()
424                .build()?,
425        )?;
426        let endpoint = ListVersions::builder().project_id_or_name("92").build()?;
427        redmine.json_response_body::<_, VersionsWrapper<Version>>(&endpoint)?;
428        Ok(())
429    }
430
431    #[traced_test]
432    #[test]
433    fn test_get_version() -> Result<(), Box<dyn Error>> {
434        let _r_version = VERSION_LOCK.blocking_read();
435        dotenvy::dotenv()?;
436        let redmine = crate::api::Redmine::from_env(
437            reqwest::blocking::Client::builder()
438                .use_rustls_tls()
439                .build()?,
440        )?;
441        let endpoint = GetVersion::builder().id(1182).build()?;
442        redmine.json_response_body::<_, VersionWrapper<Version>>(&endpoint)?;
443        Ok(())
444    }
445
446    #[function_name::named]
447    #[traced_test]
448    #[test]
449    fn test_create_update_version_with_custom_fields() -> Result<(), Box<dyn Error>> {
450        let _w_version = VERSION_LOCK.blocking_write();
451        let name = format!("unittest_{}", function_name!());
452        with_project(&name, |redmine, project_id, _name| {
453            // Find a custom field for versions
454            let list_custom_fields_endpoint = ListCustomFields::builder().build()?;
455            let CustomFieldsWrapper { custom_fields } = redmine
456                .json_response_body::<_, CustomFieldsWrapper<CustomFieldDefinition>>(
457                    &list_custom_fields_endpoint,
458                )?;
459
460            let version_custom_field = custom_fields
461                .into_iter()
462                .find(|cf| cf.customized_type == CustomizedType::Version);
463
464            let custom_field_id = if let Some(cf) = version_custom_field {
465                cf.id
466            } else {
467                // If no custom field for versions is found, skip the test
468                eprintln!("No custom field of type Version found. Skipping test.");
469                return Ok(());
470            };
471
472            let create_endpoint = CreateVersion::builder()
473                .project_id_or_name(project_id.to_string())
474                .name("Test Version with Custom Fields")
475                .custom_fields(vec![CustomField {
476                    id: custom_field_id,
477                    name: Some(Cow::Borrowed("VersionCustomField")),
478                    value: Cow::Borrowed("Custom Value 1"),
479                }])
480                .build()?;
481            let VersionWrapper { version } =
482                redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
483
484            assert_eq!(version.name, "Test Version with Custom Fields");
485            assert_eq!(
486                version.custom_fields.unwrap()[0].value.as_ref().unwrap()[0],
487                "Custom Value 1"
488            );
489
490            let update_endpoint = UpdateVersion::builder()
491                .id(version.id)
492                .name("Updated Test Version with Custom Fields")
493                .custom_fields(vec![CustomField {
494                    id: custom_field_id,
495                    name: Some(Cow::Borrowed("VersionCustomField")),
496                    value: Cow::Borrowed("Updated Custom Value 1"),
497                }])
498                .build()?;
499            redmine.ignore_response_body::<_>(&update_endpoint)?;
500
501            let get_endpoint = GetVersion::builder().id(version.id).build()?;
502            let VersionWrapper {
503                version: updated_version,
504            } = redmine.json_response_body::<_, VersionWrapper<Version>>(&get_endpoint)?;
505
506            assert_eq!(
507                updated_version.name,
508                "Updated Test Version with Custom Fields"
509            );
510            assert_eq!(
511                updated_version.custom_fields.unwrap()[0]
512                    .value
513                    .as_ref()
514                    .unwrap()[0],
515                "Updated Custom Value 1"
516            );
517            Ok(())
518        })?;
519        Ok(())
520    }
521
522    #[function_name::named]
523    #[traced_test]
524    #[test]
525    fn test_create_version_with_default_project_version() -> Result<(), Box<dyn Error>> {
526        let _w_version = VERSION_LOCK.blocking_write();
527        let name = format!("unittest_{}", function_name!());
528        with_project(&name, |redmine, project_id, name| {
529            let create_endpoint = CreateVersion::builder()
530                .project_id_or_name(name)
531                .name("Default Version")
532                .default_project_version(true)
533                .build()?;
534            redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
535
536            let project_endpoint = crate::api::projects::GetProject::builder()
537                .project_id_or_name(project_id.to_string())
538                .build()?;
539            let project_wrapper: crate::api::projects::ProjectWrapper<
540                crate::api::projects::Project,
541            > = redmine.json_response_body(&project_endpoint)?;
542            assert_eq!(
543                project_wrapper.project.default_version.unwrap().name,
544                "Default Version"
545            );
546            Ok(())
547        })?;
548        Ok(())
549    }
550
551    #[function_name::named]
552    #[traced_test]
553    #[test]
554    fn test_update_version_with_default_project_version() -> Result<(), Box<dyn Error>> {
555        let _w_version = VERSION_LOCK.blocking_write();
556        let name = format!("unittest_{}", function_name!());
557        with_project(&name, |redmine, project_id, name| {
558            let create_endpoint = CreateVersion::builder()
559                .project_id_or_name(name)
560                .name("Non-Default Version")
561                .build()?;
562            let VersionWrapper { version } =
563                redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
564
565            let update_endpoint = super::UpdateVersion::builder()
566                .id(version.id)
567                .default_project_version(true)
568                .build()?;
569            redmine.ignore_response_body::<_>(&update_endpoint)?;
570
571            let project_endpoint = crate::api::projects::GetProject::builder()
572                .project_id_or_name(project_id.to_string())
573                .build()?;
574            let project_wrapper: crate::api::projects::ProjectWrapper<
575                crate::api::projects::Project,
576            > = redmine.json_response_body(&project_endpoint)?;
577            assert_eq!(
578                project_wrapper.project.default_version.unwrap().name,
579                "Non-Default Version"
580            );
581            Ok(())
582        })?;
583        Ok(())
584    }
585}