contextvm_sdk/transport/open_stream/
frame.rs1use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16use crate::core::types::JsonRpcNotification;
17use crate::transport::oversized_transfer::NOTIFICATIONS_PROGRESS_METHOD;
18
19use super::constants::OPEN_STREAM_TYPE;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(tag = "frameType", rename_all = "camelCase")]
29pub enum OpenStreamFrame {
30 #[serde(rename_all = "camelCase")]
33 Start {
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 content_type: Option<String>,
37 },
38 Accept,
40 #[serde(rename_all = "camelCase")]
42 Chunk {
43 chunk_index: u64,
45 data: String,
47 },
48 Ping {
50 nonce: String,
52 },
53 Pong {
55 nonce: String,
57 },
58 #[serde(rename_all = "camelCase")]
61 Close {
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 last_chunk_index: Option<u64>,
65 },
66 Abort {
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 reason: Option<String>,
71 },
72}
73
74impl OpenStreamFrame {
75 pub fn frame_type(&self) -> &'static str {
77 match self {
78 Self::Start { .. } => "start",
79 Self::Accept => "accept",
80 Self::Chunk { .. } => "chunk",
81 Self::Ping { .. } => "ping",
82 Self::Pong { .. } => "pong",
83 Self::Close { .. } => "close",
84 Self::Abort { .. } => "abort",
85 }
86 }
87
88 pub fn to_cvm_value(&self) -> Result<Value, serde_json::Error> {
91 let mut value = serde_json::to_value(self)?;
92 if let Value::Object(map) = &mut value {
93 map.insert(
94 "type".to_string(),
95 Value::String(OPEN_STREAM_TYPE.to_string()),
96 );
97 }
98 Ok(value)
99 }
100
101 pub fn from_cvm_value(value: &Value) -> Option<Self> {
105 if value.get("type").and_then(Value::as_str) != Some(OPEN_STREAM_TYPE) {
106 return None;
107 }
108 serde_json::from_value(value.clone()).ok()
109 }
110
111 pub fn is_frame_value(value: &Value) -> bool {
116 value.get("type").and_then(Value::as_str) == Some(OPEN_STREAM_TYPE)
117 && value.get("frameType").and_then(Value::as_str).is_some()
118 }
119
120 pub fn into_progress_notification(
129 &self,
130 progress_token: &str,
131 progress: u64,
132 message: Option<&str>,
133 ) -> Result<JsonRpcNotification, serde_json::Error> {
134 let mut params = Map::new();
135 params.insert(
136 "progressToken".to_string(),
137 Value::String(progress_token.to_string()),
138 );
139 params.insert("progress".to_string(), Value::Number(progress.into()));
140 if let Some(message) = message {
141 params.insert("message".to_string(), Value::String(message.to_string()));
142 }
143 params.insert("cvm".to_string(), self.to_cvm_value()?);
144
145 Ok(JsonRpcNotification {
146 jsonrpc: "2.0".to_string(),
147 method: NOTIFICATIONS_PROGRESS_METHOD.to_string(),
148 params: Some(Value::Object(params)),
149 })
150 }
151}
152
153pub fn open_stream_frame_from_notification(
156 notification: &JsonRpcNotification,
157) -> Option<OpenStreamFrame> {
158 notification
159 .params
160 .as_ref()
161 .and_then(|params| params.get("cvm"))
162 .and_then(OpenStreamFrame::from_cvm_value)
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use serde_json::json;
169
170 fn assert_cvm_roundtrip(frame: OpenStreamFrame) {
171 let value = frame.to_cvm_value().unwrap();
172 assert_eq!(value["type"], json!("open-stream"));
173 assert_eq!(value["frameType"], json!(frame.frame_type()));
174 assert_eq!(OpenStreamFrame::from_cvm_value(&value), Some(frame));
175 }
176
177 #[test]
178 fn all_frame_variants_roundtrip() {
179 assert_cvm_roundtrip(OpenStreamFrame::Start {
180 content_type: Some("text/plain".to_string()),
181 });
182 assert_cvm_roundtrip(OpenStreamFrame::Start { content_type: None });
183 assert_cvm_roundtrip(OpenStreamFrame::Accept);
184 assert_cvm_roundtrip(OpenStreamFrame::Chunk {
185 chunk_index: 7,
186 data: "payload".to_string(),
187 });
188 assert_cvm_roundtrip(OpenStreamFrame::Ping {
189 nonce: "tok:1".to_string(),
190 });
191 assert_cvm_roundtrip(OpenStreamFrame::Pong {
192 nonce: "tok:1".to_string(),
193 });
194 assert_cvm_roundtrip(OpenStreamFrame::Close {
195 last_chunk_index: Some(4),
196 });
197 assert_cvm_roundtrip(OpenStreamFrame::Close {
198 last_chunk_index: None,
199 });
200 assert_cvm_roundtrip(OpenStreamFrame::Abort {
201 reason: Some("boom".to_string()),
202 });
203 assert_cvm_roundtrip(OpenStreamFrame::Abort { reason: None });
204 }
205
206 #[test]
207 fn chunk_frame_serializes_with_camel_case_fields() {
208 let value = OpenStreamFrame::Chunk {
209 chunk_index: 3,
210 data: "abc".to_string(),
211 }
212 .to_cvm_value()
213 .unwrap();
214 assert_eq!(value["type"], json!("open-stream"));
215 assert_eq!(value["frameType"], json!("chunk"));
216 assert_eq!(value["chunkIndex"], json!(3));
217 assert_eq!(value["data"], json!("abc"));
218 assert!(!value.as_object().unwrap().contains_key("chunk_index"));
220 }
221
222 #[test]
223 fn start_content_type_serializes_camel_case_and_omits_when_none() {
224 let with = OpenStreamFrame::Start {
225 content_type: Some("application/json".to_string()),
226 }
227 .to_cvm_value()
228 .unwrap();
229 assert_eq!(with["contentType"], json!("application/json"));
230
231 let without = OpenStreamFrame::Start { content_type: None }
232 .to_cvm_value()
233 .unwrap();
234 assert!(!without.as_object().unwrap().contains_key("contentType"));
235 }
236
237 #[test]
238 fn close_last_chunk_index_serializes_camel_case_and_omits_when_none() {
239 let with = OpenStreamFrame::Close {
240 last_chunk_index: Some(9),
241 }
242 .to_cvm_value()
243 .unwrap();
244 assert_eq!(with["lastChunkIndex"], json!(9));
245
246 let without = OpenStreamFrame::Close {
247 last_chunk_index: None,
248 }
249 .to_cvm_value()
250 .unwrap();
251 assert!(!without.as_object().unwrap().contains_key("lastChunkIndex"));
252 }
253
254 #[test]
255 fn abort_reason_omitted_when_none() {
256 let value = OpenStreamFrame::Abort { reason: None }
257 .to_cvm_value()
258 .unwrap();
259 assert!(!value.as_object().unwrap().contains_key("reason"));
260 }
261
262 #[test]
263 fn from_cvm_value_rejects_wrong_type() {
264 let value = json!({ "type": "oversized-transfer", "frameType": "start" });
265 assert_eq!(OpenStreamFrame::from_cvm_value(&value), None);
266 assert!(!OpenStreamFrame::is_frame_value(&value));
267 }
268
269 #[test]
270 fn from_cvm_value_rejects_unknown_frame_type() {
271 let value = json!({ "type": "open-stream", "frameType": "bogus" });
272 assert_eq!(OpenStreamFrame::from_cvm_value(&value), None);
273 }
274
275 #[test]
276 fn is_frame_value_requires_type_and_frame_type() {
277 assert!(OpenStreamFrame::is_frame_value(
278 &json!({ "type": "open-stream", "frameType": "chunk", "chunkIndex": 0, "data": "x" })
279 ));
280 assert!(!OpenStreamFrame::is_frame_value(
281 &json!({ "type": "open-stream" })
282 ));
283 assert!(!OpenStreamFrame::is_frame_value(
284 &json!({ "frameType": "chunk" })
285 ));
286 assert!(!OpenStreamFrame::is_frame_value(&json!("not an object")));
287 }
288
289 #[test]
290 fn into_progress_notification_builds_progress_envelope() {
291 let notification = OpenStreamFrame::Chunk {
292 chunk_index: 2,
293 data: "frag".to_string(),
294 }
295 .into_progress_notification("tok-1", 7, Some("hi"))
296 .unwrap();
297
298 assert_eq!(notification.method, "notifications/progress");
299 let params = notification.params.as_ref().unwrap();
300 assert_eq!(params["progressToken"], json!("tok-1"));
302 assert_eq!(params["progress"], json!(7));
304 assert_eq!(params["message"], json!("hi"));
305 assert_eq!(params["cvm"]["frameType"], json!("chunk"));
306 assert_eq!(params["cvm"]["chunkIndex"], json!(2));
307 assert_eq!(params["cvm"]["data"], json!("frag"));
308 assert_eq!(params["cvm"]["type"], json!("open-stream"));
309 }
310
311 #[test]
312 fn into_progress_notification_omits_absent_message() {
313 let notification = OpenStreamFrame::Accept
314 .into_progress_notification("tok-1", 9, None)
315 .unwrap();
316 let params = notification.params.as_ref().unwrap();
317 assert!(!params.as_object().unwrap().contains_key("message"));
318 }
319
320 #[test]
321 fn open_stream_frame_from_notification_extracts_typed_frame() {
322 let notification = OpenStreamFrame::Ping {
323 nonce: "n".to_string(),
324 }
325 .into_progress_notification("tok", 1, None)
326 .unwrap();
327 assert_eq!(
328 open_stream_frame_from_notification(¬ification),
329 Some(OpenStreamFrame::Ping {
330 nonce: "n".to_string()
331 })
332 );
333
334 let plain = JsonRpcNotification {
336 jsonrpc: "2.0".to_string(),
337 method: "notifications/progress".to_string(),
338 params: Some(json!({ "progressToken": "t", "progress": 3 })),
339 };
340 assert_eq!(open_stream_frame_from_notification(&plain), None);
341 }
342}