gitlab/
hooktypes.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
8use log::warn;
9use serde::de::{Error, Unexpected};
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12
13/// Kinds of webhooks sent by GitLab.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum HookKind {
17    /// System hooks (from the installation).
18    System(Option<SystemHookType>),
19    /// Web hooks (from projects).
20    Web(Option<WebHookType>),
21}
22
23impl HookKind {
24    /// Determine the kind of hook payload for a JSON object.
25    // Ignore `clippy` complaining about a "manual map" so that future hook kinds can be easily
26    // added in the future.
27    #[allow(clippy::manual_map)]
28    pub fn kind_of(hook: &Value) -> Option<Self> {
29        if let Some(object_kind) = hook.pointer("/object_kind") {
30            Some(Self::Web(WebHookType::hook_type_of_kind(object_kind)))
31        } else if let Some(event_name) = hook.pointer("/event_name") {
32            Some(Self::System(SystemHookType::hook_type_of_name(event_name)))
33        } else {
34            None
35        }
36    }
37}
38
39/// Types for system hooks.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum SystemHookType {
43    /// Hooks for project events.
44    Project,
45    /// Hooks for project membership events.
46    ProjectMember,
47    /// Hooks for user events.
48    User,
49    /// Hooks for blocked user login events.
50    UserFailedLogin,
51    /// Hooks for user key events.
52    Key,
53    /// Hooks for group events.
54    Group,
55    /// Hooks for group membership events.
56    GroupMember,
57    /// Hooks for push events.
58    Push,
59    /// Hooks for repository updates (one per push batch).
60    RepositoryUpdate,
61}
62
63impl SystemHookType {
64    /// Compute the type of a system hook.
65    ///
66    /// Unknown types will raise a warning, but return `None`.
67    pub fn type_of(hook: &Value) -> Option<Self> {
68        match hook.pointer("/event_name") {
69            Some(Value::String(name)) => Self::of(name),
70            _ => None,
71        }
72    }
73
74    fn hook_type_of_name(hook: &Value) -> Option<Self> {
75        match hook {
76            Value::String(name) => Self::of(name),
77            _ => None,
78        }
79    }
80
81    fn of(event_name: &str) -> Option<Self> {
82        match event_name {
83            "project_create" | "project_destroy" | "project_rename" | "project_transfer"
84            | "project_update" => Some(Self::Project),
85            "user_access_request_revoked_for_project"
86            | "user_access_request_to_project"
87            | "user_add_to_team"
88            | "user_remove_from_team"
89            | "user_update_for_team" => Some(Self::ProjectMember),
90            "repository_update" => Some(Self::RepositoryUpdate),
91            "user_create" | "user_destroy" | "user_rename" => Some(Self::User),
92            "user_failed_login" => Some(Self::UserFailedLogin),
93            "key_create" | "key_destroy" => Some(Self::Key),
94            "group_create" | "group_destroy" | "group_rename" => Some(Self::Group),
95            "user_access_request_revoked_for_group"
96            | "user_access_request_to_group"
97            | "user_add_to_group"
98            | "user_remove_from_group"
99            | "user_update_for_group" => Some(Self::GroupMember),
100            "push" | "tag_push" => Some(Self::Push),
101            event_name => {
102                warn!("unrecognized system hook `event_name`: {}", event_name);
103                None
104            },
105        }
106    }
107}
108
109/// Types for webhooks.
110///
111/// Webhooks are associated with specific projects.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum WebHookType {
115    /// Hooks for push events.
116    Push,
117    /// Hooks for issue events.
118    Issue,
119    /// Hooks for merge request events.
120    MergeRequest,
121    /// Hooks for note events.
122    Note,
123    /// Hooks for build events.
124    Build,
125    /// Hooks for pipeline events.
126    Pipeline,
127    /// Hooks for wiki events.
128    WikiPage,
129    /// Hooks for deployent events.
130    Deployment,
131    /// Hooks for feature flag changes.
132    FeatureFlag,
133    /// Hooks for release events.
134    Release,
135    /// Hooks for emoji events.
136    Emoji,
137    /// Hooks for access token events.
138    AccessToken,
139}
140
141impl WebHookType {
142    /// Compute the type of a webhook.
143    ///
144    /// Unknown types will raise a warning, but return `None`.
145    pub fn type_of(hook: &Value) -> Option<Self> {
146        match hook.pointer("/object_kind") {
147            Some(Value::String(name)) => Self::of(name),
148            _ => None,
149        }
150    }
151
152    fn hook_type_of_kind(hook: &Value) -> Option<Self> {
153        match hook {
154            Value::String(name) => Self::of(name),
155            _ => None,
156        }
157    }
158
159    fn of(object_kind: &str) -> Option<Self> {
160        match object_kind {
161            "push" | "tag_push" => Some(Self::Push),
162            "issue" | "work_item" => Some(Self::Issue),
163            "merge_request" => Some(Self::MergeRequest),
164            "note" => Some(Self::Note),
165            "build" => Some(Self::Build),
166            "pipeline" => Some(Self::Pipeline),
167            "wiki_page" => Some(Self::WikiPage),
168            "deployment" => Some(Self::Deployment),
169            "feature_flag" => Some(Self::FeatureFlag),
170            "release" => Some(Self::Release),
171            "emoji" => Some(Self::Emoji),
172            "access_token" => Some(Self::AccessToken),
173            object_kind => {
174                warn!("unrecognized web hook `object_kind`: {}", object_kind);
175                None
176            },
177        }
178    }
179}
180
181/// A wrapper struct for dates in web hooks.
182///
183/// Gitlab does not use a standard date format for dates in web hooks. This structure supports
184/// deserializing the formats that have been observed.
185#[derive(Debug, Clone, Copy)]
186#[repr(transparent)]
187pub struct HookDate(DateTime<Utc>);
188
189impl<'de> Deserialize<'de> for HookDate {
190    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191    where
192        D: Deserializer<'de>,
193    {
194        let val = String::deserialize(deserializer)?;
195
196        NaiveDateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S UTC")
197            // XXX(chrono-0.4.25): `dt.and_utc()`
198            .map(|dt| Utc.from_utc_datetime(&dt))
199            .or_else(|_| DateTime::parse_from_rfc3339(&val).map(|dt| dt.with_timezone(&Utc)))
200            .or_else(|_| {
201                DateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S %z")
202                    .map(|dt| dt.with_timezone(&Utc))
203            })
204            .map_err(|err| {
205                D::Error::invalid_value(
206                    Unexpected::Other("hook date"),
207                    &format!("Unsupported format: {} {:?}", val, err).as_str(),
208                )
209            })
210            .map(HookDate)
211    }
212}
213
214impl AsRef<DateTime<Utc>> for HookDate {
215    fn as_ref(&self) -> &DateTime<Utc> {
216        &self.0
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use std::fmt;
223
224    use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
225    use serde::de::value::StrDeserializer;
226    use serde::Deserialize;
227    use serde_json::json;
228    use thiserror::Error;
229
230    use crate::hooktypes::{HookDate, HookKind, SystemHookType, WebHookType};
231
232    #[derive(Debug, Error)]
233    struct TestError {
234        msg: String,
235    }
236
237    impl fmt::Display for TestError {
238        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
239            write!(f, "TestError ({})", self.msg)
240        }
241    }
242
243    impl serde::de::Error for TestError {
244        fn custom<T>(msg: T) -> Self
245        where
246            T: fmt::Display,
247        {
248            Self {
249                msg: msg.to_string(),
250            }
251        }
252    }
253
254    fn expected_datetime() -> DateTime<Utc> {
255        let d = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
256        let t = NaiveTime::from_hms_opt(10, 34, 38).unwrap();
257        let dt = d.and_time(t);
258        // XXX(chrono-0.4.25): `dt.and_utc()`
259        Utc.from_utc_datetime(&dt)
260    }
261
262    fn test_hookdate_parse(input: &str) {
263        let hd = HookDate::deserialize(StrDeserializer::<TestError>::new(input)).unwrap();
264        assert_eq!(hd.as_ref(), &expected_datetime());
265    }
266
267    #[test]
268    fn test_hookdate_literal_utc() {
269        test_hookdate_parse("2024-05-06 10:34:38 UTC");
270    }
271
272    #[test]
273    fn test_hookdate_literal_local() {
274        test_hookdate_parse("2024-05-06 05:34:38 -0500");
275    }
276
277    #[test]
278    fn test_hookdate_rfc3339() {
279        test_hookdate_parse("2024-05-06T10:34:38Z");
280        test_hookdate_parse("2024-05-06T10:34:38+00:00");
281        test_hookdate_parse("2024-05-06T06:34:38-04:00");
282    }
283
284    #[test]
285    fn test_hookdate_invalid() {
286        let err =
287            HookDate::deserialize(StrDeserializer::<TestError>::new("invalid_date")).unwrap_err();
288        assert_eq!(err.msg, "invalid value: hook date, expected Unsupported format: invalid_date ParseError(Invalid)");
289    }
290
291    const SYSTEM_ITEMS: &[(&str, Option<SystemHookType>)] = &[
292        ("group_create", Some(SystemHookType::Group)),
293        ("group_destroy", Some(SystemHookType::Group)),
294        ("group_rename", Some(SystemHookType::Group)),
295        ("key_create", Some(SystemHookType::Key)),
296        ("key_destroy", Some(SystemHookType::Key)),
297        ("project_create", Some(SystemHookType::Project)),
298        ("project_destroy", Some(SystemHookType::Project)),
299        ("project_rename", Some(SystemHookType::Project)),
300        ("project_transfer", Some(SystemHookType::Project)),
301        ("project_update", Some(SystemHookType::Project)),
302        ("push", Some(SystemHookType::Push)),
303        ("repository_update", Some(SystemHookType::RepositoryUpdate)),
304        ("tag_push", Some(SystemHookType::Push)),
305        (
306            "user_access_request_revoked_for_group",
307            Some(SystemHookType::GroupMember),
308        ),
309        (
310            "user_access_request_revoked_for_project",
311            Some(SystemHookType::ProjectMember),
312        ),
313        (
314            "user_access_request_to_group",
315            Some(SystemHookType::GroupMember),
316        ),
317        (
318            "user_access_request_to_project",
319            Some(SystemHookType::ProjectMember),
320        ),
321        ("user_add_to_group", Some(SystemHookType::GroupMember)),
322        ("user_add_to_team", Some(SystemHookType::ProjectMember)),
323        ("user_create", Some(SystemHookType::User)),
324        ("user_destroy", Some(SystemHookType::User)),
325        ("user_failed_login", Some(SystemHookType::UserFailedLogin)),
326        ("user_remove_from_group", Some(SystemHookType::GroupMember)),
327        ("user_remove_from_team", Some(SystemHookType::ProjectMember)),
328        ("user_rename", Some(SystemHookType::User)),
329        ("user_update_for_group", Some(SystemHookType::GroupMember)),
330        ("user_update_for_team", Some(SystemHookType::ProjectMember)),
331        ("NOT_A_SYSTEM_HOOK", None),
332    ];
333    const WEB_ITEMS: &[(&str, Option<WebHookType>)] = &[
334        ("access_token", Some(WebHookType::AccessToken)),
335        ("build", Some(WebHookType::Build)),
336        ("deployment", Some(WebHookType::Deployment)),
337        ("emoji", Some(WebHookType::Emoji)),
338        ("feature_flag", Some(WebHookType::FeatureFlag)),
339        ("issue", Some(WebHookType::Issue)),
340        ("merge_request", Some(WebHookType::MergeRequest)),
341        ("note", Some(WebHookType::Note)),
342        ("pipeline", Some(WebHookType::Pipeline)),
343        ("push", Some(WebHookType::Push)),
344        ("release", Some(WebHookType::Release)),
345        ("tag_push", Some(WebHookType::Push)),
346        ("wiki_page", Some(WebHookType::WikiPage)),
347        ("work_item", Some(WebHookType::Issue)),
348        ("NOT_A_HOOK", None),
349    ];
350
351    #[test]
352    fn test_hook_kind_classification() {
353        for (n, k) in SYSTEM_ITEMS {
354            assert_eq!(
355                HookKind::kind_of(&json!({
356                    "event_name": n,
357                })),
358                Some(HookKind::System(*k)),
359            );
360        }
361
362        for (n, k) in WEB_ITEMS {
363            assert_eq!(
364                HookKind::kind_of(&json!({
365                    "object_kind": n,
366                })),
367                Some(HookKind::Web(*k)),
368            );
369        }
370
371        assert!(HookKind::kind_of(&json!({})).is_none());
372    }
373
374    #[test]
375    fn test_system_hook_classification() {
376        for (n, k) in SYSTEM_ITEMS {
377            assert_eq!(
378                SystemHookType::type_of(&json!({
379                    "event_name": n,
380                })),
381                *k,
382            );
383        }
384    }
385
386    #[test]
387    fn test_web_hook_classification() {
388        for (n, k) in WEB_ITEMS {
389            assert_eq!(
390                WebHookType::type_of(&json!({
391                    "object_kind": n,
392                })),
393                *k,
394            );
395        }
396    }
397}