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}