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, 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<'_> {}
93
94impl<'a> ListVersions<'a> {
95    /// Create a builder for the endpoint.
96    #[must_use]
97    pub fn builder() -> ListVersionsBuilder<'a> {
98        ListVersionsBuilder::default()
99    }
100}
101
102impl Endpoint for ListVersions<'_> {
103    fn method(&self) -> Method {
104        Method::GET
105    }
106
107    fn endpoint(&self) -> Cow<'static, str> {
108        format!("projects/{}/versions.json", self.project_id_or_name).into()
109    }
110}
111
112/// The endpoint for a specific Redmine project version
113#[derive(Debug, Clone, Builder)]
114#[builder(setter(strip_option))]
115pub struct GetVersion {
116    /// the id of the version to retrieve
117    id: u64,
118}
119
120impl ReturnsJsonResponse for GetVersion {}
121
122impl GetVersion {
123    /// Create a builder for the endpoint.
124    #[must_use]
125    pub fn builder() -> GetVersionBuilder {
126        GetVersionBuilder::default()
127    }
128}
129
130impl Endpoint for GetVersion {
131    fn method(&self) -> Method {
132        Method::GET
133    }
134
135    fn endpoint(&self) -> Cow<'static, str> {
136        format!("versions/{}.json", &self.id).into()
137    }
138}
139
140/// The status of a version restricts if issues can be assigned to this
141/// version and if assigned issues can be reopened
142#[derive(Debug, Clone, serde::Deserialize, Serialize)]
143#[serde(rename_all = "snake_case")]
144pub enum VersionStatus {
145    /// no restrictions, default
146    Open,
147    /// can not assign new issues to the version
148    Locked,
149    /// can not assign new issues and can not reopen assigned issues
150    Closed,
151}
152
153/// Version sharing determines the cross-project visibility of the version
154#[derive(Debug, Clone, serde::Deserialize, Serialize)]
155#[serde(rename_all = "snake_case")]
156pub enum VersionSharing {
157    /// default
158    None,
159    /// only descendant projects in the hierarchy can see the project's version
160    Descendants,
161    /// descendant projects and ancestor projects in the hierarchy can see the project's version
162    Hierarchy,
163    /// descendant projects, ancestor projects and other projects in the same tree can see the project's version
164    Tree,
165    /// versions can be seen by all projects in the Redmine instance
166    System,
167}
168
169/// The endpoint to create a Redmine project version
170#[serde_with::skip_serializing_none]
171#[derive(Debug, Clone, Builder, Serialize)]
172#[builder(setter(strip_option))]
173pub struct CreateVersion<'a> {
174    /// The project Id or the project name as it appears in the URL to add the version to
175    #[builder(setter(into))]
176    #[serde(skip_serializing)]
177    project_id_or_name: Cow<'a, str>,
178    /// display name
179    #[builder(setter(into))]
180    name: Cow<'a, str>,
181    /// the status of the version
182    #[builder(default)]
183    status: Option<VersionStatus>,
184    /// how the version is shared with other projects
185    #[builder(default)]
186    sharing: Option<VersionSharing>,
187    /// when the version is due to be released
188    #[builder(default)]
189    due_date: Option<time::Date>,
190    /// Description of the version
191    #[builder(default)]
192    description: Option<Cow<'a, str>>,
193    /// The title of the wiki page for this version
194    #[builder(default)]
195    wiki_page_title: Option<Cow<'a, str>>,
196}
197
198impl ReturnsJsonResponse for CreateVersion<'_> {}
199
200impl<'a> CreateVersion<'a> {
201    /// Create a builder for the endpoint.
202    #[must_use]
203    pub fn builder() -> CreateVersionBuilder<'a> {
204        CreateVersionBuilder::default()
205    }
206}
207
208impl Endpoint for CreateVersion<'_> {
209    fn method(&self) -> Method {
210        Method::POST
211    }
212
213    fn endpoint(&self) -> Cow<'static, str> {
214        format!("projects/{}/versions.json", self.project_id_or_name).into()
215    }
216
217    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
218        Ok(Some((
219            "application/json",
220            serde_json::to_vec(&VersionWrapper::<CreateVersion> {
221                version: (*self).to_owned(),
222            })?,
223        )))
224    }
225}
226
227/// The endpoint to update an existing Redmine project version
228#[serde_with::skip_serializing_none]
229#[derive(Debug, Clone, Builder, Serialize)]
230#[builder(setter(strip_option))]
231pub struct UpdateVersion<'a> {
232    /// The id of the version to update
233    #[serde(skip_serializing)]
234    id: u64,
235    /// display name
236    #[builder(default, setter(into))]
237    name: Option<Cow<'a, str>>,
238    /// the status of the version
239    #[builder(default)]
240    status: Option<VersionStatus>,
241    /// how the version is shared with other projects
242    #[builder(default)]
243    sharing: Option<VersionSharing>,
244    /// when the version is due to be released
245    #[builder(default)]
246    due_date: Option<time::Date>,
247    /// Description of the version
248    #[builder(default)]
249    description: Option<Cow<'a, str>>,
250    /// The title of the wiki page for this version
251    #[builder(default)]
252    wiki_page_title: Option<Cow<'a, str>>,
253}
254
255impl<'a> UpdateVersion<'a> {
256    /// Create a builder for the endpoint.
257    #[must_use]
258    pub fn builder() -> UpdateVersionBuilder<'a> {
259        UpdateVersionBuilder::default()
260    }
261}
262
263impl Endpoint for UpdateVersion<'_> {
264    fn method(&self) -> Method {
265        Method::PUT
266    }
267
268    fn endpoint(&self) -> Cow<'static, str> {
269        format!("versions/{}.json", self.id).into()
270    }
271
272    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
273        Ok(Some((
274            "application/json",
275            serde_json::to_vec(&VersionWrapper::<UpdateVersion> {
276                version: (*self).to_owned(),
277            })?,
278        )))
279    }
280}
281
282/// The endpoint to delete a version in a Redmine project
283#[derive(Debug, Clone, Builder)]
284#[builder(setter(strip_option))]
285pub struct DeleteVersion {
286    /// The id of the version to delete
287    id: u64,
288}
289
290impl DeleteVersion {
291    /// Create a builder for the endpoint.
292    #[must_use]
293    pub fn builder() -> DeleteVersionBuilder {
294        DeleteVersionBuilder::default()
295    }
296}
297
298impl Endpoint for DeleteVersion {
299    fn method(&self) -> Method {
300        Method::DELETE
301    }
302
303    fn endpoint(&self) -> Cow<'static, str> {
304        format!("versions/{}.json", &self.id).into()
305    }
306}
307
308/// helper struct for outer layers with a versions field holding the inner data
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
310pub struct VersionsWrapper<T> {
311    /// to parse JSON with versions key
312    pub versions: Vec<T>,
313}
314
315/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
316/// helper struct for outer layers with a version field holding the inner data
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
318pub struct VersionWrapper<T> {
319    /// to parse JSON with version key
320    pub version: T,
321}
322
323#[cfg(test)]
324mod test {
325    use super::*;
326    use crate::api::test_helpers::with_project;
327    use pretty_assertions::assert_eq;
328    use std::error::Error;
329    use tokio::sync::RwLock;
330    use tracing_test::traced_test;
331
332    /// needed so we do not get 404s when listing while
333    /// creating/deleting or creating/updating/deleting
334    static VERSION_LOCK: RwLock<()> = RwLock::const_new(());
335
336    #[traced_test]
337    #[test]
338    fn test_list_versions_no_pagination() -> Result<(), Box<dyn Error>> {
339        let _r_versions = VERSION_LOCK.read();
340        dotenvy::dotenv()?;
341        let redmine = crate::api::Redmine::from_env()?;
342        let endpoint = ListVersions::builder().project_id_or_name("92").build()?;
343        redmine.json_response_body::<_, VersionsWrapper<Version>>(&endpoint)?;
344        Ok(())
345    }
346
347    #[traced_test]
348    #[test]
349    fn test_get_version() -> Result<(), Box<dyn Error>> {
350        let _r_versions = VERSION_LOCK.read();
351        dotenvy::dotenv()?;
352        let redmine = crate::api::Redmine::from_env()?;
353        let endpoint = GetVersion::builder().id(1182).build()?;
354        redmine.json_response_body::<_, VersionWrapper<Version>>(&endpoint)?;
355        Ok(())
356    }
357
358    #[function_name::named]
359    #[traced_test]
360    #[test]
361    fn test_create_version() -> Result<(), Box<dyn Error>> {
362        let _w_versions = VERSION_LOCK.write();
363        let name = format!("unittest_{}", function_name!());
364        with_project(&name, |redmine, _, name| {
365            let create_endpoint = CreateVersion::builder()
366                .project_id_or_name(name)
367                .name("Test Version")
368                .build()?;
369            redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
370            Ok(())
371        })?;
372        Ok(())
373    }
374
375    #[function_name::named]
376    #[traced_test]
377    #[test]
378    fn test_update_version() -> Result<(), Box<dyn Error>> {
379        let _w_versions = VERSION_LOCK.write();
380        let name = format!("unittest_{}", function_name!());
381        with_project(&name, |redmine, _, name| {
382            let create_endpoint = CreateVersion::builder()
383                .project_id_or_name(name)
384                .name("Test Version")
385                .build()?;
386            let VersionWrapper { version } =
387                redmine.json_response_body::<_, VersionWrapper<Version>>(&create_endpoint)?;
388            let update_endpoint = super::UpdateVersion::builder()
389                .id(version.id)
390                .name("Neue Test-Version")
391                .build()?;
392            redmine.ignore_response_body::<_>(&update_endpoint)?;
393            Ok(())
394        })?;
395        Ok(())
396    }
397
398    /// this tests if any of the results contain a field we are not deserializing
399    ///
400    /// this will only catch fields we missed if they are part of the response but
401    /// it is better than nothing
402    #[traced_test]
403    #[test]
404    fn test_completeness_version_type() -> Result<(), Box<dyn Error>> {
405        let _r_versions = VERSION_LOCK.read();
406        dotenvy::dotenv()?;
407        let redmine = crate::api::Redmine::from_env()?;
408        let endpoint = ListVersions::builder().project_id_or_name("92").build()?;
409        let VersionsWrapper { versions: values } =
410            redmine.json_response_body::<_, VersionsWrapper<serde_json::Value>>(&endpoint)?;
411        for value in values {
412            let o: Version = serde_json::from_value(value.clone())?;
413            let reserialized = serde_json::to_value(o)?;
414            assert_eq!(value, reserialized);
415        }
416        Ok(())
417    }
418}