use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum WebhookEvent {
BatchSucceeded,
BatchExpired,
BatchFailed,
InteractionRequiresAction,
InteractionCompleted,
InteractionFailed,
VideoGenerated,
Unknown {
event_type: String,
data: serde_json::Value,
},
}
impl WebhookEvent {
#[must_use]
pub const fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown { .. })
}
#[must_use]
pub fn unknown_event_type(&self) -> Option<&str> {
match self {
Self::Unknown { event_type, .. } => Some(event_type),
_ => None,
}
}
#[must_use]
pub fn unknown_data(&self) -> Option<&serde_json::Value> {
match self {
Self::Unknown { data, .. } => Some(data),
_ => None,
}
}
const fn as_wire(&self) -> Option<&'static str> {
match self {
Self::BatchSucceeded => Some("batch.succeeded"),
Self::BatchExpired => Some("batch.expired"),
Self::BatchFailed => Some("batch.failed"),
Self::InteractionRequiresAction => Some("interaction.requires_action"),
Self::InteractionCompleted => Some("interaction.completed"),
Self::InteractionFailed => Some("interaction.failed"),
Self::VideoGenerated => Some("video.generated"),
Self::Unknown { .. } => None,
}
}
}
impl fmt::Display for WebhookEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.as_wire() {
Some(wire) => write!(f, "{}", wire),
None => match self {
Self::Unknown { event_type, .. } => write!(f, "{}", event_type),
_ => unreachable!("known events always have a wire form"),
},
}
}
}
impl Serialize for WebhookEvent {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self.as_wire() {
Some(wire) => serializer.serialize_str(wire),
None => match self {
Self::Unknown { event_type, .. } => serializer.serialize_str(event_type),
_ => unreachable!("known events always have a wire form"),
},
}
}
}
impl<'de> Deserialize<'de> for WebhookEvent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value.as_str() {
Some("batch.succeeded") => Ok(Self::BatchSucceeded),
Some("batch.expired") => Ok(Self::BatchExpired),
Some("batch.failed") => Ok(Self::BatchFailed),
Some("interaction.requires_action") => Ok(Self::InteractionRequiresAction),
Some("interaction.completed") => Ok(Self::InteractionCompleted),
Some("interaction.failed") => Ok(Self::InteractionFailed),
Some("video.generated") => Ok(Self::VideoGenerated),
Some(other) => {
tracing::warn!(
"Encountered unknown WebhookEvent '{}' - using Unknown variant (Evergreen)",
other
);
Ok(Self::Unknown {
event_type: other.to_string(),
data: value,
})
}
None => {
let event_type = format!("<non-string: {}>", value);
tracing::warn!(
"WebhookEvent received non-string value: {}. \
Preserving in Unknown variant.",
value
);
Ok(Self::Unknown {
event_type,
data: value,
})
}
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum WebhookState {
Enabled,
Disabled,
DisabledDueToFailedDeliveries,
Unknown {
state_type: String,
data: serde_json::Value,
},
}
impl WebhookState {
#[must_use]
pub const fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown { .. })
}
#[must_use]
pub fn unknown_state_type(&self) -> Option<&str> {
match self {
Self::Unknown { state_type, .. } => Some(state_type),
_ => None,
}
}
#[must_use]
pub fn unknown_data(&self) -> Option<&serde_json::Value> {
match self {
Self::Unknown { data, .. } => Some(data),
_ => None,
}
}
}
impl Serialize for WebhookState {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::Enabled => serializer.serialize_str("enabled"),
Self::Disabled => serializer.serialize_str("disabled"),
Self::DisabledDueToFailedDeliveries => {
serializer.serialize_str("disabled_due_to_failed_deliveries")
}
Self::Unknown { state_type, .. } => serializer.serialize_str(state_type),
}
}
}
impl<'de> Deserialize<'de> for WebhookState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value.as_str() {
Some("enabled") => Ok(Self::Enabled),
Some("disabled") => Ok(Self::Disabled),
Some("disabled_due_to_failed_deliveries") => Ok(Self::DisabledDueToFailedDeliveries),
Some(other) => {
tracing::warn!(
"Encountered unknown WebhookState '{}' - using Unknown variant (Evergreen)",
other
);
Ok(Self::Unknown {
state_type: other.to_string(),
data: value,
})
}
None => {
let state_type = format!("<non-string: {}>", value);
tracing::warn!(
"WebhookState received non-string value: {}. \
Preserving in Unknown variant.",
value
);
Ok(Self::Unknown {
state_type,
data: value,
})
}
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SigningSecret {
#[serde(skip_serializing_if = "Option::is_none")]
pub truncated_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expire_time: Option<DateTime<Utc>>,
}
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Webhook {
pub uri: String,
pub subscribed_events: Vec<WebhookEvent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<WebhookState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signing_secrets: Option<Vec<SigningSecret>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_signing_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub create_time: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_time: Option<DateTime<Utc>>,
}
impl std::fmt::Debug for Webhook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Webhook")
.field("uri", &self.uri)
.field("subscribed_events", &self.subscribed_events)
.field("name", &self.name)
.field("id", &self.id)
.field("state", &self.state)
.field("signing_secrets", &self.signing_secrets)
.field(
"new_signing_secret",
&self.new_signing_secret.as_ref().map(|_| "[REDACTED]"),
)
.field("create_time", &self.create_time)
.field("update_time", &self.update_time)
.finish()
}
}
impl Webhook {
#[must_use]
pub fn new(uri: impl Into<String>, subscribed_events: Vec<WebhookEvent>) -> Self {
Self {
uri: uri.into(),
subscribed_events,
..Default::default()
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subscribed_events: Option<Vec<WebhookEvent>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<WebhookState>,
}
impl WebhookUpdate {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
self.uri = Some(uri.into());
self
}
#[must_use]
pub fn with_subscribed_events(mut self, events: Vec<WebhookEvent>) -> Self {
self.subscribed_events = Some(events);
self
}
#[must_use]
pub fn with_state(mut self, state: WebhookState) -> Self {
self.state = Some(state);
self
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum RevocationBehavior {
RevokePreviousSecretsAfterH24,
RevokePreviousSecretsImmediately,
Unknown {
behavior_type: String,
data: serde_json::Value,
},
}
impl RevocationBehavior {
#[must_use]
pub const fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown { .. })
}
#[must_use]
pub fn unknown_behavior_type(&self) -> Option<&str> {
match self {
Self::Unknown { behavior_type, .. } => Some(behavior_type),
_ => None,
}
}
#[must_use]
pub fn unknown_data(&self) -> Option<&serde_json::Value> {
match self {
Self::Unknown { data, .. } => Some(data),
_ => None,
}
}
}
impl Serialize for RevocationBehavior {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::RevokePreviousSecretsAfterH24 => {
serializer.serialize_str("revoke_previous_secrets_after_h24")
}
Self::RevokePreviousSecretsImmediately => {
serializer.serialize_str("revoke_previous_secrets_immediately")
}
Self::Unknown { behavior_type, .. } => serializer.serialize_str(behavior_type),
}
}
}
impl<'de> Deserialize<'de> for RevocationBehavior {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value.as_str() {
Some("revoke_previous_secrets_after_h24") => Ok(Self::RevokePreviousSecretsAfterH24),
Some("revoke_previous_secrets_immediately") => {
Ok(Self::RevokePreviousSecretsImmediately)
}
Some(other) => {
tracing::warn!(
"Encountered unknown RevocationBehavior '{}' - using Unknown variant (Evergreen)",
other
);
Ok(Self::Unknown {
behavior_type: other.to_string(),
data: value,
})
}
None => {
let behavior_type = format!("<non-string: {}>", value);
tracing::warn!(
"RevocationBehavior received non-string value: {}. \
Preserving in Unknown variant.",
value
);
Ok(Self::Unknown {
behavior_type,
data: value,
})
}
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookListResponse {
pub webhooks: Vec<Webhook>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_page_token: Option<String>,
}
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RotateSigningSecretResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
}
impl std::fmt::Debug for RotateSigningSecretResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RotateSigningSecretResponse")
.field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
.finish()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub uris: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_metadata: Option<serde_json::Value>,
}
impl WebhookConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_uris(mut self, uris: Vec<String>) -> Self {
self.uris = Some(uris);
self
}
#[must_use]
pub fn with_user_metadata(mut self, metadata: serde_json::Value) -> Self {
self.user_metadata = Some(metadata);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_webhook_event_wire_roundtrip() {
for (event, wire) in [
(WebhookEvent::BatchSucceeded, "\"batch.succeeded\""),
(WebhookEvent::BatchExpired, "\"batch.expired\""),
(WebhookEvent::BatchFailed, "\"batch.failed\""),
(
WebhookEvent::InteractionRequiresAction,
"\"interaction.requires_action\"",
),
(
WebhookEvent::InteractionCompleted,
"\"interaction.completed\"",
),
(WebhookEvent::InteractionFailed, "\"interaction.failed\""),
(WebhookEvent::VideoGenerated, "\"video.generated\""),
] {
assert_eq!(serde_json::to_string(&event).unwrap(), wire);
let parsed: WebhookEvent = serde_json::from_str(wire).unwrap();
assert_eq!(parsed, event);
}
}
#[test]
fn test_webhook_event_unknown_roundtrip() {
let unknown: WebhookEvent = serde_json::from_str("\"file.generated\"").unwrap();
assert!(unknown.is_unknown());
assert_eq!(unknown.unknown_event_type(), Some("file.generated"));
assert!(unknown.unknown_data().is_some());
assert_eq!(
serde_json::to_string(&unknown).unwrap(),
"\"file.generated\""
);
}
#[test]
fn test_webhook_state_wire_roundtrip() {
for (state, wire) in [
(WebhookState::Enabled, "\"enabled\""),
(WebhookState::Disabled, "\"disabled\""),
(
WebhookState::DisabledDueToFailedDeliveries,
"\"disabled_due_to_failed_deliveries\"",
),
] {
assert_eq!(serde_json::to_string(&state).unwrap(), wire);
let parsed: WebhookState = serde_json::from_str(wire).unwrap();
assert_eq!(parsed, state);
}
}
#[test]
fn test_webhook_state_unknown_roundtrip() {
let unknown: WebhookState = serde_json::from_str("\"paused\"").unwrap();
assert!(unknown.is_unknown());
assert_eq!(unknown.unknown_state_type(), Some("paused"));
assert!(unknown.unknown_data().is_some());
assert_eq!(serde_json::to_string(&unknown).unwrap(), "\"paused\"");
}
#[test]
fn test_revocation_behavior_wire_roundtrip() {
for (behavior, wire) in [
(
RevocationBehavior::RevokePreviousSecretsAfterH24,
"\"revoke_previous_secrets_after_h24\"",
),
(
RevocationBehavior::RevokePreviousSecretsImmediately,
"\"revoke_previous_secrets_immediately\"",
),
] {
assert_eq!(serde_json::to_string(&behavior).unwrap(), wire);
let parsed: RevocationBehavior = serde_json::from_str(wire).unwrap();
assert_eq!(parsed, behavior);
}
}
#[test]
fn test_revocation_behavior_unknown_roundtrip() {
let unknown: RevocationBehavior = serde_json::from_str("\"revoke_after_week\"").unwrap();
assert!(unknown.is_unknown());
assert_eq!(unknown.unknown_behavior_type(), Some("revoke_after_week"));
assert!(unknown.unknown_data().is_some());
assert_eq!(
serde_json::to_string(&unknown).unwrap(),
"\"revoke_after_week\""
);
}
#[test]
fn test_webhook_new_serializes_input_fields_only() {
let webhook = Webhook::new(
"https://example.com/hook",
vec![WebhookEvent::InteractionCompleted],
)
.with_name("my-hook");
let value = serde_json::to_value(&webhook).unwrap();
assert_eq!(value["uri"], "https://example.com/hook");
assert_eq!(value["subscribed_events"][0], "interaction.completed");
assert_eq!(value["name"], "my-hook");
for field in [
"id",
"state",
"signing_secrets",
"new_signing_secret",
"create_time",
"update_time",
] {
assert!(value.get(field).is_none(), "{field} should be skipped");
}
}
#[test]
fn test_webhook_full_resource_roundtrip() {
let json = json!({
"id": "webhooks/wh-123",
"name": "my-hook",
"uri": "https://example.com/hook",
"subscribed_events": ["batch.succeeded", "interaction.failed", "video.generated"],
"state": "enabled",
"signing_secrets": [
{"truncated_secret": "whsec_...abcd", "expire_time": "2026-08-01T00:00:00Z"}
],
"new_signing_secret": "whsec_full_secret",
"create_time": "2026-07-01T12:00:00Z",
"update_time": "2026-07-02T12:00:00Z"
});
let webhook: Webhook = serde_json::from_value(json.clone()).unwrap();
assert_eq!(webhook.id.as_deref(), Some("webhooks/wh-123"));
assert_eq!(webhook.state, Some(WebhookState::Enabled));
assert_eq!(webhook.subscribed_events.len(), 3);
assert_eq!(
webhook.signing_secrets.as_ref().unwrap()[0]
.truncated_secret
.as_deref(),
Some("whsec_...abcd")
);
let back = serde_json::to_value(&webhook).unwrap();
assert_eq!(back, json);
}
#[test]
fn test_webhook_update_partial_serialization() {
let update = WebhookUpdate::new().with_state(WebhookState::Disabled);
let value = serde_json::to_value(&update).unwrap();
assert_eq!(value, json!({"state": "disabled"}));
}
#[test]
fn test_webhook_list_response_deserialization() {
let json = json!({
"webhooks": [
{"uri": "https://a.example.com", "subscribed_events": ["batch.failed"]}
],
"next_page_token": "tok-1"
});
let list: WebhookListResponse = serde_json::from_value(json).unwrap();
assert_eq!(list.webhooks.len(), 1);
assert_eq!(list.next_page_token.as_deref(), Some("tok-1"));
let empty: WebhookListResponse = serde_json::from_str("{}").unwrap();
assert!(empty.webhooks.is_empty());
assert!(empty.next_page_token.is_none());
}
#[test]
fn test_rotate_signing_secret_response() {
let response: RotateSigningSecretResponse =
serde_json::from_str(r#"{"secret": "whsec_new"}"#).unwrap();
assert_eq!(response.secret.as_deref(), Some("whsec_new"));
let empty: RotateSigningSecretResponse = serde_json::from_str("{}").unwrap();
assert!(empty.secret.is_none());
}
#[test]
fn test_webhook_debug_redacts_new_signing_secret() {
let webhook = Webhook {
new_signing_secret: Some("whsec_super_secret".to_string()),
..Webhook::new("https://example.com/hook", vec![])
};
let debug = format!("{webhook:?}");
assert!(!debug.contains("whsec_super_secret"));
assert!(debug.contains("[REDACTED]"));
let no_secret = Webhook::new("https://example.com/hook", vec![]);
assert!(!format!("{no_secret:?}").contains("[REDACTED]"));
}
#[test]
fn test_rotate_signing_secret_response_debug_redacts_secret() {
let response = RotateSigningSecretResponse {
secret: Some("whsec_rotated_secret".to_string()),
};
let debug = format!("{response:?}");
assert!(!debug.contains("whsec_rotated_secret"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn test_webhook_config_serialization() {
let config = WebhookConfig::new()
.with_uris(vec!["https://example.com/hook".to_string()])
.with_user_metadata(json!({"job": "nightly"}));
let value = serde_json::to_value(&config).unwrap();
assert_eq!(
value,
json!({
"uris": ["https://example.com/hook"],
"user_metadata": {"job": "nightly"}
})
);
}
#[test]
fn test_webhook_config_empty_serializes_to_empty_object() {
let config = WebhookConfig::new();
assert_eq!(serde_json::to_string(&config).unwrap(), "{}");
}
#[test]
fn test_webhook_config_roundtrip() {
let config = WebhookConfig::new()
.with_uris(vec!["https://example.com/a".to_string()])
.with_user_metadata(json!({"k": [1, 2, 3]}));
let json = serde_json::to_string(&config).unwrap();
let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn test_webhook_event_display() {
assert_eq!(
WebhookEvent::InteractionCompleted.to_string(),
"interaction.completed"
);
let unknown = WebhookEvent::Unknown {
event_type: "x.y".to_string(),
data: json!("x.y"),
};
assert_eq!(unknown.to_string(), "x.y");
}
}