hyperstack_server/websocket/
subscription.rs

1use serde::{Deserialize, Serialize};
2
3/// Client message types for subscription management
4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(tag = "type", rename_all = "lowercase")]
6pub enum ClientMessage {
7    /// Subscribe to a view
8    Subscribe(Subscription),
9    /// Unsubscribe from a view
10    Unsubscribe(Unsubscription),
11    /// Keep-alive ping (no response needed)
12    Ping,
13}
14
15/// Client subscription to a specific view
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Subscription {
18    pub view: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub key: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub partition: Option<String>,
23    /// Number of items to return (for windowed subscriptions)
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub take: Option<usize>,
26    /// Number of items to skip (for windowed subscriptions)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub skip: Option<usize>,
29}
30
31/// Client unsubscription request
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Unsubscription {
34    pub view: String,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub key: Option<String>,
37}
38
39impl Unsubscription {
40    /// Generate the subscription key used for tracking
41    pub fn sub_key(&self) -> String {
42        match &self.key {
43            Some(k) => format!("{}:{}", self.view, k),
44            None => format!("{}:*", self.view),
45        }
46    }
47}
48
49impl Subscription {
50    pub fn matches_view(&self, view_id: &str) -> bool {
51        self.view == view_id
52    }
53
54    pub fn matches_key(&self, key: &str) -> bool {
55        self.key.as_ref().is_none_or(|k| k == key)
56    }
57
58    pub fn matches(&self, view_id: &str, key: &str) -> bool {
59        self.matches_view(view_id) && self.matches_key(key)
60    }
61
62    pub fn sub_key(&self) -> String {
63        match &self.key {
64            Some(k) => format!("{}:{}", self.view, k),
65            None => format!("{}:*", self.view),
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use serde_json::json;
74
75    #[test]
76    fn test_subscription_parse() {
77        let json = json!({
78            "view": "SettlementGame/list",
79            "key": "835"
80        });
81
82        let sub: Subscription = serde_json::from_value(json).unwrap();
83        assert_eq!(sub.view, "SettlementGame/list");
84        assert_eq!(sub.key, Some("835".to_string()));
85    }
86
87    #[test]
88    fn test_subscription_no_key() {
89        let json = json!({
90            "view": "SettlementGame/list"
91        });
92
93        let sub: Subscription = serde_json::from_value(json).unwrap();
94        assert_eq!(sub.view, "SettlementGame/list");
95        assert!(sub.key.is_none());
96    }
97
98    #[test]
99    fn test_subscription_matches() {
100        let sub = Subscription {
101            view: "SettlementGame/list".to_string(),
102            key: Some("835".to_string()),
103            partition: None,
104            take: None,
105            skip: None,
106        };
107
108        assert!(sub.matches("SettlementGame/list", "835"));
109        assert!(!sub.matches("SettlementGame/list", "836"));
110        assert!(!sub.matches("SettlementGame/state", "835"));
111    }
112
113    #[test]
114    fn test_subscription_matches_all_keys() {
115        let sub = Subscription {
116            view: "SettlementGame/list".to_string(),
117            key: None,
118            partition: None,
119            take: None,
120            skip: None,
121        };
122
123        assert!(sub.matches("SettlementGame/list", "835"));
124        assert!(sub.matches("SettlementGame/list", "836"));
125        assert!(!sub.matches("SettlementGame/state", "835"));
126    }
127
128    #[test]
129    fn test_client_message_subscribe_parse() {
130        let json = json!({
131            "type": "subscribe",
132            "view": "SettlementGame/list",
133            "key": "835"
134        });
135
136        let msg: ClientMessage = serde_json::from_value(json).unwrap();
137        match msg {
138            ClientMessage::Subscribe(sub) => {
139                assert_eq!(sub.view, "SettlementGame/list");
140                assert_eq!(sub.key, Some("835".to_string()));
141            }
142            _ => panic!("Expected Subscribe"),
143        }
144    }
145
146    #[test]
147    fn test_client_message_unsubscribe_parse() {
148        let json = json!({
149            "type": "unsubscribe",
150            "view": "SettlementGame/list",
151            "key": "835"
152        });
153
154        let msg: ClientMessage = serde_json::from_value(json).unwrap();
155        match msg {
156            ClientMessage::Unsubscribe(unsub) => {
157                assert_eq!(unsub.view, "SettlementGame/list");
158                assert_eq!(unsub.key, Some("835".to_string()));
159            }
160            _ => panic!("Expected Unsubscribe"),
161        }
162    }
163
164    #[test]
165    fn test_client_message_ping_parse() {
166        let json = json!({ "type": "ping" });
167
168        let msg: ClientMessage = serde_json::from_value(json).unwrap();
169        assert!(matches!(msg, ClientMessage::Ping));
170    }
171
172    #[test]
173    fn test_legacy_subscription_parse_as_subscribe() {
174        let json = json!({
175            "view": "SettlementGame/list",
176            "key": "835"
177        });
178
179        let sub: Subscription = serde_json::from_value(json).unwrap();
180        assert_eq!(sub.view, "SettlementGame/list");
181        assert_eq!(sub.key, Some("835".to_string()));
182    }
183
184    #[test]
185    fn test_sub_key_with_key() {
186        let sub = Subscription {
187            view: "SettlementGame/list".to_string(),
188            key: Some("835".to_string()),
189            partition: None,
190            take: None,
191            skip: None,
192        };
193        assert_eq!(sub.sub_key(), "SettlementGame/list:835");
194    }
195
196    #[test]
197    fn test_sub_key_without_key() {
198        let sub = Subscription {
199            view: "SettlementGame/list".to_string(),
200            key: None,
201            partition: None,
202            take: None,
203            skip: None,
204        };
205        assert_eq!(sub.sub_key(), "SettlementGame/list:*");
206    }
207
208    #[test]
209    fn test_unsubscription_sub_key() {
210        let unsub = Unsubscription {
211            view: "SettlementGame/list".to_string(),
212            key: Some("835".to_string()),
213        };
214        assert_eq!(unsub.sub_key(), "SettlementGame/list:835");
215
216        let unsub_all = Unsubscription {
217            view: "SettlementGame/list".to_string(),
218            key: None,
219        };
220        assert_eq!(unsub_all.sub_key(), "SettlementGame/list:*");
221    }
222
223    #[test]
224    fn test_subscription_with_take_skip() {
225        let json = json!({
226            "type": "subscribe",
227            "view": "OreRound/latest",
228            "take": 10,
229            "skip": 0
230        });
231
232        let msg: ClientMessage = serde_json::from_value(json).unwrap();
233        match msg {
234            ClientMessage::Subscribe(sub) => {
235                assert_eq!(sub.view, "OreRound/latest");
236                assert_eq!(sub.take, Some(10));
237                assert_eq!(sub.skip, Some(0));
238            }
239            _ => panic!("Expected Subscribe"),
240        }
241    }
242}