use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::types::Json;
use sqlx::FromRow;
fn de_field_present<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Some(Option::<String>::deserialize(deserializer)?))
}
pub fn mask_webhook_url(url: &str) -> Option<String> {
let url = url.trim();
if url.is_empty() {
return None;
}
match url.split_once("://") {
Some((scheme, rest)) => {
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
if authority_end < rest.len() {
Some(format!("{scheme}://{authority}/…"))
} else {
Some(format!("{scheme}://{authority}"))
}
}
None => Some("…".to_string()),
}
}
#[derive(Debug, Clone, FromRow)]
pub struct WebhookSubscription {
pub id: String,
pub name: String,
pub url: String,
pub event_types: Json<Vec<String>>,
pub min_severity: String,
pub secret: Option<String>,
pub enabled: bool,
pub cursor_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WebhookSubscriptionView {
pub id: String,
pub name: String,
pub url: String,
pub event_types: Vec<String>,
pub min_severity: String,
pub has_secret: bool,
pub enabled: bool,
pub cursor_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<WebhookSubscription> for WebhookSubscriptionView {
fn from(s: WebhookSubscription) -> Self {
WebhookSubscriptionView {
id: s.id,
name: s.name,
url: s.url,
event_types: s.event_types.0,
min_severity: s.min_severity,
has_secret: s.secret.as_deref().map(|v| !v.is_empty()).unwrap_or(false),
enabled: s.enabled,
cursor_at: s.cursor_at,
created_at: s.created_at,
updated_at: s.updated_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct WebhookSubscriptionCreate {
pub name: String,
pub url: String,
pub event_types: Option<Vec<String>>,
pub min_severity: Option<String>,
pub secret: Option<String>,
pub enabled: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
pub struct WebhookSubscriptionUpdate {
pub name: Option<String>,
pub url: Option<String>,
pub event_types: Option<Vec<String>>,
pub min_severity: Option<String>,
#[serde(default, deserialize_with = "de_field_present")]
pub secret: Option<Option<String>>,
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct WebhookDelivery {
pub id: String,
pub subscription_id: String,
pub event_id: Option<String>,
pub event_type: Option<String>,
pub status: String,
pub attempts: i64,
pub response_code: Option<i64>,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub delivered_at: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mask_webhook_url_hides_path_and_token() {
assert_eq!(
mask_webhook_url("https://hooks.slack.com/services/T000/B000/XXXXSECRET"),
Some("https://hooks.slack.com/…".to_string())
);
assert_eq!(
mask_webhook_url("https://example.com:8443/alert?token=abc"),
Some("https://example.com:8443/…".to_string())
);
assert_eq!(
mask_webhook_url("https://example.com"),
Some("https://example.com".to_string())
);
assert_eq!(mask_webhook_url(" "), None);
assert_eq!(mask_webhook_url("not-a-url"), Some("…".to_string()));
}
#[test]
fn webhook_update_secret_is_three_state() {
let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"enabled": true}"#).unwrap();
assert!(u.secret.is_none());
assert_eq!(u.enabled, Some(true));
let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"secret": null}"#).unwrap();
assert_eq!(u.secret, Some(None));
let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"secret": "s3cr3t"}"#).unwrap();
assert_eq!(u.secret, Some(Some("s3cr3t".to_string())));
}
}