use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use log::warn;
use serde::de::{Error, Unexpected};
use serde::{Deserialize, Deserializer};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum HookKind {
System(Option<SystemHookType>),
Web(Option<WebHookType>),
}
impl HookKind {
#[allow(clippy::manual_map)]
pub fn kind_of(hook: &Value) -> Option<Self> {
if let Some(object_kind) = hook.pointer("/object_kind") {
Some(Self::Web(WebHookType::hook_type_of_kind(object_kind)))
} else if let Some(event_name) = hook.pointer("/event_name") {
Some(Self::System(SystemHookType::hook_type_of_name(event_name)))
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SystemHookType {
Project,
ProjectMember,
User,
UserFailedLogin,
Key,
Group,
GroupMember,
Push,
RepositoryUpdate,
}
impl SystemHookType {
pub fn type_of(hook: &Value) -> Option<Self> {
match hook.pointer("/event_name") {
Some(Value::String(name)) => Self::of(name),
_ => None,
}
}
fn hook_type_of_name(hook: &Value) -> Option<Self> {
match hook {
Value::String(name) => Self::of(name),
_ => None,
}
}
fn of(event_name: &str) -> Option<Self> {
match event_name {
"project_create" | "project_destroy" | "project_rename" | "project_transfer"
| "project_update" => Some(Self::Project),
"user_access_request_revoked_for_project"
| "user_access_request_to_project"
| "user_add_to_team"
| "user_remove_from_team"
| "user_update_for_team" => Some(Self::ProjectMember),
"repository_update" => Some(Self::RepositoryUpdate),
"user_create" | "user_destroy" | "user_rename" => Some(Self::User),
"user_failed_login" => Some(Self::UserFailedLogin),
"key_create" | "key_destroy" => Some(Self::Key),
"group_create" | "group_destroy" | "group_rename" => Some(Self::Group),
"user_access_request_revoked_for_group"
| "user_access_request_to_group"
| "user_add_to_group"
| "user_remove_from_group"
| "user_update_for_group" => Some(Self::GroupMember),
"push" | "tag_push" => Some(Self::Push),
event_name => {
warn!("unrecognized system hook `event_name`: {}", event_name);
None
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WebHookType {
Push,
Issue,
MergeRequest,
Note,
Build,
Pipeline,
WikiPage,
Deployment,
FeatureFlag,
Release,
Emoji,
AccessToken,
}
impl WebHookType {
pub fn type_of(hook: &Value) -> Option<Self> {
match hook.pointer("/object_kind") {
Some(Value::String(name)) => Self::of(name),
_ => None,
}
}
fn hook_type_of_kind(hook: &Value) -> Option<Self> {
match hook {
Value::String(name) => Self::of(name),
_ => None,
}
}
fn of(object_kind: &str) -> Option<Self> {
match object_kind {
"push" | "tag_push" => Some(Self::Push),
"issue" | "work_item" => Some(Self::Issue),
"merge_request" => Some(Self::MergeRequest),
"note" => Some(Self::Note),
"build" => Some(Self::Build),
"pipeline" => Some(Self::Pipeline),
"wiki_page" => Some(Self::WikiPage),
"deployment" => Some(Self::Deployment),
"feature_flag" => Some(Self::FeatureFlag),
"release" => Some(Self::Release),
"emoji" => Some(Self::Emoji),
"access_token" => Some(Self::AccessToken),
object_kind => {
warn!("unrecognized web hook `object_kind`: {}", object_kind);
None
},
}
}
}
#[derive(Debug, Clone, Copy)]
#[repr(transparent)]
pub struct HookDate(DateTime<Utc>);
impl<'de> Deserialize<'de> for HookDate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let val = String::deserialize(deserializer)?;
NaiveDateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S UTC")
.map(|dt| Utc.from_utc_datetime(&dt))
.or_else(|_| DateTime::parse_from_rfc3339(&val).map(|dt| dt.with_timezone(&Utc)))
.or_else(|_| {
DateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S %z")
.map(|dt| dt.with_timezone(&Utc))
})
.map_err(|err| {
D::Error::invalid_value(
Unexpected::Other("hook date"),
&format!("Unsupported format: {} {:?}", val, err).as_str(),
)
})
.map(HookDate)
}
}
impl AsRef<DateTime<Utc>> for HookDate {
fn as_ref(&self) -> &DateTime<Utc> {
&self.0
}
}
#[cfg(test)]
mod tests {
use std::fmt;
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
use serde::de::value::StrDeserializer;
use serde::Deserialize;
use serde_json::json;
use thiserror::Error;
use crate::hooktypes::{HookDate, HookKind, SystemHookType, WebHookType};
#[derive(Debug, Error)]
struct TestError {
msg: String,
}
impl fmt::Display for TestError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "TestError ({})", self.msg)
}
}
impl serde::de::Error for TestError {
fn custom<T>(msg: T) -> Self
where
T: fmt::Display,
{
Self {
msg: msg.to_string(),
}
}
}
fn expected_datetime() -> DateTime<Utc> {
let d = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
let t = NaiveTime::from_hms_opt(10, 34, 38).unwrap();
let dt = d.and_time(t);
Utc.from_utc_datetime(&dt)
}
fn test_hookdate_parse(input: &str) {
let hd = HookDate::deserialize(StrDeserializer::<TestError>::new(input)).unwrap();
assert_eq!(hd.as_ref(), &expected_datetime());
}
#[test]
fn test_hookdate_literal_utc() {
test_hookdate_parse("2024-05-06 10:34:38 UTC");
}
#[test]
fn test_hookdate_literal_local() {
test_hookdate_parse("2024-05-06 05:34:38 -0500");
}
#[test]
fn test_hookdate_rfc3339() {
test_hookdate_parse("2024-05-06T10:34:38Z");
test_hookdate_parse("2024-05-06T10:34:38+00:00");
test_hookdate_parse("2024-05-06T06:34:38-04:00");
}
#[test]
fn test_hookdate_invalid() {
let err =
HookDate::deserialize(StrDeserializer::<TestError>::new("invalid_date")).unwrap_err();
assert_eq!(err.msg, "invalid value: hook date, expected Unsupported format: invalid_date ParseError(Invalid)");
}
const SYSTEM_ITEMS: &[(&str, Option<SystemHookType>)] = &[
("group_create", Some(SystemHookType::Group)),
("group_destroy", Some(SystemHookType::Group)),
("group_rename", Some(SystemHookType::Group)),
("key_create", Some(SystemHookType::Key)),
("key_destroy", Some(SystemHookType::Key)),
("project_create", Some(SystemHookType::Project)),
("project_destroy", Some(SystemHookType::Project)),
("project_rename", Some(SystemHookType::Project)),
("project_transfer", Some(SystemHookType::Project)),
("project_update", Some(SystemHookType::Project)),
("push", Some(SystemHookType::Push)),
("repository_update", Some(SystemHookType::RepositoryUpdate)),
("tag_push", Some(SystemHookType::Push)),
(
"user_access_request_revoked_for_group",
Some(SystemHookType::GroupMember),
),
(
"user_access_request_revoked_for_project",
Some(SystemHookType::ProjectMember),
),
(
"user_access_request_to_group",
Some(SystemHookType::GroupMember),
),
(
"user_access_request_to_project",
Some(SystemHookType::ProjectMember),
),
("user_add_to_group", Some(SystemHookType::GroupMember)),
("user_add_to_team", Some(SystemHookType::ProjectMember)),
("user_create", Some(SystemHookType::User)),
("user_destroy", Some(SystemHookType::User)),
("user_failed_login", Some(SystemHookType::UserFailedLogin)),
("user_remove_from_group", Some(SystemHookType::GroupMember)),
("user_remove_from_team", Some(SystemHookType::ProjectMember)),
("user_rename", Some(SystemHookType::User)),
("user_update_for_group", Some(SystemHookType::GroupMember)),
("user_update_for_team", Some(SystemHookType::ProjectMember)),
("NOT_A_SYSTEM_HOOK", None),
];
const WEB_ITEMS: &[(&str, Option<WebHookType>)] = &[
("access_token", Some(WebHookType::AccessToken)),
("build", Some(WebHookType::Build)),
("deployment", Some(WebHookType::Deployment)),
("emoji", Some(WebHookType::Emoji)),
("feature_flag", Some(WebHookType::FeatureFlag)),
("issue", Some(WebHookType::Issue)),
("merge_request", Some(WebHookType::MergeRequest)),
("note", Some(WebHookType::Note)),
("pipeline", Some(WebHookType::Pipeline)),
("push", Some(WebHookType::Push)),
("release", Some(WebHookType::Release)),
("tag_push", Some(WebHookType::Push)),
("wiki_page", Some(WebHookType::WikiPage)),
("work_item", Some(WebHookType::Issue)),
("NOT_A_HOOK", None),
];
#[test]
fn test_hook_kind_classification() {
for (n, k) in SYSTEM_ITEMS {
assert_eq!(
HookKind::kind_of(&json!({
"event_name": n,
})),
Some(HookKind::System(*k)),
);
}
for (n, k) in WEB_ITEMS {
assert_eq!(
HookKind::kind_of(&json!({
"object_kind": n,
})),
Some(HookKind::Web(*k)),
);
}
assert!(HookKind::kind_of(&json!({})).is_none());
}
#[test]
fn test_system_hook_classification() {
for (n, k) in SYSTEM_ITEMS {
assert_eq!(
SystemHookType::type_of(&json!({
"event_name": n,
})),
*k,
);
}
}
#[test]
fn test_web_hook_classification() {
for (n, k) in WEB_ITEMS {
assert_eq!(
WebHookType::type_of(&json!({
"object_kind": n,
})),
*k,
);
}
}
}