redmine_api/api/
users.rs

1//! Users Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_Users)
4//!
5//! - [x] all users endpoint
6//!   - [x] status filter
7//!   - [x] name filter
8//!   - [x] group_id filter
9//! - [x] specific user endpoint
10//!   - [x] by user id
11//!   - [x] current
12//! - [x] create user endpoint
13//! - [x] update user endpoint
14//! - [x] delete user endpoint
15
16use derive_builder::Builder;
17use reqwest::Method;
18use std::borrow::Cow;
19
20use crate::api::custom_fields::CustomFieldEssentialsWithValue;
21use crate::api::groups::GroupEssentials;
22use crate::api::project_memberships::UserProjectMembership;
23use crate::api::{Endpoint, NoPagination, Pageable, QueryParams, ReturnsJsonResponse};
24use serde::Serialize;
25
26/// a minimal type for Redmine users used in
27/// other Redmine objects (e.g. issue author)
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub struct UserEssentials {
30    /// numeric id
31    pub id: u64,
32    /// display name
33    pub name: String,
34}
35
36/// a type for user to use as an API return type
37///
38/// alternatively you can use your own type limited to the fields you need
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct User {
41    /// numeric id
42    pub id: u64,
43    /// user status (seemingly numeric here, unlike filters)
44    ///
45    /// TODO: turn this into the Enum UserStatus?
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub status: Option<u64>,
48    /// login name
49    pub login: String,
50    /// is this user an admin
51    pub admin: bool,
52    /// user's firstname
53    pub firstname: String,
54    /// user's lastname
55    pub lastname: String,
56    /// primary email of the user
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub mail: Option<String>,
59    /// the user's API key
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub api_key: Option<String>,
62    /// user's 2FA scheme
63    #[serde(default)]
64    pub twofa_scheme: Option<String>,
65    /// allows setting users to be e.g. LDAP users
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    auth_source_id: Option<u64>,
68    /// The time when this user was created
69    #[serde(
70        serialize_with = "crate::api::serialize_rfc3339",
71        deserialize_with = "crate::api::deserialize_rfc3339"
72    )]
73    pub created_on: time::OffsetDateTime,
74    /// The time when this user was last updated
75    #[serde(
76        serialize_with = "crate::api::serialize_rfc3339",
77        deserialize_with = "crate::api::deserialize_rfc3339"
78    )]
79    pub updated_on: time::OffsetDateTime,
80    /// The time when this user's password was last changed
81    #[serde(
82        serialize_with = "crate::api::serialize_optional_rfc3339",
83        deserialize_with = "crate::api::deserialize_optional_rfc3339"
84    )]
85    pub passwd_changed_on: Option<time::OffsetDateTime>,
86    /// the time when this user last logged in
87    #[serde(
88        serialize_with = "crate::api::serialize_optional_rfc3339",
89        deserialize_with = "crate::api::deserialize_optional_rfc3339"
90    )]
91    pub last_login_on: Option<time::OffsetDateTime>,
92    /// custom fields with values
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
95    /// groups (only if include is specified)
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub groups: Option<Vec<GroupEssentials>>,
98    /// memberships (only if include is specified)
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub memberships: Option<Vec<UserProjectMembership>>,
101}
102
103/// The user status values for filtering
104#[derive(Debug, Clone)]
105pub enum UserStatus {
106    /// User can login and use their account (default)
107    Active,
108    /// User has registered but not yet confirmed their email address or was not yet activated by an administrator. User can not login
109    Registered,
110    /// User was once active and is now locked, User can not login
111    Locked,
112    /// Specify this to get users with any status
113    AnyStatus,
114}
115
116impl std::fmt::Display for UserStatus {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::Active => {
120                write!(f, "Active")
121            }
122            Self::Registered => {
123                write!(f, "Registered")
124            }
125            Self::Locked => {
126                write!(f, "Locked")
127            }
128            Self::AnyStatus => {
129                write!(f, "")
130            }
131        }
132    }
133}
134
135/// The endpoint for all users
136#[derive(Debug, Clone, Builder)]
137#[builder(setter(strip_option))]
138pub struct ListUsers<'a> {
139    /// Filter by user status
140    #[builder(default)]
141    /// The status of the users (locked, registered but not confirmed yet,...)
142    status: Option<UserStatus>,
143    #[builder(default)]
144    /// Filter by name, this matches login, firstname, lastname and if it contains a space also firstname and lastname
145    #[builder(setter(into))]
146    name: Option<Cow<'a, str>>,
147    /// Users need to be members of this group
148    #[builder(default)]
149    group_id: Option<u64>,
150}
151
152impl ReturnsJsonResponse for ListUsers<'_> {}
153impl Pageable for ListUsers<'_> {
154    fn response_wrapper_key(&self) -> String {
155        "users".to_string()
156    }
157}
158
159impl<'a> ListUsers<'a> {
160    /// Create a builder for the endpoint.
161    #[must_use]
162    pub fn builder() -> ListUsersBuilder<'a> {
163        ListUsersBuilder::default()
164    }
165}
166
167impl Endpoint for ListUsers<'_> {
168    fn method(&self) -> Method {
169        Method::GET
170    }
171
172    fn endpoint(&self) -> Cow<'static, str> {
173        "users.json".into()
174    }
175
176    fn parameters(&self) -> QueryParams {
177        let mut params = QueryParams::default();
178        params.push_opt("status", self.status.as_ref().map(|s| s.to_string()));
179        params.push_opt("name", self.name.as_ref());
180        params.push_opt("group_id", self.group_id);
181        params
182    }
183}
184
185/// The types of associated data which can be fetched along with a user
186#[derive(Debug, Clone)]
187pub enum UserInclude {
188    /// The project memberships of this user
189    Memberships,
190    /// The groups where this user is a member
191    Groups,
192}
193
194impl std::fmt::Display for UserInclude {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        match self {
197            Self::Memberships => {
198                write!(f, "memberships")
199            }
200            Self::Groups => {
201                write!(f, "groups")
202            }
203        }
204    }
205}
206
207/// The endpoint for a specific user
208#[derive(Debug, Clone, Builder)]
209#[builder(setter(strip_option))]
210pub struct GetUser {
211    /// User id to fetch, if not specified will fetch the current user
212    #[builder(default)]
213    id: Option<u64>,
214    /// Include associated data
215    #[builder(default)]
216    include: Option<Vec<UserInclude>>,
217}
218
219impl ReturnsJsonResponse for GetUser {}
220impl NoPagination for GetUser {}
221
222impl GetUser {
223    /// Create a builder for the endpoint.
224    #[must_use]
225    pub fn builder() -> GetUserBuilder {
226        GetUserBuilder::default()
227    }
228}
229
230impl Endpoint for GetUser {
231    fn method(&self) -> Method {
232        Method::GET
233    }
234
235    fn endpoint(&self) -> Cow<'static, str> {
236        match self.id {
237            Some(id) => format!("users/{}.json", id).into(),
238            None => "users/current.json".into(),
239        }
240    }
241
242    fn parameters(&self) -> QueryParams {
243        let mut params = QueryParams::default();
244        params.push_opt("include", self.include.as_ref());
245        params
246    }
247}
248
249/// Possible values for mail notification options for a user
250#[derive(Debug, Clone, Serialize)]
251#[serde(rename_all = "snake_case")]
252pub enum MailNotificationOptions {
253    /// Get notified by all events (visible to user)
254    All,
255    /// This allows to be notified only by selected projects, not sure if those can be selected via the API
256    Selected,
257    /// Only get notifications for events caused by the user's own actions
258    OnlyMyEvents,
259    /// Only get notifications for events in issues assigned to the user
260    OnlyAssigned,
261    /// Only get notifications for events in issues owned by the user
262    OnlyOwner,
263    /// Do not get any notifications
264    #[serde(rename = "none")]
265    NoMailNotifications,
266}
267
268/// The endpoint to create a Redmine user
269#[serde_with::skip_serializing_none]
270#[derive(Debug, Clone, Builder, Serialize)]
271#[builder(setter(strip_option))]
272pub struct CreateUser<'a> {
273    /// The login for the user
274    #[builder(setter(into))]
275    login: Cow<'a, str>,
276    /// The user's password
277    ///
278    /// It is recommended to use generate_password instead
279    #[builder(setter(into), default)]
280    password: Option<Cow<'a, str>>,
281    /// The user's firstname
282    #[builder(setter(into))]
283    firstname: Cow<'a, str>,
284    /// The user's lastname
285    #[builder(setter(into))]
286    lastname: Cow<'a, str>,
287    /// The users primary email address
288    #[builder(setter(into))]
289    mail: Cow<'a, str>,
290    /// allows setting users to be e.g. LDAP users
291    #[builder(default)]
292    auth_source_id: Option<u64>,
293    /// what kind of mail notifications should be sent to the user
294    #[builder(default)]
295    mail_notification: Option<MailNotificationOptions>,
296    /// if set the user must change their password after the next login
297    #[builder(default)]
298    must_change_passwd: Option<bool>,
299    /// generate a random password
300    #[builder(default)]
301    generate_password: Option<bool>,
302    /// Send account information to the user
303    #[builder(default)]
304    #[serde(skip_serializing)]
305    send_information: Option<bool>,
306    /// Make the user a Redmine administrator
307    #[builder(default)]
308    admin: Option<bool>,
309}
310
311impl ReturnsJsonResponse for CreateUser<'_> {}
312impl NoPagination for CreateUser<'_> {}
313
314impl<'a> CreateUser<'a> {
315    /// Create a builder for the endpoint.
316    #[must_use]
317    pub fn builder() -> CreateUserBuilder<'a> {
318        CreateUserBuilder::default()
319    }
320}
321
322impl Endpoint for CreateUser<'_> {
323    fn method(&self) -> Method {
324        Method::POST
325    }
326
327    fn endpoint(&self) -> Cow<'static, str> {
328        "users.json".into()
329    }
330
331    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
332        Ok(Some((
333            "application/json",
334            serde_json::to_vec(&UserWrapperWithSendInformation::<CreateUser> {
335                user: (*self).to_owned(),
336                send_information: self.send_information,
337            })?,
338        )))
339    }
340}
341
342/// The endpoint to update an existing Redmine user
343#[serde_with::skip_serializing_none]
344#[derive(Debug, Clone, Builder, Serialize)]
345#[builder(setter(strip_option))]
346pub struct UpdateUser<'a> {
347    /// The id of the user to update
348    #[serde(skip_serializing)]
349    id: u64,
350    /// The login for the user
351    #[builder(setter(into))]
352    login: Cow<'a, str>,
353    /// The user's password
354    ///
355    /// It is recommended to use generate_password instead
356    #[builder(setter(into), default)]
357    password: Option<Cow<'a, str>>,
358    /// The user's firstname
359    #[builder(default, setter(into))]
360    firstname: Option<Cow<'a, str>>,
361    /// The user's lastname
362    #[builder(default, setter(into))]
363    lastname: Option<Cow<'a, str>>,
364    /// The users primary email address
365    #[builder(default, setter(into))]
366    mail: Option<Cow<'a, str>>,
367    /// allows setting users to be e.g. LDAP users
368    #[builder(default)]
369    auth_source_id: Option<u64>,
370    /// what kind of mail notifications should be sent to the user
371    #[builder(default)]
372    mail_notification: Option<MailNotificationOptions>,
373    /// if set the user must change their password after the next login
374    #[builder(default)]
375    must_change_passwd: Option<bool>,
376    /// generate a random password
377    #[builder(default)]
378    generate_password: Option<bool>,
379    /// Send account information to the user
380    #[builder(default)]
381    #[serde(skip_serializing)]
382    send_information: Option<bool>,
383    /// Make the user a Redmine administrator
384    #[builder(default)]
385    admin: Option<bool>,
386}
387
388impl<'a> UpdateUser<'a> {
389    /// Create a builder for the endpoint.
390    #[must_use]
391    pub fn builder() -> UpdateUserBuilder<'a> {
392        UpdateUserBuilder::default()
393    }
394}
395
396impl Endpoint for UpdateUser<'_> {
397    fn method(&self) -> Method {
398        Method::PUT
399    }
400
401    fn endpoint(&self) -> Cow<'static, str> {
402        format!("users/{}.json", self.id).into()
403    }
404
405    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
406        Ok(Some((
407            "application/json",
408            serde_json::to_vec(&UserWrapperWithSendInformation::<UpdateUser> {
409                user: (*self).to_owned(),
410                send_information: self.send_information,
411            })?,
412        )))
413    }
414}
415
416/// The endpoint to delete a Redmine user
417#[derive(Debug, Clone, Builder)]
418#[builder(setter(strip_option))]
419pub struct DeleteUser {
420    /// The id of the user to delete
421    id: u64,
422}
423
424impl DeleteUser {
425    /// Create a builder for the endpoint.
426    #[must_use]
427    pub fn builder() -> DeleteUserBuilder {
428        DeleteUserBuilder::default()
429    }
430}
431
432impl Endpoint for DeleteUser {
433    fn method(&self) -> Method {
434        Method::DELETE
435    }
436
437    fn endpoint(&self) -> Cow<'static, str> {
438        format!("users/{}.json", &self.id).into()
439    }
440}
441
442/// helper struct for outer layers with a users field holding the inner data
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
444pub struct UsersWrapper<T> {
445    /// to parse JSON with users key
446    pub users: Vec<T>,
447}
448
449/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
450/// helper struct for outer layers with a user field holding the inner data
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
452pub struct UserWrapper<T> {
453    /// to parse JSON with user key
454    pub user: T,
455}
456
457/// a special version of the UserWrapper to use with [CreateUser] and [UpdateUser]
458/// because Redmine puts the send_information flag outside the user object for
459/// some reason
460#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
461pub struct UserWrapperWithSendInformation<T> {
462    /// to parse JSON with user key
463    pub user: T,
464    /// send information flag in [CreateUser] and [UpdateUser]
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub send_information: Option<bool>,
467}
468
469#[cfg(test)]
470mod test {
471    use crate::api::ResponsePage;
472
473    use super::*;
474    use pretty_assertions::assert_eq;
475    use std::error::Error;
476    use tokio::sync::RwLock;
477    use tracing_test::traced_test;
478
479    /// needed so we do not get 404s when listing while
480    /// creating/deleting or creating/updating/deleting
481    static USER_LOCK: RwLock<()> = RwLock::const_new(());
482
483    #[traced_test]
484    #[test]
485    fn test_list_users_first_page() -> Result<(), Box<dyn Error>> {
486        let _r_user = USER_LOCK.read();
487        dotenvy::dotenv()?;
488        let redmine = crate::api::Redmine::from_env()?;
489        let endpoint = ListUsers::builder().build()?;
490        redmine.json_response_body_page::<_, User>(&endpoint, 0, 25)?;
491        Ok(())
492    }
493
494    #[traced_test]
495    #[test]
496    fn test_list_users_all_pages() -> Result<(), Box<dyn Error>> {
497        let _r_user = USER_LOCK.read();
498        dotenvy::dotenv()?;
499        let redmine = crate::api::Redmine::from_env()?;
500        let endpoint = ListUsers::builder().build()?;
501        redmine.json_response_body_all_pages::<_, User>(&endpoint)?;
502        Ok(())
503    }
504
505    #[traced_test]
506    #[test]
507    fn test_get_user() -> Result<(), Box<dyn Error>> {
508        let _r_user = USER_LOCK.read();
509        dotenvy::dotenv()?;
510        let redmine = crate::api::Redmine::from_env()?;
511        let endpoint = GetUser::builder().id(1).build()?;
512        redmine.json_response_body::<_, UserWrapper<User>>(&endpoint)?;
513        Ok(())
514    }
515
516    #[function_name::named]
517    #[traced_test]
518    #[test]
519    fn test_create_user() -> Result<(), Box<dyn Error>> {
520        let _w_user = USER_LOCK.write();
521        let name = format!("unittest_{}", function_name!());
522        dotenvy::dotenv()?;
523        let redmine = crate::api::Redmine::from_env()?;
524        let list_endpoint = ListUsers::builder().name(name.clone()).build()?;
525        let users: Vec<User> = redmine.json_response_body_all_pages(&list_endpoint)?;
526        for user in users {
527            let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
528            redmine.ignore_response_body::<_>(&delete_endpoint)?;
529        }
530        let create_endpoint = CreateUser::builder()
531            .login(name.clone())
532            .firstname("Unit")
533            .lastname("Test")
534            .mail(format!("unit-test_{}@example.org", name))
535            .build()?;
536        let UserWrapper { user } =
537            redmine.json_response_body::<_, UserWrapper<User>>(&create_endpoint)?;
538        let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
539        redmine.ignore_response_body::<_>(&delete_endpoint)?;
540        Ok(())
541    }
542
543    // this test causes emails to be sent so we comment it out, mainly it was
544    // meant to check if the send_information attribute is inside or outside the
545    // user object in CreateUser (the docs in the wiki say outside and that really
546    // seems to be the case)
547    // #[function_name::named]
548    // #[traced_test]
549    // #[test]
550    // fn test_create_user_send_account_info() -> Result<(), Box<dyn Error>> {
551    //     let _w_user = USER_LOCK.write();
552    //     let name = format!("unittest_{}", function_name!());
553    //     dotenvy::dotenv()?;
554    //     let redmine = crate::api::Redmine::from_env()?;
555    //     let list_endpoint = ListUsers::builder().name(name.clone()).build()?;
556    //     let UsersWrapper { users } =
557    //         redmine.json_response_body::<_, UsersWrapper<User>>(&list_endpoint)?;
558    //     for user in users {
559    //         let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
560    //         redmine.ignore_response_body::<_>(&delete_endpoint)?;
561    //     }
562    //     let create_endpoint = CreateUser::builder()
563    //         .login(name.clone())
564    //         .firstname("Unit")
565    //         .lastname("Test Send Account Info")
566    //         .mail(format!("{}@example.org", name)) // apparently there is a 60 character limit on the email in Redmine
567    //         .send_information(true)
568    //         .build()?;
569    //     let UserWrapper { user } =
570    //         redmine.json_response_body::<_, UserWrapper<User>>(&create_endpoint)?;
571    //     let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
572    //     redmine.ignore_response_body::<_>(&delete_endpoint)?;
573    //     Ok(())
574    // }
575
576    // this test causes emails to be sent so we comment it out, mainly it was
577    // meant to check if the admin attribute is inside or outside the user object
578    // in CreateUser (the docs on the wiki say outside but inside seems
579    // to be correct)
580    // #[function_name::named]
581    // #[traced_test]
582    // #[test]
583    // fn test_create_admin_user() -> Result<(), Box<dyn Error>> {
584    //     let _w_user = USER_LOCK.write();
585    //     let name = format!("unittest_{}", function_name!());
586    //     dotenvy::dotenv()?;
587    //     let redmine = crate::api::Redmine::from_env()?;
588    //     let list_endpoint = ListUsers::builder().name(name.clone()).build()?;
589    //     let UsersWrapper { users } =
590    //         redmine.json_response_body::<_, UsersWrapper<User>>(&list_endpoint)?;
591    //     for user in users {
592    //         let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
593    //         redmine.ignore_response_body::<_>(&delete_endpoint)?;
594    //     }
595    //     let create_endpoint = CreateUser::builder()
596    //         .login(name.clone())
597    //         .firstname("Unit")
598    //         .lastname("Test Admin")
599    //         .mail(format!("unit-test_{}@example.org", name))
600    //         .admin(true)
601    //         .build()?;
602    //     let UserWrapper { user } =
603    //         redmine.json_response_body::<_, UserWrapper<User>>(&create_endpoint)?;
604    //     let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
605    //     redmine.ignore_response_body::<_>(&delete_endpoint)?;
606    //     Ok(())
607    // }
608
609    #[function_name::named]
610    #[traced_test]
611    #[test]
612    fn test_update_user() -> Result<(), Box<dyn Error>> {
613        let _w_user = USER_LOCK.write();
614        let name = format!("unittest_{}", function_name!());
615        dotenvy::dotenv()?;
616        let redmine = crate::api::Redmine::from_env()?;
617        let list_endpoint = ListUsers::builder().name(name.clone()).build()?;
618        let users: Vec<User> = redmine.json_response_body_all_pages(&list_endpoint)?;
619        for user in users {
620            let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
621            redmine.ignore_response_body::<_>(&delete_endpoint)?;
622        }
623        let create_endpoint = CreateUser::builder()
624            .login(name.clone())
625            .firstname("Unit")
626            .lastname("Test")
627            .mail(format!("unit-test_{}@example.org", name))
628            .build()?;
629        let UserWrapper { user } =
630            redmine.json_response_body::<_, UserWrapper<User>>(&create_endpoint)?;
631        let update_endpoint = super::UpdateUser::builder()
632            .id(user.id)
633            .login(format!("new_{}", name))
634            .build()?;
635        redmine.ignore_response_body::<_>(&update_endpoint)?;
636        let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
637        redmine.ignore_response_body::<_>(&delete_endpoint)?;
638        Ok(())
639    }
640
641    /// this tests if any of the results contain a field we are not deserializing
642    ///
643    /// this will only catch fields we missed if they are part of the response but
644    /// it is better than nothing
645    #[traced_test]
646    #[test]
647    fn test_completeness_user_type_first_page() -> Result<(), Box<dyn Error>> {
648        let _r_user = USER_LOCK.read();
649        dotenvy::dotenv()?;
650        let redmine = crate::api::Redmine::from_env()?;
651        let endpoint = ListUsers::builder().build()?;
652        let ResponsePage {
653            values,
654            total_count: _,
655            offset: _,
656            limit: _,
657        } = redmine.json_response_body_page::<_, serde_json::Value>(&endpoint, 0, 100)?;
658        for value in values {
659            let o: User = serde_json::from_value(value.clone())?;
660            let reserialized = serde_json::to_value(o)?;
661            assert_eq!(value, reserialized);
662        }
663        Ok(())
664    }
665
666    /// this tests if any of the results contain a field we are not deserializing
667    ///
668    /// this will only catch fields we missed if they are part of the response but
669    /// it is better than nothing
670    ///
671    /// this version of the test will load all pages of users and the individual
672    /// users for each via GetUser
673    #[traced_test]
674    #[test]
675    fn test_completeness_user_type_all_pages_all_user_details() -> Result<(), Box<dyn Error>> {
676        let _r_user = USER_LOCK.read();
677        dotenvy::dotenv()?;
678        let redmine = crate::api::Redmine::from_env()?;
679        let endpoint = ListUsers::builder().build()?;
680        let users = redmine.json_response_body_all_pages::<_, User>(&endpoint)?;
681        for user in users {
682            let get_endpoint = GetUser::builder()
683                .id(user.id)
684                .include(vec![UserInclude::Memberships, UserInclude::Groups])
685                .build()?;
686            let UserWrapper { user: value } =
687                redmine.json_response_body::<_, UserWrapper<serde_json::Value>>(&get_endpoint)?;
688            let o: User = serde_json::from_value(value.clone())?;
689            let reserialized = serde_json::to_value(o)?;
690            assert_eq!(value, reserialized);
691        }
692        Ok(())
693    }
694}