use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::core::types::JsonRpcNotification;
use crate::transport::oversized_transfer::NOTIFICATIONS_PROGRESS_METHOD;
use super::constants::OPEN_STREAM_TYPE;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "frameType", rename_all = "camelCase")]
pub enum OpenStreamFrame {
#[serde(rename_all = "camelCase")]
Start {
#[serde(default, skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
},
Accept,
#[serde(rename_all = "camelCase")]
Chunk {
chunk_index: u64,
data: String,
},
Ping {
nonce: String,
},
Pong {
nonce: String,
},
#[serde(rename_all = "camelCase")]
Close {
#[serde(default, skip_serializing_if = "Option::is_none")]
last_chunk_index: Option<u64>,
},
Abort {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
}
impl OpenStreamFrame {
pub fn frame_type(&self) -> &'static str {
match self {
Self::Start { .. } => "start",
Self::Accept => "accept",
Self::Chunk { .. } => "chunk",
Self::Ping { .. } => "ping",
Self::Pong { .. } => "pong",
Self::Close { .. } => "close",
Self::Abort { .. } => "abort",
}
}
pub fn to_cvm_value(&self) -> Result<Value, serde_json::Error> {
let mut value = serde_json::to_value(self)?;
if let Value::Object(map) = &mut value {
map.insert(
"type".to_string(),
Value::String(OPEN_STREAM_TYPE.to_string()),
);
}
Ok(value)
}
pub fn from_cvm_value(value: &Value) -> Option<Self> {
if value.get("type").and_then(Value::as_str) != Some(OPEN_STREAM_TYPE) {
return None;
}
serde_json::from_value(value.clone()).ok()
}
pub fn is_frame_value(value: &Value) -> bool {
value.get("type").and_then(Value::as_str) == Some(OPEN_STREAM_TYPE)
&& value.get("frameType").and_then(Value::as_str).is_some()
}
pub fn into_progress_notification(
&self,
progress_token: &str,
progress: u64,
message: Option<&str>,
) -> Result<JsonRpcNotification, serde_json::Error> {
let mut params = Map::new();
params.insert(
"progressToken".to_string(),
Value::String(progress_token.to_string()),
);
params.insert("progress".to_string(), Value::Number(progress.into()));
if let Some(message) = message {
params.insert("message".to_string(), Value::String(message.to_string()));
}
params.insert("cvm".to_string(), self.to_cvm_value()?);
Ok(JsonRpcNotification {
jsonrpc: "2.0".to_string(),
method: NOTIFICATIONS_PROGRESS_METHOD.to_string(),
params: Some(Value::Object(params)),
})
}
}
pub fn open_stream_frame_from_notification(
notification: &JsonRpcNotification,
) -> Option<OpenStreamFrame> {
notification
.params
.as_ref()
.and_then(|params| params.get("cvm"))
.and_then(OpenStreamFrame::from_cvm_value)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn assert_cvm_roundtrip(frame: OpenStreamFrame) {
let value = frame.to_cvm_value().unwrap();
assert_eq!(value["type"], json!("open-stream"));
assert_eq!(value["frameType"], json!(frame.frame_type()));
assert_eq!(OpenStreamFrame::from_cvm_value(&value), Some(frame));
}
#[test]
fn all_frame_variants_roundtrip() {
assert_cvm_roundtrip(OpenStreamFrame::Start {
content_type: Some("text/plain".to_string()),
});
assert_cvm_roundtrip(OpenStreamFrame::Start { content_type: None });
assert_cvm_roundtrip(OpenStreamFrame::Accept);
assert_cvm_roundtrip(OpenStreamFrame::Chunk {
chunk_index: 7,
data: "payload".to_string(),
});
assert_cvm_roundtrip(OpenStreamFrame::Ping {
nonce: "tok:1".to_string(),
});
assert_cvm_roundtrip(OpenStreamFrame::Pong {
nonce: "tok:1".to_string(),
});
assert_cvm_roundtrip(OpenStreamFrame::Close {
last_chunk_index: Some(4),
});
assert_cvm_roundtrip(OpenStreamFrame::Close {
last_chunk_index: None,
});
assert_cvm_roundtrip(OpenStreamFrame::Abort {
reason: Some("boom".to_string()),
});
assert_cvm_roundtrip(OpenStreamFrame::Abort { reason: None });
}
#[test]
fn chunk_frame_serializes_with_camel_case_fields() {
let value = OpenStreamFrame::Chunk {
chunk_index: 3,
data: "abc".to_string(),
}
.to_cvm_value()
.unwrap();
assert_eq!(value["type"], json!("open-stream"));
assert_eq!(value["frameType"], json!("chunk"));
assert_eq!(value["chunkIndex"], json!(3));
assert_eq!(value["data"], json!("abc"));
assert!(!value.as_object().unwrap().contains_key("chunk_index"));
}
#[test]
fn start_content_type_serializes_camel_case_and_omits_when_none() {
let with = OpenStreamFrame::Start {
content_type: Some("application/json".to_string()),
}
.to_cvm_value()
.unwrap();
assert_eq!(with["contentType"], json!("application/json"));
let without = OpenStreamFrame::Start { content_type: None }
.to_cvm_value()
.unwrap();
assert!(!without.as_object().unwrap().contains_key("contentType"));
}
#[test]
fn close_last_chunk_index_serializes_camel_case_and_omits_when_none() {
let with = OpenStreamFrame::Close {
last_chunk_index: Some(9),
}
.to_cvm_value()
.unwrap();
assert_eq!(with["lastChunkIndex"], json!(9));
let without = OpenStreamFrame::Close {
last_chunk_index: None,
}
.to_cvm_value()
.unwrap();
assert!(!without.as_object().unwrap().contains_key("lastChunkIndex"));
}
#[test]
fn abort_reason_omitted_when_none() {
let value = OpenStreamFrame::Abort { reason: None }
.to_cvm_value()
.unwrap();
assert!(!value.as_object().unwrap().contains_key("reason"));
}
#[test]
fn from_cvm_value_rejects_wrong_type() {
let value = json!({ "type": "oversized-transfer", "frameType": "start" });
assert_eq!(OpenStreamFrame::from_cvm_value(&value), None);
assert!(!OpenStreamFrame::is_frame_value(&value));
}
#[test]
fn from_cvm_value_rejects_unknown_frame_type() {
let value = json!({ "type": "open-stream", "frameType": "bogus" });
assert_eq!(OpenStreamFrame::from_cvm_value(&value), None);
}
#[test]
fn is_frame_value_requires_type_and_frame_type() {
assert!(OpenStreamFrame::is_frame_value(
&json!({ "type": "open-stream", "frameType": "chunk", "chunkIndex": 0, "data": "x" })
));
assert!(!OpenStreamFrame::is_frame_value(
&json!({ "type": "open-stream" })
));
assert!(!OpenStreamFrame::is_frame_value(
&json!({ "frameType": "chunk" })
));
assert!(!OpenStreamFrame::is_frame_value(&json!("not an object")));
}
#[test]
fn into_progress_notification_builds_progress_envelope() {
let notification = OpenStreamFrame::Chunk {
chunk_index: 2,
data: "frag".to_string(),
}
.into_progress_notification("tok-1", 7, Some("hi"))
.unwrap();
assert_eq!(notification.method, "notifications/progress");
let params = notification.params.as_ref().unwrap();
assert_eq!(params["progressToken"], json!("tok-1"));
assert_eq!(params["progress"], json!(7));
assert_eq!(params["message"], json!("hi"));
assert_eq!(params["cvm"]["frameType"], json!("chunk"));
assert_eq!(params["cvm"]["chunkIndex"], json!(2));
assert_eq!(params["cvm"]["data"], json!("frag"));
assert_eq!(params["cvm"]["type"], json!("open-stream"));
}
#[test]
fn into_progress_notification_omits_absent_message() {
let notification = OpenStreamFrame::Accept
.into_progress_notification("tok-1", 9, None)
.unwrap();
let params = notification.params.as_ref().unwrap();
assert!(!params.as_object().unwrap().contains_key("message"));
}
#[test]
fn open_stream_frame_from_notification_extracts_typed_frame() {
let notification = OpenStreamFrame::Ping {
nonce: "n".to_string(),
}
.into_progress_notification("tok", 1, None)
.unwrap();
assert_eq!(
open_stream_frame_from_notification(¬ification),
Some(OpenStreamFrame::Ping {
nonce: "n".to_string()
})
);
let plain = JsonRpcNotification {
jsonrpc: "2.0".to_string(),
method: "notifications/progress".to_string(),
params: Some(json!({ "progressToken": "t", "progress": 3 })),
};
assert_eq!(open_stream_frame_from_notification(&plain), None);
}
}