Skip to main content

genai_rs/
webhooks.rs

1//! Webhook types for the `/v1beta/webhooks` resource and the per-request
2//! `webhook_config` field.
3//!
4//! Webhooks let the API push events (batch completion, interaction lifecycle,
5//! video generation) to your HTTPS endpoint instead of requiring polling.
6//!
7//! - Manage registered webhooks with the [`Client`](crate::Client) methods
8//!   `create_webhook`, `get_webhook`, `list_webhooks`, `update_webhook`,
9//!   `delete_webhook`, `ping_webhook`, and `rotate_webhook_signing_secret`.
10//! - Route a single request's events to ad-hoc URIs with
11//!   [`WebhookConfig`] via
12//!   [`InteractionBuilder::with_webhook_config()`](crate::InteractionBuilder::with_webhook_config).
13//!
14//! See `docs/AGENTS_AND_BACKGROUND.md` for the full background-execution +
15//! webhook flow.
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use std::fmt;
20
21/// An event type a webhook can subscribe to.
22///
23/// This enum is marked `#[non_exhaustive]` for forward compatibility.
24/// New event types may be added in future API versions.
25///
26/// # Wire Format
27///
28/// Serializes as dotted lowercase strings: `"batch.succeeded"`,
29/// `"interaction.completed"`, `"video.generated"`, etc.
30///
31/// # Evergreen Pattern
32///
33/// Unknown values from the API deserialize into the `Unknown` variant,
34/// preserving the original data for debugging and roundtrip serialization.
35#[derive(Clone, Debug, PartialEq)]
36#[non_exhaustive]
37pub enum WebhookEvent {
38    /// Batch processing finished successfully.
39    BatchSucceeded,
40    /// Batch was not processed within the 48h timeframe.
41    BatchExpired,
42    /// Batch job failed.
43    BatchFailed,
44    /// Interaction requires action (e.g., function calling).
45    InteractionRequiresAction,
46    /// Interaction completed successfully.
47    InteractionCompleted,
48    /// Interaction failed.
49    InteractionFailed,
50    /// Video generation completed.
51    VideoGenerated,
52    /// Unknown variant for forward compatibility (Evergreen pattern)
53    Unknown {
54        /// The unrecognized event type from the API
55        event_type: String,
56        /// The raw JSON value, preserved for debugging and roundtrip
57        data: serde_json::Value,
58    },
59}
60
61impl WebhookEvent {
62    /// Returns true if this is an unknown webhook event.
63    #[must_use]
64    pub const fn is_unknown(&self) -> bool {
65        matches!(self, Self::Unknown { .. })
66    }
67
68    /// Returns the event type name if this is an unknown webhook event.
69    #[must_use]
70    pub fn unknown_event_type(&self) -> Option<&str> {
71        match self {
72            Self::Unknown { event_type, .. } => Some(event_type),
73            _ => None,
74        }
75    }
76
77    /// Returns the preserved data if this is an unknown webhook event.
78    #[must_use]
79    pub fn unknown_data(&self) -> Option<&serde_json::Value> {
80        match self {
81            Self::Unknown { data, .. } => Some(data),
82            _ => None,
83        }
84    }
85
86    const fn as_wire(&self) -> Option<&'static str> {
87        match self {
88            Self::BatchSucceeded => Some("batch.succeeded"),
89            Self::BatchExpired => Some("batch.expired"),
90            Self::BatchFailed => Some("batch.failed"),
91            Self::InteractionRequiresAction => Some("interaction.requires_action"),
92            Self::InteractionCompleted => Some("interaction.completed"),
93            Self::InteractionFailed => Some("interaction.failed"),
94            Self::VideoGenerated => Some("video.generated"),
95            Self::Unknown { .. } => None,
96        }
97    }
98}
99
100impl fmt::Display for WebhookEvent {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match self.as_wire() {
103            Some(wire) => write!(f, "{}", wire),
104            None => match self {
105                Self::Unknown { event_type, .. } => write!(f, "{}", event_type),
106                _ => unreachable!("known events always have a wire form"),
107            },
108        }
109    }
110}
111
112impl Serialize for WebhookEvent {
113    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
114    where
115        S: Serializer,
116    {
117        match self.as_wire() {
118            Some(wire) => serializer.serialize_str(wire),
119            None => match self {
120                Self::Unknown { event_type, .. } => serializer.serialize_str(event_type),
121                _ => unreachable!("known events always have a wire form"),
122            },
123        }
124    }
125}
126
127impl<'de> Deserialize<'de> for WebhookEvent {
128    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129    where
130        D: Deserializer<'de>,
131    {
132        let value = serde_json::Value::deserialize(deserializer)?;
133        match value.as_str() {
134            Some("batch.succeeded") => Ok(Self::BatchSucceeded),
135            Some("batch.expired") => Ok(Self::BatchExpired),
136            Some("batch.failed") => Ok(Self::BatchFailed),
137            Some("interaction.requires_action") => Ok(Self::InteractionRequiresAction),
138            Some("interaction.completed") => Ok(Self::InteractionCompleted),
139            Some("interaction.failed") => Ok(Self::InteractionFailed),
140            Some("video.generated") => Ok(Self::VideoGenerated),
141            Some(other) => {
142                tracing::warn!(
143                    "Encountered unknown WebhookEvent '{}' - using Unknown variant (Evergreen)",
144                    other
145                );
146                Ok(Self::Unknown {
147                    event_type: other.to_string(),
148                    data: value,
149                })
150            }
151            None => {
152                let event_type = format!("<non-string: {}>", value);
153                tracing::warn!(
154                    "WebhookEvent received non-string value: {}. \
155                     Preserving in Unknown variant.",
156                    value
157                );
158                Ok(Self::Unknown {
159                    event_type,
160                    data: value,
161                })
162            }
163        }
164    }
165}
166
167/// The state of a registered webhook (output only).
168///
169/// This enum is marked `#[non_exhaustive]` for forward compatibility.
170///
171/// # Wire Format
172///
173/// Serializes as lowercase snake_case strings: `"enabled"`, `"disabled"`,
174/// `"disabled_due_to_failed_deliveries"`.
175///
176/// # Evergreen Pattern
177///
178/// Unknown values from the API deserialize into the `Unknown` variant,
179/// preserving the original data for debugging and roundtrip serialization.
180#[derive(Clone, Debug, PartialEq)]
181#[non_exhaustive]
182pub enum WebhookState {
183    /// Webhook is active and receiving events.
184    Enabled,
185    /// Webhook is disabled and receives no events.
186    Disabled,
187    /// The API disabled the webhook after repeated delivery failures.
188    DisabledDueToFailedDeliveries,
189    /// Unknown variant for forward compatibility (Evergreen pattern)
190    Unknown {
191        /// The unrecognized state type from the API
192        state_type: String,
193        /// The raw JSON value, preserved for debugging and roundtrip
194        data: serde_json::Value,
195    },
196}
197
198impl WebhookState {
199    /// Returns true if this is an unknown webhook state.
200    #[must_use]
201    pub const fn is_unknown(&self) -> bool {
202        matches!(self, Self::Unknown { .. })
203    }
204
205    /// Returns the state type name if this is an unknown webhook state.
206    #[must_use]
207    pub fn unknown_state_type(&self) -> Option<&str> {
208        match self {
209            Self::Unknown { state_type, .. } => Some(state_type),
210            _ => None,
211        }
212    }
213
214    /// Returns the preserved data if this is an unknown webhook state.
215    #[must_use]
216    pub fn unknown_data(&self) -> Option<&serde_json::Value> {
217        match self {
218            Self::Unknown { data, .. } => Some(data),
219            _ => None,
220        }
221    }
222}
223
224impl Serialize for WebhookState {
225    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
226    where
227        S: Serializer,
228    {
229        match self {
230            Self::Enabled => serializer.serialize_str("enabled"),
231            Self::Disabled => serializer.serialize_str("disabled"),
232            Self::DisabledDueToFailedDeliveries => {
233                serializer.serialize_str("disabled_due_to_failed_deliveries")
234            }
235            Self::Unknown { state_type, .. } => serializer.serialize_str(state_type),
236        }
237    }
238}
239
240impl<'de> Deserialize<'de> for WebhookState {
241    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
242    where
243        D: Deserializer<'de>,
244    {
245        let value = serde_json::Value::deserialize(deserializer)?;
246        match value.as_str() {
247            Some("enabled") => Ok(Self::Enabled),
248            Some("disabled") => Ok(Self::Disabled),
249            Some("disabled_due_to_failed_deliveries") => Ok(Self::DisabledDueToFailedDeliveries),
250            Some(other) => {
251                tracing::warn!(
252                    "Encountered unknown WebhookState '{}' - using Unknown variant (Evergreen)",
253                    other
254                );
255                Ok(Self::Unknown {
256                    state_type: other.to_string(),
257                    data: value,
258                })
259            }
260            None => {
261                let state_type = format!("<non-string: {}>", value);
262                tracing::warn!(
263                    "WebhookState received non-string value: {}. \
264                     Preserving in Unknown variant.",
265                    value
266                );
267                Ok(Self::Unknown {
268                    state_type,
269                    data: value,
270                })
271            }
272        }
273    }
274}
275
276/// A signing secret used to verify webhook payloads (output only).
277#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
278#[serde(default)]
279pub struct SigningSecret {
280    /// Truncated version of the signing secret (for identification).
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub truncated_secret: Option<String>,
283    /// Expiration timestamp of the signing secret. A malformed timestamp
284    /// degrades to `None` (see `serde_util::deserialize_lenient_timestamp`).
285    #[serde(
286        default,
287        skip_serializing_if = "Option::is_none",
288        deserialize_with = "crate::serde_util::deserialize_lenient_timestamp::<_, crate::serde_util::ForSigningSecret>"
289    )]
290    pub expire_time: Option<DateTime<Utc>>,
291}
292
293/// A Webhook resource.
294///
295/// Create with [`Webhook::new()`] and register it via
296/// [`Client::create_webhook()`](crate::Client::create_webhook). Fields marked
297/// "output only" are populated by the API and ignored on create.
298///
299/// # Example
300///
301/// ```
302/// use genai_rs::{Webhook, WebhookEvent};
303///
304/// let webhook = Webhook::new(
305///     "https://example.com/hooks/genai",
306///     vec![WebhookEvent::InteractionCompleted, WebhookEvent::InteractionFailed],
307/// )
308/// .with_name("my-hook");
309/// ```
310#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
311#[serde(default)]
312pub struct Webhook {
313    /// The URI to which webhook events will be sent (required).
314    pub uri: String,
315    /// The events that the webhook is subscribed to (required).
316    pub subscribed_events: Vec<WebhookEvent>,
317    /// Optional user-provided name of the webhook.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub name: Option<String>,
320    /// Output only. The ID of the webhook.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub id: Option<String>,
323    /// Output only. The state of the webhook.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub state: Option<WebhookState>,
326    /// Output only. The signing secrets associated with this webhook.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub signing_secrets: Option<Vec<SigningSecret>>,
329    /// Output only. The new signing secret. Only populated on create —
330    /// store it securely, it is not returned again.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub new_signing_secret: Option<String>,
333    /// Output only. When the webhook was created. A malformed timestamp
334    /// degrades to `None` rather than dropping the webhook from a listed
335    /// page (see `serde_util::deserialize_lenient_timestamp`).
336    #[serde(
337        default,
338        skip_serializing_if = "Option::is_none",
339        deserialize_with = "crate::serde_util::deserialize_lenient_timestamp::<_, crate::serde_util::ForWebhook>"
340    )]
341    pub create_time: Option<DateTime<Utc>>,
342    /// Output only. When the webhook was last updated. A malformed timestamp
343    /// degrades to `None` rather than dropping the webhook from a listed
344    /// page (see `serde_util::deserialize_lenient_timestamp`).
345    #[serde(
346        default,
347        skip_serializing_if = "Option::is_none",
348        deserialize_with = "crate::serde_util::deserialize_lenient_timestamp::<_, crate::serde_util::ForWebhook>"
349    )]
350    pub update_time: Option<DateTime<Utc>>,
351}
352
353// Custom Debug that redacts the one-time signing secret (mirrors the
354// api_key redaction on `Client` / `HttpContext`).
355impl std::fmt::Debug for Webhook {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        f.debug_struct("Webhook")
358            .field("uri", &self.uri)
359            .field("subscribed_events", &self.subscribed_events)
360            .field("name", &self.name)
361            .field("id", &self.id)
362            .field("state", &self.state)
363            .field("signing_secrets", &self.signing_secrets)
364            .field(
365                "new_signing_secret",
366                &self.new_signing_secret.as_ref().map(|_| "[REDACTED]"),
367            )
368            .field("create_time", &self.create_time)
369            .field("update_time", &self.update_time)
370            .finish()
371    }
372}
373
374impl Webhook {
375    /// Creates a new webhook definition for registration.
376    #[must_use]
377    pub fn new(uri: impl Into<String>, subscribed_events: Vec<WebhookEvent>) -> Self {
378        Self {
379            uri: uri.into(),
380            subscribed_events,
381            ..Default::default()
382        }
383    }
384
385    /// Sets the user-provided name of the webhook.
386    #[must_use]
387    pub fn with_name(mut self, name: impl Into<String>) -> Self {
388        self.name = Some(name.into());
389        self
390    }
391}
392
393/// A partial update for a webhook (`PATCH /v1beta/webhooks/{id}`).
394///
395/// Only the set fields are updated. Pair with an `update_mask` in
396/// [`Client::update_webhook()`](crate::Client::update_webhook) to control
397/// which fields the server applies.
398#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
399#[serde(default)]
400pub struct WebhookUpdate {
401    /// New user-provided name.
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub name: Option<String>,
404    /// New destination URI.
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub uri: Option<String>,
407    /// New event subscription list.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub subscribed_events: Option<Vec<WebhookEvent>>,
410    /// New state (`enabled` / `disabled`).
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub state: Option<WebhookState>,
413}
414
415impl WebhookUpdate {
416    /// Creates an empty update.
417    #[must_use]
418    pub fn new() -> Self {
419        Self::default()
420    }
421
422    /// Sets a new name.
423    #[must_use]
424    pub fn with_name(mut self, name: impl Into<String>) -> Self {
425        self.name = Some(name.into());
426        self
427    }
428
429    /// Sets a new destination URI.
430    #[must_use]
431    pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
432        self.uri = Some(uri.into());
433        self
434    }
435
436    /// Sets a new event subscription list.
437    #[must_use]
438    pub fn with_subscribed_events(mut self, events: Vec<WebhookEvent>) -> Self {
439        self.subscribed_events = Some(events);
440        self
441    }
442
443    /// Sets a new state.
444    #[must_use]
445    pub fn with_state(mut self, state: WebhookState) -> Self {
446        self.state = Some(state);
447        self
448    }
449}
450
451/// Revocation behavior for previous signing secrets when rotating.
452///
453/// This enum is marked `#[non_exhaustive]` for forward compatibility.
454///
455/// # Wire Format
456///
457/// Serializes as `"revoke_previous_secrets_after_h24"` or
458/// `"revoke_previous_secrets_immediately"`.
459#[derive(Clone, Debug, PartialEq)]
460#[non_exhaustive]
461pub enum RevocationBehavior {
462    /// Previous secrets stay valid for 24 hours (safe rollover).
463    RevokePreviousSecretsAfterH24,
464    /// Previous secrets are revoked immediately.
465    RevokePreviousSecretsImmediately,
466    /// Unknown variant for forward compatibility (Evergreen pattern)
467    Unknown {
468        /// The unrecognized behavior type from the API
469        behavior_type: String,
470        /// The raw JSON value, preserved for debugging and roundtrip
471        data: serde_json::Value,
472    },
473}
474
475impl RevocationBehavior {
476    /// Returns true if this is an unknown revocation behavior.
477    #[must_use]
478    pub const fn is_unknown(&self) -> bool {
479        matches!(self, Self::Unknown { .. })
480    }
481
482    /// Returns the behavior type name if this is an unknown revocation behavior.
483    #[must_use]
484    pub fn unknown_behavior_type(&self) -> Option<&str> {
485        match self {
486            Self::Unknown { behavior_type, .. } => Some(behavior_type),
487            _ => None,
488        }
489    }
490
491    /// Returns the preserved data if this is an unknown revocation behavior.
492    #[must_use]
493    pub fn unknown_data(&self) -> Option<&serde_json::Value> {
494        match self {
495            Self::Unknown { data, .. } => Some(data),
496            _ => None,
497        }
498    }
499}
500
501impl Serialize for RevocationBehavior {
502    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
503    where
504        S: Serializer,
505    {
506        match self {
507            Self::RevokePreviousSecretsAfterH24 => {
508                serializer.serialize_str("revoke_previous_secrets_after_h24")
509            }
510            Self::RevokePreviousSecretsImmediately => {
511                serializer.serialize_str("revoke_previous_secrets_immediately")
512            }
513            Self::Unknown { behavior_type, .. } => serializer.serialize_str(behavior_type),
514        }
515    }
516}
517
518impl<'de> Deserialize<'de> for RevocationBehavior {
519    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
520    where
521        D: Deserializer<'de>,
522    {
523        let value = serde_json::Value::deserialize(deserializer)?;
524        match value.as_str() {
525            Some("revoke_previous_secrets_after_h24") => Ok(Self::RevokePreviousSecretsAfterH24),
526            Some("revoke_previous_secrets_immediately") => {
527                Ok(Self::RevokePreviousSecretsImmediately)
528            }
529            Some(other) => {
530                tracing::warn!(
531                    "Encountered unknown RevocationBehavior '{}' - using Unknown variant (Evergreen)",
532                    other
533                );
534                Ok(Self::Unknown {
535                    behavior_type: other.to_string(),
536                    data: value,
537                })
538            }
539            None => {
540                let behavior_type = format!("<non-string: {}>", value);
541                tracing::warn!(
542                    "RevocationBehavior received non-string value: {}. \
543                     Preserving in Unknown variant.",
544                    value
545                );
546                Ok(Self::Unknown {
547                    behavior_type,
548                    data: value,
549                })
550            }
551        }
552    }
553}
554
555/// Response for `GET /v1beta/webhooks` (list).
556#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
557#[serde(default)]
558pub struct WebhookListResponse {
559    /// The webhooks on this page. A null or malformed list degrades to
560    /// empty; malformed elements drop individually.
561    #[serde(deserialize_with = "crate::serde_util::deserialize_lenient_vec")]
562    pub webhooks: Vec<Webhook>,
563    /// Token for the next page. Absent when there are no more pages.
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub next_page_token: Option<String>,
566}
567
568/// Response for `POST /v1beta/webhooks/{id}:rotateSigningSecret`.
569#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
570#[serde(default)]
571pub struct RotateSigningSecretResponse {
572    /// The newly generated signing secret. Store it securely — it is not
573    /// returned again.
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub secret: Option<String>,
576}
577
578// Custom Debug that redacts the rotated signing secret (mirrors the
579// api_key redaction on `Client` / `HttpContext`).
580impl std::fmt::Debug for RotateSigningSecretResponse {
581    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
582        f.debug_struct("RotateSigningSecretResponse")
583            .field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
584            .finish()
585    }
586}
587
588/// Per-request webhook configuration (`webhook_config` on an interaction
589/// request).
590///
591/// When set, events for this request are delivered to `uris` instead of the
592/// registered webhooks, and `user_metadata` is echoed back on each event.
593///
594/// # Example
595///
596/// ```
597/// use genai_rs::WebhookConfig;
598///
599/// let config = WebhookConfig::new()
600///     .with_uris(vec!["https://example.com/hooks/genai".to_string()])
601///     .with_user_metadata(serde_json::json!({"job": "nightly-report"}));
602/// ```
603#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
604#[serde(default)]
605pub struct WebhookConfig {
606    /// If set, these webhook URIs are used for events from this request
607    /// instead of the registered webhooks.
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub uris: Option<Vec<String>>,
610    /// User metadata returned on each event emission to the webhooks.
611    #[serde(skip_serializing_if = "Option::is_none")]
612    pub user_metadata: Option<serde_json::Value>,
613}
614
615impl WebhookConfig {
616    /// Creates an empty webhook config.
617    #[must_use]
618    pub fn new() -> Self {
619        Self::default()
620    }
621
622    /// Sets the override webhook URIs for this request.
623    #[must_use]
624    pub fn with_uris(mut self, uris: Vec<String>) -> Self {
625        self.uris = Some(uris);
626        self
627    }
628
629    /// Sets the user metadata echoed back on each event emission.
630    #[must_use]
631    pub fn with_user_metadata(mut self, metadata: serde_json::Value) -> Self {
632        self.user_metadata = Some(metadata);
633        self
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use serde_json::json;
641
642    #[test]
643    fn test_webhook_event_wire_roundtrip() {
644        for (event, wire) in [
645            (WebhookEvent::BatchSucceeded, "\"batch.succeeded\""),
646            (WebhookEvent::BatchExpired, "\"batch.expired\""),
647            (WebhookEvent::BatchFailed, "\"batch.failed\""),
648            (
649                WebhookEvent::InteractionRequiresAction,
650                "\"interaction.requires_action\"",
651            ),
652            (
653                WebhookEvent::InteractionCompleted,
654                "\"interaction.completed\"",
655            ),
656            (WebhookEvent::InteractionFailed, "\"interaction.failed\""),
657            (WebhookEvent::VideoGenerated, "\"video.generated\""),
658        ] {
659            assert_eq!(serde_json::to_string(&event).unwrap(), wire);
660            let parsed: WebhookEvent = serde_json::from_str(wire).unwrap();
661            assert_eq!(parsed, event);
662        }
663    }
664
665    #[test]
666    fn test_webhook_event_unknown_roundtrip() {
667        let unknown: WebhookEvent = serde_json::from_str("\"file.generated\"").unwrap();
668        assert!(unknown.is_unknown());
669        assert_eq!(unknown.unknown_event_type(), Some("file.generated"));
670        assert!(unknown.unknown_data().is_some());
671        assert_eq!(
672            serde_json::to_string(&unknown).unwrap(),
673            "\"file.generated\""
674        );
675    }
676
677    #[test]
678    fn test_webhook_state_wire_roundtrip() {
679        for (state, wire) in [
680            (WebhookState::Enabled, "\"enabled\""),
681            (WebhookState::Disabled, "\"disabled\""),
682            (
683                WebhookState::DisabledDueToFailedDeliveries,
684                "\"disabled_due_to_failed_deliveries\"",
685            ),
686        ] {
687            assert_eq!(serde_json::to_string(&state).unwrap(), wire);
688            let parsed: WebhookState = serde_json::from_str(wire).unwrap();
689            assert_eq!(parsed, state);
690        }
691    }
692
693    #[test]
694    fn test_webhook_state_unknown_roundtrip() {
695        let unknown: WebhookState = serde_json::from_str("\"paused\"").unwrap();
696        assert!(unknown.is_unknown());
697        assert_eq!(unknown.unknown_state_type(), Some("paused"));
698        assert!(unknown.unknown_data().is_some());
699        assert_eq!(serde_json::to_string(&unknown).unwrap(), "\"paused\"");
700    }
701
702    #[test]
703    fn test_revocation_behavior_wire_roundtrip() {
704        for (behavior, wire) in [
705            (
706                RevocationBehavior::RevokePreviousSecretsAfterH24,
707                "\"revoke_previous_secrets_after_h24\"",
708            ),
709            (
710                RevocationBehavior::RevokePreviousSecretsImmediately,
711                "\"revoke_previous_secrets_immediately\"",
712            ),
713        ] {
714            assert_eq!(serde_json::to_string(&behavior).unwrap(), wire);
715            let parsed: RevocationBehavior = serde_json::from_str(wire).unwrap();
716            assert_eq!(parsed, behavior);
717        }
718    }
719
720    #[test]
721    fn test_revocation_behavior_unknown_roundtrip() {
722        let unknown: RevocationBehavior = serde_json::from_str("\"revoke_after_week\"").unwrap();
723        assert!(unknown.is_unknown());
724        assert_eq!(unknown.unknown_behavior_type(), Some("revoke_after_week"));
725        assert!(unknown.unknown_data().is_some());
726        assert_eq!(
727            serde_json::to_string(&unknown).unwrap(),
728            "\"revoke_after_week\""
729        );
730    }
731
732    #[test]
733    fn test_webhook_new_serializes_input_fields_only() {
734        let webhook = Webhook::new(
735            "https://example.com/hook",
736            vec![WebhookEvent::InteractionCompleted],
737        )
738        .with_name("my-hook");
739
740        let value = serde_json::to_value(&webhook).unwrap();
741        assert_eq!(value["uri"], "https://example.com/hook");
742        assert_eq!(value["subscribed_events"][0], "interaction.completed");
743        assert_eq!(value["name"], "my-hook");
744        // Output-only fields are skipped when unset
745        for field in [
746            "id",
747            "state",
748            "signing_secrets",
749            "new_signing_secret",
750            "create_time",
751            "update_time",
752        ] {
753            assert!(value.get(field).is_none(), "{field} should be skipped");
754        }
755    }
756
757    #[test]
758    fn test_webhook_full_resource_roundtrip() {
759        // Wire fixture derived from the generated google-genai bindings.
760        let json = json!({
761            "id": "wh123bare0pq",
762            "name": "my-hook",
763            "uri": "https://example.com/hook",
764            "subscribed_events": ["batch.succeeded", "interaction.failed", "video.generated"],
765            "state": "enabled",
766            "signing_secrets": [
767                {"truncated_secret": "whsec_...abcd", "expire_time": "2026-08-01T00:00:00Z"}
768            ],
769            "new_signing_secret": "whsec_full_secret",
770            "create_time": "2026-07-01T12:00:00Z",
771            "update_time": "2026-07-02T12:00:00Z"
772        });
773
774        let webhook: Webhook = serde_json::from_value(json.clone()).unwrap();
775        assert_eq!(webhook.id.as_deref(), Some("wh123bare0pq"));
776        assert_eq!(webhook.state, Some(WebhookState::Enabled));
777        assert_eq!(webhook.subscribed_events.len(), 3);
778        assert_eq!(
779            webhook.signing_secrets.as_ref().unwrap()[0]
780                .truncated_secret
781                .as_deref(),
782            Some("whsec_...abcd")
783        );
784
785        let back = serde_json::to_value(&webhook).unwrap();
786        assert_eq!(back, json);
787    }
788
789    #[test]
790    fn test_webhook_update_partial_serialization() {
791        let update = WebhookUpdate::new().with_state(WebhookState::Disabled);
792        let value = serde_json::to_value(&update).unwrap();
793        assert_eq!(value, json!({"state": "disabled"}));
794    }
795
796    #[test]
797    fn test_webhook_list_response_deserialization() {
798        let json = json!({
799            "webhooks": [
800                {"uri": "https://a.example.com", "subscribed_events": ["batch.failed"]}
801            ],
802            "next_page_token": "tok-1"
803        });
804        let list: WebhookListResponse = serde_json::from_value(json).unwrap();
805        assert_eq!(list.webhooks.len(), 1);
806        assert_eq!(list.next_page_token.as_deref(), Some("tok-1"));
807
808        // Empty response is valid too
809        let empty: WebhookListResponse = serde_json::from_str("{}").unwrap();
810        assert!(empty.webhooks.is_empty());
811        assert!(empty.next_page_token.is_none());
812
813        // Present-but-degenerate list keys degrade like the trigger and
814        // environment envelopes, rather than erroring the page.
815        let null: WebhookListResponse = serde_json::from_value(json!({"webhooks": null})).unwrap();
816        assert!(null.webhooks.is_empty());
817        let bad: WebhookListResponse = serde_json::from_value(json!({"webhooks": 7})).unwrap();
818        assert!(bad.webhooks.is_empty());
819
820        // The element-drop arm, pinned on the concrete type: a malformed
821        // timestamp does NOT drop the webhook (the lenient timestamp
822        // degrades that field alone), while a wrong-typed modeled field
823        // drops only its own element and the good sibling survives.
824        let partial: WebhookListResponse = serde_json::from_value(json!({"webhooks": [
825            {"uri": "https://a.example.com", "create_time": "not-a-time"},
826            {"uri": 42},
827            {"uri": "https://b.example.com"}
828        ]}))
829        .unwrap();
830        assert_eq!(partial.webhooks.len(), 2);
831        assert_eq!(partial.webhooks[0].uri, "https://a.example.com");
832        assert!(partial.webhooks[0].create_time.is_none());
833        assert_eq!(partial.webhooks[1].uri, "https://b.example.com");
834    }
835
836    #[test]
837    fn test_rotate_signing_secret_response() {
838        let response: RotateSigningSecretResponse =
839            serde_json::from_str(r#"{"secret": "whsec_new"}"#).unwrap();
840        assert_eq!(response.secret.as_deref(), Some("whsec_new"));
841
842        let empty: RotateSigningSecretResponse = serde_json::from_str("{}").unwrap();
843        assert!(empty.secret.is_none());
844    }
845
846    #[test]
847    fn test_webhook_debug_redacts_new_signing_secret() {
848        let webhook = Webhook {
849            new_signing_secret: Some("whsec_super_secret".to_string()),
850            ..Webhook::new("https://example.com/hook", vec![])
851        };
852        let debug = format!("{webhook:?}");
853        assert!(!debug.contains("whsec_super_secret"));
854        assert!(debug.contains("[REDACTED]"));
855        // Absent secrets print as None (no misleading placeholder).
856        let no_secret = Webhook::new("https://example.com/hook", vec![]);
857        assert!(!format!("{no_secret:?}").contains("[REDACTED]"));
858    }
859
860    #[test]
861    fn test_rotate_signing_secret_response_debug_redacts_secret() {
862        let response = RotateSigningSecretResponse {
863            secret: Some("whsec_rotated_secret".to_string()),
864        };
865        let debug = format!("{response:?}");
866        assert!(!debug.contains("whsec_rotated_secret"));
867        assert!(debug.contains("[REDACTED]"));
868    }
869
870    #[test]
871    fn test_webhook_config_serialization() {
872        let config = WebhookConfig::new()
873            .with_uris(vec!["https://example.com/hook".to_string()])
874            .with_user_metadata(json!({"job": "nightly"}));
875
876        let value = serde_json::to_value(&config).unwrap();
877        assert_eq!(
878            value,
879            json!({
880                "uris": ["https://example.com/hook"],
881                "user_metadata": {"job": "nightly"}
882            })
883        );
884    }
885
886    #[test]
887    fn test_webhook_config_empty_serializes_to_empty_object() {
888        let config = WebhookConfig::new();
889        assert_eq!(serde_json::to_string(&config).unwrap(), "{}");
890    }
891
892    #[test]
893    fn test_webhook_config_roundtrip() {
894        let config = WebhookConfig::new()
895            .with_uris(vec!["https://example.com/a".to_string()])
896            .with_user_metadata(json!({"k": [1, 2, 3]}));
897        let json = serde_json::to_string(&config).unwrap();
898        let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
899        assert_eq!(config, parsed);
900    }
901
902    #[test]
903    fn test_webhook_event_display() {
904        assert_eq!(
905            WebhookEvent::InteractionCompleted.to_string(),
906            "interaction.completed"
907        );
908        let unknown = WebhookEvent::Unknown {
909            event_type: "x.y".to_string(),
910            data: json!("x.y"),
911        };
912        assert_eq!(unknown.to_string(), "x.y");
913    }
914}