gitlab_time_report/fetch_api/
deserializer.rs

1//! Contains custom deserializer logic for the items from the GitLab API.
2
3use crate::model::{
4    Issue, MergeRequest, TrackableItem, TrackableItemFields, TrackableItemKind, UserNodes,
5};
6use serde::{Deserialize, Deserializer};
7
8/// To not always check if a trackable item is an issue or a merge request when accessing common
9/// fields, they are contained inside [`TrackableItemFields`].
10/// The type of the trackable item is encoded in [`TrackableItemKind`].
11/// To create the structure, a custom deserializer is needed. It consists of multiple steps
12/// 1. Create temporary structs that model the structure of the JSON (the ones ending on `Deserialize`)
13/// 2. Deserialize them with the default deserializer (derive macro)
14/// 3. Create the real [`TrackableItem`] that sets `TrackableItem.kind` accordingly.
15impl<'de> Deserialize<'de> for TrackableItem {
16    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
17        // Create temporary structs to deserialize the JSON as it exists in the API
18        // Both fields can be present in the JSON, but only one will be non-null
19        #[derive(Deserialize)]
20        #[serde(rename_all = "camelCase")]
21        struct TrackableItemDeserialize {
22            issue: Option<IssueDeserialize>,
23            merge_request: Option<MergeRequestDeserialize>,
24        }
25
26        #[derive(Deserialize)]
27        struct IssueDeserialize {
28            #[serde(flatten)]
29            common: TrackableItemFields,
30        }
31
32        #[derive(Deserialize)]
33        struct MergeRequestDeserialize {
34            #[serde(flatten)]
35            common: TrackableItemFields,
36            reviewers: UserNodes,
37        }
38
39        // Deserialize the trackable item type with the derived deserializer
40        let trackable_item_from_api = TrackableItemDeserialize::deserialize(deserializer)?;
41
42        // Create the real trackable item from the temporary structs based on which field is non-null
43        let real_trackable_item = match (
44            trackable_item_from_api.issue,
45            trackable_item_from_api.merge_request,
46        ) {
47            (Some(issue), None) => TrackableItem {
48                common: issue.common,
49                kind: TrackableItemKind::Issue(Issue {}),
50            },
51            (None, Some(mr)) => TrackableItem {
52                common: mr.common,
53                kind: TrackableItemKind::MergeRequest(MergeRequest {
54                    reviewers: mr.reviewers,
55                }),
56            },
57            (Some(_), Some(_)) => {
58                return Err(serde::de::Error::custom(
59                    "Both issue and mergeRequest are present, expected only one",
60                ));
61            }
62            (None, None) => {
63                return Err(serde::de::Error::custom(
64                    "Neither issue nor mergeRequest is present, expected one",
65                ));
66            }
67        };
68
69        Ok(real_trackable_item)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::model::User;
77
78    #[test]
79    fn deserialize_issue() {
80        let json_str = r#"{"issue":{"iid":"1","title":"Issue Name","timeEstimate":3600,"totalTimeSpent":10800,"assignees":{"nodes":[]},"milestone":null,"labels":{"nodes":[{"title":"Project Management"}]}},"mergeRequest":null}"#;
81        let deserialized: TrackableItem = serde_json::from_str(json_str).unwrap();
82        assert_eq!(deserialized.common.title, "Issue Name");
83        assert_eq!(
84            deserialized.common.time_estimate,
85            chrono::Duration::seconds(3600)
86        );
87        assert_eq!(
88            deserialized.common.total_time_spent,
89            chrono::Duration::seconds(10800)
90        );
91        assert!(matches!(deserialized.kind, TrackableItemKind::Issue(_)));
92    }
93
94    #[test]
95    fn deserialize_mr() {
96        let json_str = r#"{"issue":null,"mergeRequest":{"iid":"1","title":"Update README.md","timeEstimate":1800,"totalTimeSpent":1800,"assignees":{"nodes":[]},"reviewers":{"nodes":[{"name":"User1","username":"user.1"}]},"milestone":null,"labels":{"nodes":[]}}}"#;
97        let reviewers = UserNodes {
98            users: vec![User {
99                name: "User1".to_string(),
100                username: "user.1".to_string(),
101            }],
102        };
103
104        let deserialized: TrackableItem = serde_json::from_str(json_str).unwrap();
105        assert_eq!(deserialized.common.title, "Update README.md");
106        assert_eq!(
107            deserialized.common.time_estimate,
108            chrono::Duration::seconds(1800)
109        );
110        assert_eq!(
111            deserialized.common.total_time_spent,
112            chrono::Duration::seconds(1800)
113        );
114        assert!(matches!(
115            deserialized.kind,
116            TrackableItemKind::MergeRequest(_)
117        ));
118
119        if let TrackableItemKind::MergeRequest(deserialized_mr) = deserialized.kind {
120            assert_eq!(deserialized_mr.reviewers, reviewers);
121        }
122    }
123
124    #[test]
125    fn deserialize_mr_and_issue_present() {
126        let json_str = r#"{"issue":{"iid":"1","title":"Issue Name","timeEstimate":3600,"totalTimeSpent":10800,"assignees":{"nodes":[]},"milestone":null,"labels":{"nodes":[]}},"mergeRequest":{"iid":"1","title":"Update README.md","timeEstimate":1800,"totalTimeSpent":1800,"assignees":{"nodes":[]},"reviewers":{"nodes":[]},"milestone":null,"labels":{"nodes":[]}}}"#;
127        let deserialized = serde_json::from_str::<TrackableItem>(json_str);
128
129        assert!(deserialized.is_err());
130        let error = deserialized.unwrap_err();
131        assert!(error.is_data());
132        assert_eq!(
133            error.to_string(),
134            "Both issue and mergeRequest are present, expected only one"
135        );
136    }
137
138    #[test]
139    fn deserialize_mr_and_issue_null() {
140        let json_str = r#"{"issue":null,"mergeRequest":null}"#;
141        let deserialized = serde_json::from_str::<TrackableItem>(json_str);
142
143        assert!(deserialized.is_err());
144        let error = deserialized.unwrap_err();
145        assert!(error.is_data());
146        assert_eq!(
147            error.to_string(),
148            "Neither issue nor mergeRequest is present, expected one"
149        );
150    }
151}