redmine_api/api/
projects.rs

1//! Projects Rest API Endpoint definitions
2//!
3//! [`Redmine Documentation`](https://www.redmine.org/projects/redmine/wiki/Rest_Projects)
4//!
5//! - [x] all projects endpoint
6//! - [x] specific project endpoint
7//! - [x] create project endpoint
8//! - [x] update project endpoint
9//! - [x] archive project endpoint
10//! - [x] unarchive project endpoint
11//! - [x] delete project endpoint
12
13use derive_builder::Builder;
14use reqwest::Method;
15use std::borrow::Cow;
16
17use crate::api::custom_fields::CustomFieldName;
18use crate::api::enumerations::TimeEntryActivityEssentials;
19use crate::api::issue_categories::IssueCategoryEssentials;
20use crate::api::issues::AssigneeEssentials;
21use crate::api::trackers::TrackerEssentials;
22use crate::api::versions::VersionEssentials;
23use crate::api::{Endpoint, NoPagination, Pageable, QueryParams, ReturnsJsonResponse};
24use serde::Serialize;
25use std::collections::HashMap;
26
27/// a minimal type for Redmine modules used in lists enabled modules
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub struct Module {
30    /// numeric id
31    pub id: u64,
32    /// name (all lower-case and with underscores so probably not meant for display purposes)
33    pub name: String,
34}
35
36/// a minimal type for Redmine projects used in lists of projects included in
37/// other Redmine objects (e.g. custom fields)
38#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39pub struct ProjectEssentials {
40    /// numeric id
41    pub id: u64,
42    /// display name
43    pub name: String,
44}
45
46impl From<Project> for ProjectEssentials {
47    fn from(v: Project) -> Self {
48        ProjectEssentials {
49            id: v.id,
50            name: v.name,
51        }
52    }
53}
54
55impl From<&Project> for ProjectEssentials {
56    fn from(v: &Project) -> Self {
57        ProjectEssentials {
58            id: v.id,
59            name: v.name.to_owned(),
60        }
61    }
62}
63
64/// a type for projects to use as an API return type
65///
66/// alternatively you can use your own type limited to the fields you need
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
68pub struct Project {
69    /// numeric id
70    pub id: u64,
71    /// display name
72    pub name: String,
73    /// URL slug
74    pub identifier: String,
75    /// description
76    pub description: Option<String>,
77    /// the project homepage
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub homepage: Option<String>,
80    /// is the project public (visible to anonymous users)
81    pub is_public: Option<bool>,
82    /// the parent project (id and name)
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub parent: Option<ProjectEssentials>,
85    /// will the project inherit members from its ancestors
86    pub inherit_members: Option<bool>,
87    /// the default user/group issues in this project are assigned to
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub default_assignee: Option<AssigneeEssentials>,
90    /// the default version for issues in this project
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub default_version: Option<VersionEssentials>,
93    /// ID of the default version. It works only with existing shared versions
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub default_version_id: Option<u64>,
96    /// trackers to enable in the project
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub tracker_ids: Option<Vec<u64>>,
99    /// modules to enable in the project
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub enabled_module_names: Option<Vec<String>>,
102    /// custom issue fields to enable in the project
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub issue_custom_field_id: Option<Vec<u64>>,
105    /// values for custom fields
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub custom_field_values: Option<HashMap<u64, String>>,
108    /// archived or not?
109    pub status: u64,
110    /// The time when this project was created
111    #[serde(
112        serialize_with = "crate::api::serialize_rfc3339",
113        deserialize_with = "crate::api::deserialize_rfc3339"
114    )]
115    pub created_on: time::OffsetDateTime,
116    /// The time when this project was last updated
117    #[serde(
118        serialize_with = "crate::api::serialize_rfc3339",
119        deserialize_with = "crate::api::deserialize_rfc3339"
120    )]
121    pub updated_on: time::OffsetDateTime,
122    /// issue categories (only with include parameter)
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub issue_categories: Option<Vec<IssueCategoryEssentials>>,
125    /// time entry activities (only with include parameter)
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub time_entry_activities: Option<Vec<TimeEntryActivityEssentials>>,
128    /// enabled modules in this project (only with include parameter)
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub enabled_modules: Option<Vec<Module>>,
131    /// trackers in this project (only with include parameter)
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub trackers: Option<Vec<TrackerEssentials>>,
134    /// custom field ids and names in this project (only with include parameter)
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub issue_custom_fields: Option<Vec<CustomFieldName>>,
137}
138
139/// The types of associated data which can be fetched along with a project
140#[derive(Debug, Clone)]
141pub enum ProjectsInclude {
142    /// Trackers enabled in the project
143    Trackers,
144    /// Issue categories in the project
145    IssueCategories,
146    /// Redmine Modules enabled in the project
147    EnabledModules,
148    /// Time entry activities for the project
149    TimeEntryActivities,
150    /// Issue custom fields for the project
151    IssueCustomFields,
152}
153
154impl std::fmt::Display for ProjectsInclude {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            Self::Trackers => {
158                write!(f, "trackers")
159            }
160            Self::IssueCategories => {
161                write!(f, "issue_categories")
162            }
163            Self::EnabledModules => {
164                write!(f, "enabled_modules")
165            }
166            Self::TimeEntryActivities => {
167                write!(f, "time_entry_activities")
168            }
169            Self::IssueCustomFields => {
170                write!(f, "issue_custom_fields")
171            }
172        }
173    }
174}
175
176/// The project status values for filtering
177#[derive(Debug, Clone)]
178pub enum ProjectStatusFilter {
179    /// open and active projects
180    Active,
181    /// closed projects
182    Closed,
183}
184
185impl std::fmt::Display for ProjectStatusFilter {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        match self {
188            Self::Active => {
189                write!(f, "1")
190            }
191            Self::Closed => {
192                write!(f, "5")
193            }
194        }
195    }
196}
197
198/// Filter for project IDs.
199#[derive(Debug, Clone)]
200pub enum ProjectFilter {
201    /// Match any project.
202    Any,
203    /// Match no project.
204    None,
205    /// Match a specific list of project IDs.
206    TheseProjects(Vec<u64>),
207    /// Match any project but a specific list of project IDs.
208    NotTheseProjects(Vec<u64>),
209}
210
211impl std::fmt::Display for ProjectFilter {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        match self {
214            ProjectFilter::Any => write!(f, "*"),
215            ProjectFilter::None => write!(f, "!*"),
216            ProjectFilter::TheseProjects(ids) => {
217                let s: String = ids
218                    .iter()
219                    .map(|e| e.to_string())
220                    .collect::<Vec<_>>()
221                    .join(",");
222                write!(f, "{s}")
223            }
224            ProjectFilter::NotTheseProjects(ids) => {
225                let s: String = ids
226                    .iter()
227                    .map(|e| format!("!{e}"))
228                    .collect::<Vec<_>>()
229                    .join(",");
230                write!(f, "{s}")
231            }
232        }
233    }
234}
235
236/// The endpoint for all Redmine projects
237#[derive(Debug, Clone, Builder)]
238#[builder(setter(strip_option))]
239pub struct ListProjects {
240    /// the types of associate data to include
241    #[builder(default)]
242    include: Option<Vec<ProjectsInclude>>,
243    /// Filter by project status
244    #[builder(default)]
245    status: Option<Vec<ProjectStatusFilter>>,
246}
247
248impl ReturnsJsonResponse for ListProjects {}
249impl Pageable for ListProjects {
250    fn response_wrapper_key(&self) -> String {
251        "projects".to_string()
252    }
253}
254
255impl ListProjects {
256    /// Create a builder for the endpoint.
257    #[must_use]
258    pub fn builder() -> ListProjectsBuilder {
259        ListProjectsBuilder::default()
260    }
261}
262
263impl Endpoint for ListProjects {
264    fn method(&self) -> Method {
265        Method::GET
266    }
267
268    fn endpoint(&self) -> Cow<'static, str> {
269        "projects.json".into()
270    }
271
272    fn parameters(&self) -> QueryParams<'_> {
273        let mut params = QueryParams::default();
274        params.push_opt("include", self.include.as_ref());
275        params.push_opt(
276            "status",
277            self.status.as_ref().map(|statuses| {
278                statuses
279                    .iter()
280                    .map(|s| s.to_string())
281                    .collect::<Vec<String>>()
282                    .join(",")
283            }),
284        );
285        params
286    }
287}
288
289/// The types of associated data which can be fetched along with a project
290#[derive(Debug, Clone)]
291pub enum ProjectInclude {
292    /// Trackers enabled in the project
293    Trackers,
294    /// Issue categories in the project
295    IssueCategories,
296    /// Redmine Modules enabled in the project
297    EnabledModules,
298    /// Time Entry Activities enabled in the project
299    TimeEntryActivities,
300    /// Issue custom fields for the project
301    IssueCustomFields,
302}
303
304impl std::fmt::Display for ProjectInclude {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        match self {
307            Self::Trackers => {
308                write!(f, "trackers")
309            }
310            Self::IssueCategories => {
311                write!(f, "issue_categories")
312            }
313            Self::EnabledModules => {
314                write!(f, "enabled_modules")
315            }
316            Self::TimeEntryActivities => {
317                write!(f, "time_entry_activities")
318            }
319            Self::IssueCustomFields => {
320                write!(f, "issue_custom_fields")
321            }
322        }
323    }
324}
325
326/// The endpoint for a specific Redmine project
327#[derive(Debug, Clone, Builder)]
328#[builder(setter(strip_option))]
329pub struct GetProject<'a> {
330    /// the project id or name as it appears in the URL
331    #[builder(setter(into))]
332    project_id_or_name: Cow<'a, str>,
333    /// the types of associate data to include
334    #[builder(default)]
335    include: Option<Vec<ProjectInclude>>,
336}
337
338impl ReturnsJsonResponse for GetProject<'_> {}
339impl NoPagination for GetProject<'_> {}
340
341impl<'a> GetProject<'a> {
342    /// Create a builder for the endpoint.
343    #[must_use]
344    pub fn builder() -> GetProjectBuilder<'a> {
345        GetProjectBuilder::default()
346    }
347}
348
349impl Endpoint for GetProject<'_> {
350    fn method(&self) -> Method {
351        Method::GET
352    }
353
354    fn endpoint(&self) -> Cow<'static, str> {
355        format!("projects/{}.json", &self.project_id_or_name).into()
356    }
357
358    fn parameters(&self) -> QueryParams<'_> {
359        let mut params = QueryParams::default();
360        params.push_opt("include", self.include.as_ref());
361        params
362    }
363}
364
365/// The endpoint to archive a Redmine project
366#[derive(Debug, Clone, Builder)]
367#[builder(setter(strip_option))]
368pub struct ArchiveProject<'a> {
369    /// the project id or name as it appears in the URL of the project to archive
370    #[builder(setter(into))]
371    project_id_or_name: Cow<'a, str>,
372}
373
374impl<'a> ArchiveProject<'a> {
375    /// Create a builder for the endpoint.
376    #[must_use]
377    pub fn builder() -> ArchiveProjectBuilder<'a> {
378        ArchiveProjectBuilder::default()
379    }
380}
381
382impl Endpoint for ArchiveProject<'_> {
383    fn method(&self) -> Method {
384        Method::PUT
385    }
386
387    fn endpoint(&self) -> Cow<'static, str> {
388        format!("projects/{}/archive.json", &self.project_id_or_name).into()
389    }
390}
391
392/// The endpoint to unarchive a Redmine project
393#[derive(Debug, Clone, Builder)]
394#[builder(setter(strip_option))]
395pub struct UnarchiveProject<'a> {
396    /// the project id or name as it appears in the URL of the project to unarchive
397    #[builder(setter(into))]
398    project_id_or_name: Cow<'a, str>,
399}
400
401impl<'a> UnarchiveProject<'a> {
402    /// Create a builder for the endpoint.
403    #[must_use]
404    pub fn builder() -> UnarchiveProjectBuilder<'a> {
405        UnarchiveProjectBuilder::default()
406    }
407}
408
409impl Endpoint for UnarchiveProject<'_> {
410    fn method(&self) -> Method {
411        Method::PUT
412    }
413
414    fn endpoint(&self) -> Cow<'static, str> {
415        format!("projects/{}/unarchive.json", &self.project_id_or_name).into()
416    }
417}
418
419/// The endpoint to create a Redmine project
420#[serde_with::skip_serializing_none]
421#[derive(Debug, Clone, Builder, Serialize)]
422#[builder(setter(strip_option))]
423pub struct CreateProject<'a> {
424    /// the name of the project
425    #[builder(setter(into))]
426    name: Cow<'a, str>,
427    /// the identifier of the project as it appears in the URL
428    #[builder(setter(into))]
429    identifier: Cow<'a, str>,
430    /// the project description
431    #[builder(setter(into), default)]
432    description: Option<Cow<'a, str>>,
433    /// the project homepage
434    #[builder(setter(into), default)]
435    homepage: Option<Cow<'a, str>>,
436    /// is the project public (visible to anonymous users)
437    #[builder(default)]
438    is_public: Option<bool>,
439    /// the parent project id
440    #[builder(default)]
441    parent_id: Option<u64>,
442    /// will the project inherit members from its ancestors
443    #[builder(default)]
444    inherit_members: Option<bool>,
445    /// ID of the default user. It works only when the new project is a subproject and it inherits the members
446    #[builder(default)]
447    default_assigned_to_id: Option<u64>,
448    /// ID of the default version. It works only with existing shared versions
449    #[builder(default)]
450    default_version_id: Option<u64>,
451    /// trackers to enable in the project
452    #[builder(default)]
453    tracker_ids: Option<Vec<u64>>,
454    /// modules to enable in the project
455    #[builder(default)]
456    enabled_module_names: Option<Vec<Cow<'a, str>>>,
457    /// custom issue fields to enable in the project
458    #[builder(default)]
459    issue_custom_field_id: Option<Vec<u64>>,
460    /// values for custom fields
461    #[builder(default)]
462    custom_field_values: Option<HashMap<u64, Cow<'a, str>>>,
463}
464
465impl ReturnsJsonResponse for CreateProject<'_> {}
466impl NoPagination for CreateProject<'_> {}
467
468impl<'a> CreateProject<'a> {
469    /// Create a builder for the endpoint.
470    #[must_use]
471    pub fn builder() -> CreateProjectBuilder<'a> {
472        CreateProjectBuilder::default()
473    }
474}
475
476impl Endpoint for CreateProject<'_> {
477    fn method(&self) -> Method {
478        Method::POST
479    }
480
481    fn endpoint(&self) -> Cow<'static, str> {
482        "projects.json".into()
483    }
484
485    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
486        Ok(Some((
487            "application/json",
488            serde_json::to_vec(&ProjectWrapper::<CreateProject> {
489                project: (*self).to_owned(),
490            })?,
491        )))
492    }
493}
494
495/// The endpoint to update an existing Redmine project
496#[serde_with::skip_serializing_none]
497#[derive(Debug, Clone, Builder, Serialize)]
498#[builder(setter(strip_option))]
499pub struct UpdateProject<'a> {
500    /// the project id or name as it appears in the URL of the project to update
501    #[serde(skip_serializing)]
502    #[builder(setter(into))]
503    project_id_or_name: Cow<'a, str>,
504    /// the name of the project
505    #[builder(setter(into), default)]
506    name: Option<Cow<'a, str>>,
507    /// the identifier of the project as it appears in the URL
508    #[builder(setter(into), default)]
509    identifier: Option<Cow<'a, str>>,
510    /// the project description
511    #[builder(setter(into), default)]
512    description: Option<Cow<'a, str>>,
513    /// the project homepage
514    #[builder(setter(into), default)]
515    homepage: Option<Cow<'a, str>>,
516    /// is the project public (visible to anonymous users)
517    #[builder(default)]
518    is_public: Option<bool>,
519    /// the parent project id
520    #[builder(default)]
521    parent_id: Option<u64>,
522    /// will the project inherit members from its ancestors
523    #[builder(default)]
524    inherit_members: Option<bool>,
525    /// ID of the default user. It works only when the new project is a subproject and it inherits the members
526    #[builder(default)]
527    default_assigned_to_id: Option<u64>,
528    /// ID of the default version. It works only with existing shared versions
529    #[builder(default)]
530    default_version_id: Option<u64>,
531    /// trackers to enable in the project
532    #[builder(default)]
533    tracker_ids: Option<Vec<u64>>,
534    /// modules to enable in the project
535    #[builder(default)]
536    enabled_module_names: Option<Vec<Cow<'a, str>>>,
537    /// custom issue fields to enable in the project
538    #[builder(default)]
539    issue_custom_field_id: Option<Vec<u64>>,
540    /// values for custom fields
541    #[builder(default)]
542    custom_field_values: Option<HashMap<u64, Cow<'a, str>>>,
543}
544
545impl<'a> UpdateProject<'a> {
546    /// Create a builder for the endpoint.
547    #[must_use]
548    pub fn builder() -> UpdateProjectBuilder<'a> {
549        UpdateProjectBuilder::default()
550    }
551}
552
553impl Endpoint for UpdateProject<'_> {
554    fn method(&self) -> Method {
555        Method::PUT
556    }
557
558    fn endpoint(&self) -> Cow<'static, str> {
559        format!("projects/{}.json", self.project_id_or_name).into()
560    }
561
562    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
563        Ok(Some((
564            "application/json",
565            serde_json::to_vec(&ProjectWrapper::<UpdateProject> {
566                project: (*self).to_owned(),
567            })?,
568        )))
569    }
570}
571
572/// The endpoint to delete a Redmine project
573#[derive(Debug, Clone, Builder)]
574#[builder(setter(strip_option))]
575pub struct DeleteProject<'a> {
576    /// the project id or name as it appears in the URL of the project to delete
577    #[builder(setter(into))]
578    project_id_or_name: Cow<'a, str>,
579}
580
581impl<'a> DeleteProject<'a> {
582    /// Create a builder for the endpoint.
583    #[must_use]
584    pub fn builder() -> DeleteProjectBuilder<'a> {
585        DeleteProjectBuilder::default()
586    }
587}
588
589impl Endpoint for DeleteProject<'_> {
590    fn method(&self) -> Method {
591        Method::DELETE
592    }
593
594    fn endpoint(&self) -> Cow<'static, str> {
595        format!("projects/{}.json", &self.project_id_or_name).into()
596    }
597}
598
599/// helper struct for outer layers with a projects field holding the inner data
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
601pub struct ProjectsWrapper<T> {
602    /// to parse JSON with projects key
603    pub projects: Vec<T>,
604}
605
606/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
607/// helper struct for outer layers with a project field holding the inner data
608#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
609pub struct ProjectWrapper<T> {
610    /// to parse JSON with project key
611    pub project: T,
612}
613
614#[cfg(test)]
615pub(crate) mod test {
616    use super::*;
617    use crate::api::test_helpers::with_project;
618    use pretty_assertions::assert_eq;
619    use std::error::Error;
620    use tokio::sync::RwLock;
621    use tracing_test::traced_test;
622
623    /// needed so we do not get 404s when listing while
624    /// creating/deleting or creating/updating/deleting
625    pub static PROJECT_LOCK: RwLock<()> = RwLock::const_new(());
626
627    #[traced_test]
628    #[test]
629    fn test_list_projects_first_page() -> Result<(), Box<dyn Error>> {
630        let _r_project = PROJECT_LOCK.blocking_read();
631        dotenvy::dotenv()?;
632        let redmine = crate::api::Redmine::from_env(
633            reqwest::blocking::Client::builder()
634                .use_rustls_tls()
635                .build()?,
636        )?;
637        let endpoint = ListProjects::builder().build()?;
638        redmine.json_response_body_page::<_, Project>(&endpoint, 0, 25)?;
639        Ok(())
640    }
641
642    #[traced_test]
643    #[test]
644    fn test_list_projects_all_pages() -> Result<(), Box<dyn Error>> {
645        let _r_project = PROJECT_LOCK.blocking_read();
646        dotenvy::dotenv()?;
647        let redmine = crate::api::Redmine::from_env(
648            reqwest::blocking::Client::builder()
649                .use_rustls_tls()
650                .build()?,
651        )?;
652        let endpoint = ListProjects::builder().build()?;
653        redmine.json_response_body_all_pages::<_, Project>(&endpoint)?;
654        Ok(())
655    }
656
657    #[traced_test]
658    #[tokio::test]
659    async fn test_list_projects_async_first_page() -> Result<(), Box<dyn Error>> {
660        let _r_project = PROJECT_LOCK.read().await;
661        dotenvy::dotenv()?;
662        let redmine = crate::api::RedmineAsync::from_env(
663            reqwest::Client::builder().use_rustls_tls().build()?,
664        )?;
665        let endpoint = ListProjects::builder().build()?;
666        redmine
667            .json_response_body_page::<_, Project>(&endpoint, 0, 25)
668            .await?;
669        Ok(())
670    }
671
672    #[traced_test]
673    #[tokio::test]
674    async fn test_list_projects_async_all_pages() -> Result<(), Box<dyn Error>> {
675        let _r_project = PROJECT_LOCK.read().await;
676        dotenvy::dotenv()?;
677        let redmine = crate::api::RedmineAsync::from_env(
678            reqwest::Client::builder().use_rustls_tls().build()?,
679        )?;
680        let endpoint = ListProjects::builder().build()?;
681        redmine
682            .json_response_body_all_pages::<_, Project>(&endpoint)
683            .await?;
684        Ok(())
685    }
686
687    #[traced_test]
688    #[test]
689    fn test_get_project() -> Result<(), Box<dyn Error>> {
690        let _r_project = PROJECT_LOCK.blocking_read();
691        dotenvy::dotenv()?;
692        let redmine = crate::api::Redmine::from_env(
693            reqwest::blocking::Client::builder()
694                .use_rustls_tls()
695                .build()?,
696        )?;
697        let endpoint = GetProject::builder()
698            .project_id_or_name("sandbox")
699            .build()?;
700        redmine.json_response_body::<_, ProjectWrapper<Project>>(&endpoint)?;
701        Ok(())
702    }
703
704    #[function_name::named]
705    #[traced_test]
706    #[test]
707    fn test_create_project() -> Result<(), Box<dyn Error>> {
708        let name = format!("unittest_{}", function_name!());
709        with_project(&name, |_, _, _| Ok(()))?;
710        Ok(())
711    }
712
713    #[function_name::named]
714    #[traced_test]
715    #[test]
716    fn test_update_project() -> Result<(), Box<dyn Error>> {
717        let name = format!("unittest_{}", function_name!());
718        with_project(&name, |redmine, _id, name| {
719            let update_endpoint = super::UpdateProject::builder()
720                .project_id_or_name(name)
721                .description("Test-Description")
722                .build()?;
723            redmine.ignore_response_body::<_>(&update_endpoint)?;
724            Ok(())
725        })?;
726        Ok(())
727    }
728
729    /// this tests if any of the results contain a field we are not deserializing
730    ///
731    /// this will only catch fields we missed if they are part of the response but
732    /// it is better than nothing
733    #[traced_test]
734    #[test]
735    fn test_completeness_project_type() -> Result<(), Box<dyn Error>> {
736        let _r_project = PROJECT_LOCK.blocking_read();
737        dotenvy::dotenv()?;
738        let redmine = crate::api::Redmine::from_env(
739            reqwest::blocking::Client::builder()
740                .use_rustls_tls()
741                .build()?,
742        )?;
743        let endpoint = ListProjects::builder().build()?;
744        let values: Vec<serde_json::Value> = redmine.json_response_body_all_pages(&endpoint)?;
745        for value in values {
746            let o: Project = serde_json::from_value(value.clone())?;
747            let reserialized = serde_json::to_value(o)?;
748            assert_eq!(value, reserialized);
749        }
750        Ok(())
751    }
752
753    /// this tests if any of the results contain a field we are not deserializing
754    ///
755    /// this will only catch fields we missed if they are part of the response but
756    /// it is better than nothing
757    ///
758    /// this version of the test will load all pages of projects and the individual
759    /// projects for each via GetProject which means it
760    /// can take a while so you need to use --include-ignored
761    /// or --ignored to run it
762    #[traced_test]
763    #[test]
764    fn test_completeness_project_type_all_pages_all_project_details() -> Result<(), Box<dyn Error>>
765    {
766        let _r_project = PROJECT_LOCK.blocking_read();
767        dotenvy::dotenv()?;
768        let redmine = crate::api::Redmine::from_env(
769            reqwest::blocking::Client::builder()
770                .use_rustls_tls()
771                .build()?,
772        )?;
773        let endpoint = ListProjects::builder()
774            .include(vec![
775                ProjectsInclude::Trackers,
776                ProjectsInclude::IssueCategories,
777                ProjectsInclude::EnabledModules,
778                ProjectsInclude::TimeEntryActivities,
779                ProjectsInclude::IssueCustomFields,
780            ])
781            .build()?;
782        let projects = redmine.json_response_body_all_pages::<_, Project>(&endpoint)?;
783        for project in projects {
784            tracing::debug!(
785                "Now calling individual GetProject for project id {} name {}",
786                project.id,
787                project.name
788            );
789            let get_endpoint = GetProject::builder()
790                .project_id_or_name(project.id.to_string())
791                .include(vec![
792                    ProjectInclude::Trackers,
793                    ProjectInclude::IssueCategories,
794                    ProjectInclude::EnabledModules,
795                    ProjectInclude::TimeEntryActivities,
796                    ProjectInclude::IssueCustomFields,
797                ])
798                .build()?;
799            let ProjectWrapper { project: value } = redmine
800                .json_response_body::<_, ProjectWrapper<serde_json::Value>>(&get_endpoint)?;
801            let o: Project = serde_json::from_value(value.clone())?;
802            let reserialized = serde_json::to_value(o)?;
803            assert_eq!(value, reserialized);
804        }
805        Ok(())
806    }
807}