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, 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 endpoint for all Redmine projects
177#[derive(Debug, Clone, Builder)]
178#[builder(setter(strip_option))]
179pub struct ListProjects {
180    /// the types of associate data to include
181    #[builder(default)]
182    include: Option<Vec<ProjectsInclude>>,
183}
184
185impl ReturnsJsonResponse for ListProjects {}
186impl Pageable for ListProjects {
187    fn response_wrapper_key(&self) -> String {
188        "projects".to_string()
189    }
190}
191
192impl ListProjects {
193    /// Create a builder for the endpoint.
194    #[must_use]
195    pub fn builder() -> ListProjectsBuilder {
196        ListProjectsBuilder::default()
197    }
198}
199
200impl Endpoint for ListProjects {
201    fn method(&self) -> Method {
202        Method::GET
203    }
204
205    fn endpoint(&self) -> Cow<'static, str> {
206        "projects.json".into()
207    }
208
209    fn parameters(&self) -> QueryParams {
210        let mut params = QueryParams::default();
211        params.push_opt("include", self.include.as_ref());
212        params
213    }
214}
215
216/// The types of associated data which can be fetched along with a project
217#[derive(Debug, Clone)]
218pub enum ProjectInclude {
219    /// Trackers enabled in the project
220    Trackers,
221    /// Issue categories in the project
222    IssueCategories,
223    /// Redmine Modules enabled in the project
224    EnabledModules,
225    /// Time Entry Activities enabled in the project
226    TimeEntryActivities,
227    /// Issue custom fields for the project
228    IssueCustomFields,
229}
230
231impl std::fmt::Display for ProjectInclude {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        match self {
234            Self::Trackers => {
235                write!(f, "trackers")
236            }
237            Self::IssueCategories => {
238                write!(f, "issue_categories")
239            }
240            Self::EnabledModules => {
241                write!(f, "enabled_modules")
242            }
243            Self::TimeEntryActivities => {
244                write!(f, "time_entry_activities")
245            }
246            Self::IssueCustomFields => {
247                write!(f, "issue_custom_fields")
248            }
249        }
250    }
251}
252
253/// The endpoint for a specific Redmine project
254#[derive(Debug, Clone, Builder)]
255#[builder(setter(strip_option))]
256pub struct GetProject<'a> {
257    /// the project id or name as it appears in the URL
258    #[builder(setter(into))]
259    project_id_or_name: Cow<'a, str>,
260    /// the types of associate data to include
261    #[builder(default)]
262    include: Option<Vec<ProjectInclude>>,
263}
264
265impl ReturnsJsonResponse for GetProject<'_> {}
266
267impl<'a> GetProject<'a> {
268    /// Create a builder for the endpoint.
269    #[must_use]
270    pub fn builder() -> GetProjectBuilder<'a> {
271        GetProjectBuilder::default()
272    }
273}
274
275impl Endpoint for GetProject<'_> {
276    fn method(&self) -> Method {
277        Method::GET
278    }
279
280    fn endpoint(&self) -> Cow<'static, str> {
281        format!("projects/{}.json", &self.project_id_or_name).into()
282    }
283
284    fn parameters(&self) -> QueryParams {
285        let mut params = QueryParams::default();
286        params.push_opt("include", self.include.as_ref());
287        params
288    }
289}
290
291/// The endpoint to archive a Redmine project
292#[derive(Debug, Clone, Builder)]
293#[builder(setter(strip_option))]
294pub struct ArchiveProject<'a> {
295    /// the project id or name as it appears in the URL of the project to archive
296    #[builder(setter(into))]
297    project_id_or_name: Cow<'a, str>,
298}
299
300impl<'a> ArchiveProject<'a> {
301    /// Create a builder for the endpoint.
302    #[must_use]
303    pub fn builder() -> ArchiveProjectBuilder<'a> {
304        ArchiveProjectBuilder::default()
305    }
306}
307
308impl Endpoint for ArchiveProject<'_> {
309    fn method(&self) -> Method {
310        Method::PUT
311    }
312
313    fn endpoint(&self) -> Cow<'static, str> {
314        format!("projects/{}/archive.json", &self.project_id_or_name).into()
315    }
316}
317
318/// The endpoint to unarchive a Redmine project
319#[derive(Debug, Clone, Builder)]
320#[builder(setter(strip_option))]
321pub struct UnarchiveProject<'a> {
322    /// the project id or name as it appears in the URL of the project to unarchive
323    #[builder(setter(into))]
324    project_id_or_name: Cow<'a, str>,
325}
326
327impl<'a> UnarchiveProject<'a> {
328    /// Create a builder for the endpoint.
329    #[must_use]
330    pub fn builder() -> UnarchiveProjectBuilder<'a> {
331        UnarchiveProjectBuilder::default()
332    }
333}
334
335impl Endpoint for UnarchiveProject<'_> {
336    fn method(&self) -> Method {
337        Method::PUT
338    }
339
340    fn endpoint(&self) -> Cow<'static, str> {
341        format!("projects/{}/unarchive.json", &self.project_id_or_name).into()
342    }
343}
344
345/// The endpoint to create a Redmine project
346#[serde_with::skip_serializing_none]
347#[derive(Debug, Clone, Builder, Serialize)]
348#[builder(setter(strip_option))]
349pub struct CreateProject<'a> {
350    /// the name of the project
351    #[builder(setter(into))]
352    name: Cow<'a, str>,
353    /// the identifier of the project as it appears in the URL
354    #[builder(setter(into))]
355    identifier: Cow<'a, str>,
356    /// the project description
357    #[builder(setter(into), default)]
358    description: Option<Cow<'a, str>>,
359    /// the project homepage
360    #[builder(setter(into), default)]
361    homepage: Option<Cow<'a, str>>,
362    /// is the project public (visible to anonymous users)
363    #[builder(default)]
364    is_public: Option<bool>,
365    /// the parent project id
366    #[builder(default)]
367    parent_id: Option<u64>,
368    /// will the project inherit members from its ancestors
369    #[builder(default)]
370    inherit_members: Option<bool>,
371    /// ID of the default user. It works only when the new project is a subproject and it inherits the members
372    #[builder(default)]
373    default_assigned_to_id: Option<u64>,
374    /// ID of the default version. It works only with existing shared versions
375    #[builder(default)]
376    default_version_id: Option<u64>,
377    /// trackers to enable in the project
378    #[builder(default)]
379    tracker_ids: Option<Vec<u64>>,
380    /// modules to enable in the project
381    #[builder(default)]
382    enabled_module_names: Option<Vec<Cow<'a, str>>>,
383    /// custom issue fields to enable in the project
384    #[builder(default)]
385    issue_custom_field_id: Option<Vec<u64>>,
386    /// values for custom fields
387    #[builder(default)]
388    custom_field_values: Option<HashMap<u64, Cow<'a, str>>>,
389}
390
391impl ReturnsJsonResponse for CreateProject<'_> {}
392
393impl<'a> CreateProject<'a> {
394    /// Create a builder for the endpoint.
395    #[must_use]
396    pub fn builder() -> CreateProjectBuilder<'a> {
397        CreateProjectBuilder::default()
398    }
399}
400
401impl Endpoint for CreateProject<'_> {
402    fn method(&self) -> Method {
403        Method::POST
404    }
405
406    fn endpoint(&self) -> Cow<'static, str> {
407        "projects.json".into()
408    }
409
410    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
411        Ok(Some((
412            "application/json",
413            serde_json::to_vec(&ProjectWrapper::<CreateProject> {
414                project: (*self).to_owned(),
415            })?,
416        )))
417    }
418}
419
420/// The endpoint to update an existing Redmine project
421#[serde_with::skip_serializing_none]
422#[derive(Debug, Clone, Builder, Serialize)]
423#[builder(setter(strip_option))]
424pub struct UpdateProject<'a> {
425    /// the project id or name as it appears in the URL of the project to update
426    #[serde(skip_serializing)]
427    #[builder(setter(into))]
428    project_id_or_name: Cow<'a, str>,
429    /// the name of the project
430    #[builder(setter(into), default)]
431    name: Option<Cow<'a, str>>,
432    /// the identifier of the project as it appears in the URL
433    #[builder(setter(into), default)]
434    identifier: Option<Cow<'a, str>>,
435    /// the project description
436    #[builder(setter(into), default)]
437    description: Option<Cow<'a, str>>,
438    /// the project homepage
439    #[builder(setter(into), default)]
440    homepage: Option<Cow<'a, str>>,
441    /// is the project public (visible to anonymous users)
442    #[builder(default)]
443    is_public: Option<bool>,
444    /// the parent project id
445    #[builder(default)]
446    parent_id: Option<u64>,
447    /// will the project inherit members from its ancestors
448    #[builder(default)]
449    inherit_members: Option<bool>,
450    /// ID of the default user. It works only when the new project is a subproject and it inherits the members
451    #[builder(default)]
452    default_assigned_to_id: Option<u64>,
453    /// ID of the default version. It works only with existing shared versions
454    #[builder(default)]
455    default_version_id: Option<u64>,
456    /// trackers to enable in the project
457    #[builder(default)]
458    tracker_ids: Option<Vec<u64>>,
459    /// modules to enable in the project
460    #[builder(default)]
461    enabled_module_names: Option<Vec<Cow<'a, str>>>,
462    /// custom issue fields to enable in the project
463    #[builder(default)]
464    issue_custom_field_id: Option<Vec<u64>>,
465    /// values for custom fields
466    #[builder(default)]
467    custom_field_values: Option<HashMap<u64, Cow<'a, str>>>,
468}
469
470impl<'a> UpdateProject<'a> {
471    /// Create a builder for the endpoint.
472    #[must_use]
473    pub fn builder() -> UpdateProjectBuilder<'a> {
474        UpdateProjectBuilder::default()
475    }
476}
477
478impl Endpoint for UpdateProject<'_> {
479    fn method(&self) -> Method {
480        Method::PUT
481    }
482
483    fn endpoint(&self) -> Cow<'static, str> {
484        format!("projects/{}.json", self.project_id_or_name).into()
485    }
486
487    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
488        Ok(Some((
489            "application/json",
490            serde_json::to_vec(&ProjectWrapper::<UpdateProject> {
491                project: (*self).to_owned(),
492            })?,
493        )))
494    }
495}
496
497/// The endpoint to delete a Redmine project
498#[derive(Debug, Clone, Builder)]
499#[builder(setter(strip_option))]
500pub struct DeleteProject<'a> {
501    /// the project id or name as it appears in the URL of the project to delete
502    #[builder(setter(into))]
503    project_id_or_name: Cow<'a, str>,
504}
505
506impl<'a> DeleteProject<'a> {
507    /// Create a builder for the endpoint.
508    #[must_use]
509    pub fn builder() -> DeleteProjectBuilder<'a> {
510        DeleteProjectBuilder::default()
511    }
512}
513
514impl Endpoint for DeleteProject<'_> {
515    fn method(&self) -> Method {
516        Method::DELETE
517    }
518
519    fn endpoint(&self) -> Cow<'static, str> {
520        format!("projects/{}.json", &self.project_id_or_name).into()
521    }
522}
523
524/// helper struct for outer layers with a projects field holding the inner data
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
526pub struct ProjectsWrapper<T> {
527    /// to parse JSON with projects key
528    pub projects: Vec<T>,
529}
530
531/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
532/// helper struct for outer layers with a project field holding the inner data
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
534pub struct ProjectWrapper<T> {
535    /// to parse JSON with project key
536    pub project: T,
537}
538
539#[cfg(test)]
540pub(crate) mod test {
541    use super::*;
542    use crate::api::test_helpers::with_project;
543    use pretty_assertions::assert_eq;
544    use std::error::Error;
545    use tokio::sync::RwLock;
546    use tracing_test::traced_test;
547
548    /// needed so we do not get 404s when listing while
549    /// creating/deleting or creating/updating/deleting
550    pub static PROJECT_LOCK: RwLock<()> = RwLock::const_new(());
551
552    #[traced_test]
553    #[test]
554    fn test_list_projects_no_pagination() -> Result<(), Box<dyn Error>> {
555        let _r_project = PROJECT_LOCK.read();
556        dotenvy::dotenv()?;
557        let redmine = crate::api::Redmine::from_env()?;
558        let endpoint = ListProjects::builder().build()?;
559        redmine.json_response_body::<_, ProjectsWrapper<Project>>(&endpoint)?;
560        Ok(())
561    }
562
563    #[traced_test]
564    #[test]
565    fn test_list_projects_first_page() -> Result<(), Box<dyn Error>> {
566        let _r_project = PROJECT_LOCK.read();
567        dotenvy::dotenv()?;
568        let redmine = crate::api::Redmine::from_env()?;
569        let endpoint = ListProjects::builder().build()?;
570        redmine.json_response_body_page::<_, Project>(&endpoint, 0, 25)?;
571        Ok(())
572    }
573
574    #[traced_test]
575    #[test]
576    fn test_list_projects_all_pages() -> Result<(), Box<dyn Error>> {
577        let _r_project = PROJECT_LOCK.read();
578        dotenvy::dotenv()?;
579        let redmine = crate::api::Redmine::from_env()?;
580        let endpoint = ListProjects::builder().build()?;
581        redmine.json_response_body_all_pages::<_, Project>(&endpoint)?;
582        Ok(())
583    }
584
585    #[traced_test]
586    #[tokio::test]
587    async fn test_list_projects_async_no_pagination() -> Result<(), Box<dyn Error>> {
588        let _r_project = PROJECT_LOCK.read();
589        dotenvy::dotenv()?;
590        let redmine = crate::api::RedmineAsync::from_env()?;
591        let endpoint = ListProjects::builder().build()?;
592        redmine
593            .json_response_body::<_, ProjectsWrapper<Project>>(&endpoint)
594            .await?;
595        Ok(())
596    }
597
598    #[traced_test]
599    #[tokio::test]
600    async fn test_list_projects_async_first_page() -> Result<(), Box<dyn Error>> {
601        let _r_project = PROJECT_LOCK.read();
602        dotenvy::dotenv()?;
603        let redmine = crate::api::RedmineAsync::from_env()?;
604        let endpoint = ListProjects::builder().build()?;
605        redmine
606            .json_response_body_page::<_, Project>(&endpoint, 0, 25)
607            .await?;
608        Ok(())
609    }
610
611    #[traced_test]
612    #[tokio::test]
613    async fn test_list_projects_async_all_pages() -> Result<(), Box<dyn Error>> {
614        let _r_project = PROJECT_LOCK.read();
615        dotenvy::dotenv()?;
616        let redmine = crate::api::RedmineAsync::from_env()?;
617        let endpoint = ListProjects::builder().build()?;
618        redmine
619            .json_response_body_all_pages::<_, Project>(&endpoint)
620            .await?;
621        Ok(())
622    }
623
624    #[traced_test]
625    #[test]
626    fn test_get_project() -> Result<(), Box<dyn Error>> {
627        let _r_project = PROJECT_LOCK.read();
628        dotenvy::dotenv()?;
629        let redmine = crate::api::Redmine::from_env()?;
630        let endpoint = GetProject::builder()
631            .project_id_or_name("sandbox")
632            .build()?;
633        redmine.json_response_body::<_, ProjectWrapper<Project>>(&endpoint)?;
634        Ok(())
635    }
636
637    #[function_name::named]
638    #[traced_test]
639    #[test]
640    fn test_create_project() -> Result<(), Box<dyn Error>> {
641        let name = format!("unittest_{}", function_name!());
642        with_project(&name, |_, _, _| Ok(()))?;
643        Ok(())
644    }
645
646    #[function_name::named]
647    #[traced_test]
648    #[test]
649    fn test_update_project() -> Result<(), Box<dyn Error>> {
650        let name = format!("unittest_{}", function_name!());
651        with_project(&name, |redmine, _id, name| {
652            let update_endpoint = super::UpdateProject::builder()
653                .project_id_or_name(name)
654                .description("Test-Description")
655                .build()?;
656            redmine.ignore_response_body::<_>(&update_endpoint)?;
657            Ok(())
658        })?;
659        Ok(())
660    }
661
662    /// this tests if any of the results contain a field we are not deserializing
663    ///
664    /// this will only catch fields we missed if they are part of the response but
665    /// it is better than nothing
666    #[traced_test]
667    #[test]
668    fn test_completeness_project_type() -> Result<(), Box<dyn Error>> {
669        let _r_project = PROJECT_LOCK.read();
670        dotenvy::dotenv()?;
671        let redmine = crate::api::Redmine::from_env()?;
672        let endpoint = ListProjects::builder().build()?;
673        let ProjectsWrapper { projects: values } =
674            redmine.json_response_body::<_, ProjectsWrapper<serde_json::Value>>(&endpoint)?;
675        for value in values {
676            let o: Project = serde_json::from_value(value.clone())?;
677            let reserialized = serde_json::to_value(o)?;
678            assert_eq!(value, reserialized);
679        }
680        Ok(())
681    }
682
683    /// this tests if any of the results contain a field we are not deserializing
684    ///
685    /// this will only catch fields we missed if they are part of the response but
686    /// it is better than nothing
687    ///
688    /// this version of the test will load all pages of projects and the individual
689    /// projects for each via GetProject which means it
690    /// can take a while so you need to use --include-ignored
691    /// or --ignored to run it
692    #[traced_test]
693    #[test]
694    fn test_completeness_project_type_all_pages_all_project_details() -> Result<(), Box<dyn Error>>
695    {
696        let _r_project = PROJECT_LOCK.read();
697        dotenvy::dotenv()?;
698        let redmine = crate::api::Redmine::from_env()?;
699        let endpoint = ListProjects::builder()
700            .include(vec![
701                ProjectsInclude::Trackers,
702                ProjectsInclude::IssueCategories,
703                ProjectsInclude::EnabledModules,
704                ProjectsInclude::TimeEntryActivities,
705                ProjectsInclude::IssueCustomFields,
706            ])
707            .build()?;
708        let projects = redmine.json_response_body_all_pages::<_, Project>(&endpoint)?;
709        for project in projects {
710            let get_endpoint = GetProject::builder()
711                .project_id_or_name(project.id.to_string())
712                .include(vec![
713                    ProjectInclude::Trackers,
714                    ProjectInclude::IssueCategories,
715                    ProjectInclude::EnabledModules,
716                    ProjectInclude::TimeEntryActivities,
717                    ProjectInclude::IssueCustomFields,
718                ])
719                .build()?;
720            let ProjectWrapper { project: value } = redmine
721                .json_response_body::<_, ProjectWrapper<serde_json::Value>>(&get_endpoint)?;
722            let o: Project = serde_json::from_value(value.clone())?;
723            let reserialized = serde_json::to_value(o)?;
724            assert_eq!(value, reserialized);
725        }
726        Ok(())
727    }
728}