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