Skip to main content

fraiseql_server/subscriptions/
lifecycle.rs

1//! Subscription lifecycle hooks.
2//!
3//! The [`SubscriptionLifecycle`] trait provides callbacks invoked at key points
4//! in the `WebSocket` subscription lifecycle. Implementations can perform
5//! authentication, rate limiting, audit logging, or custom authorisation.
6
7use async_trait::async_trait;
8
9/// Callbacks for subscription lifecycle events.
10///
11/// All methods have default no-op implementations, so you only need to
12/// override the hooks you care about.
13///
14/// # Fail-closed vs fire-and-forget
15///
16/// - `on_connect` / `on_subscribe` are **fail-closed**: returning `Err(reason)` rejects the
17///   connection or subscription.
18/// - `on_disconnect` / `on_unsubscribe` are **fire-and-forget**: the connection is already closing
19///   and there is nothing to reject.
20// Reason: used as dyn Trait (Arc<dyn SubscriptionLifecycle>); async_trait ensures Send bounds and
21// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
22#[async_trait]
23pub trait SubscriptionLifecycle: Send + Sync + 'static {
24    /// Called after `connection_init` is received, before `connection_ack`.
25    ///
26    /// Return `Err(reason)` to reject the connection with close code 4400.
27    async fn on_connect(
28        &self,
29        _params: &serde_json::Value,
30        _connection_id: &str,
31    ) -> Result<(), String> {
32        Ok(())
33    }
34
35    /// Called when the `WebSocket` connection closes (for any reason).
36    async fn on_disconnect(&self, _connection_id: &str) {}
37
38    /// Called before a subscription is registered with the manager.
39    ///
40    /// Return `Err(reason)` to reject the subscription (the connection stays open).
41    async fn on_subscribe(
42        &self,
43        _subscription_name: &str,
44        _variables: &serde_json::Value,
45        _connection_id: &str,
46    ) -> Result<(), String> {
47        Ok(())
48    }
49
50    /// Called when a client sends `complete` for a subscription.
51    async fn on_unsubscribe(&self, _subscription_id: &str, _connection_id: &str) {}
52}
53
54/// No-op lifecycle that accepts everything.
55pub struct NoopLifecycle;
56
57// Reason: SubscriptionLifecycle is defined with #[async_trait]; all implementations must match
58// its transformed method signatures to satisfy the trait contract
59// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
60#[async_trait]
61impl SubscriptionLifecycle for NoopLifecycle {}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[tokio::test]
68    async fn noop_lifecycle_accepts_connect() {
69        let lifecycle = NoopLifecycle;
70        let result = lifecycle.on_connect(&serde_json::json!({}), "conn-1").await;
71        assert!(result.is_ok(), "noop lifecycle should accept any connection");
72    }
73
74    #[tokio::test]
75    async fn noop_lifecycle_accepts_subscribe() {
76        let lifecycle = NoopLifecycle;
77        let result = lifecycle.on_subscribe("orderCreated", &serde_json::json!({}), "conn-1").await;
78        assert!(result.is_ok(), "noop lifecycle should accept any subscription");
79    }
80}