Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.

## [Unreleased]

### Fixed

- Protocol: preserve explicit `null` results when deserializing `ResponseFrame`,
restoring round-trip consistency while rejecting responses with both a result and an error.

## [0.3.1] - 2026-09-23

### Added
Expand Down
110 changes: 110 additions & 0 deletions crates/bsk-protocol/src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ impl<'de> Deserialize<'de> for ResponseFrame {
#[derive(Deserialize)]
struct Flat {
id: RpcId,
#[serde(default, deserialize_with = "de_present_field")]
result: Option<serde_json::Value>,
#[serde(default, deserialize_with = "de_present_field")]
error: Option<RpcError>,
}

Expand All @@ -114,6 +116,16 @@ impl<'de> Deserialize<'de> for ResponseFrame {
}
}

// Deserialize present fields as their actual type: Value accepts null, but
// RpcError requires an error object. Missing fields use #[serde(default)].
fn de_present_field<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
T::deserialize(deserializer).map(Some)
}

impl Serialize for Frame {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
Expand Down Expand Up @@ -280,4 +292,102 @@ mod tests {
Some("sess-1"),
);
}

#[test]
fn explicit_null_result_decodes_as_ok_null() {
let wire = serde_json::json!({ "id": "rpc-1", "result": null });
let frame: Frame = serde_json::from_value(wire).unwrap();
assert_eq!(
frame,
Frame::Response(ResponseFrame {
id: "rpc-1".into(),
body: ResponseBody::Ok(serde_json::Value::Null),
})
);
}

#[test]
fn explicit_null_result_response_frame_decodes_as_ok_null() {
let wire = r#"{"id":"rpc-2","result":null}"#;
let resp: ResponseFrame = serde_json::from_str(wire).unwrap();
assert_eq!(resp.id, "rpc-2");
assert_eq!(resp.body, ResponseBody::Ok(serde_json::Value::Null));
}

#[test]
fn null_result_round_trips_through_serialise() {
let response = ResponseFrame {
id: "rpc-3".into(),
body: ResponseBody::Ok(serde_json::Value::Null),
};
let wire = serde_json::to_value(&response).unwrap();
assert_eq!(wire, serde_json::json!({ "id": "rpc-3", "result": null }));
let back: ResponseFrame = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(back, response);
let frame: Frame = serde_json::from_value(wire).unwrap();
assert_eq!(frame, Frame::Response(response));
}

#[test]
fn missing_result_and_error_still_rejected() {
let wire = serde_json::json!({ "id": "rpc-4" });
assert!(serde_json::from_value::<Frame>(wire.clone()).is_err());
assert!(serde_json::from_value::<ResponseFrame>(wire).is_err());
}

#[test]
fn result_and_error_together_rejected() {
for result in [serde_json::Value::Null, serde_json::json!({ "pong": true })] {
let wire = serde_json::json!({
"id": "rpc-5",
"result": result,
"error": { "code": "protocol_error", "message": "test error" },
});
assert!(serde_json::from_value::<Frame>(wire.clone()).is_err());
assert!(serde_json::from_value::<ResponseFrame>(wire).is_err());
}
}

#[test]
fn duplicate_result_including_null_rejected() {
for wire in [
r#"{"id":"rpc-6","result":null,"result":1}"#,
r#"{"id":"rpc-6","result":1,"result":null}"#,
] {
assert!(serde_json::from_str::<Frame>(wire).is_err());
assert!(serde_json::from_str::<ResponseFrame>(wire).is_err());
}
}

#[test]
fn null_error_is_rejected() {
for wire in [
r#"{"id":"rpc-null-error","error":null}"#,
r#"{"id":"rpc-null-error","result":null,"error":null}"#,
r#"{"id":"rpc-null-error","result":true,"error":null}"#,
] {
assert!(serde_json::from_str::<Frame>(wire).is_err());
assert!(serde_json::from_str::<ResponseFrame>(wire).is_err());
}
}

#[test]
fn non_null_results_and_errors_decode_consistently() {
for wire in [
serde_json::json!({ "id": "rpc-7", "result": false }),
serde_json::json!({ "id": "rpc-7", "result": 0 }),
serde_json::json!({ "id": "rpc-7", "result": "" }),
serde_json::json!({ "id": "rpc-7", "result": [] }),
serde_json::json!({ "id": "rpc-7", "result": {} }),
serde_json::json!({
"id": "rpc-7",
"error": { "code": "protocol_error", "message": "test error" },
}),
] {
let response: ResponseFrame = serde_json::from_value(wire.clone()).unwrap();
let frame: Frame = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(frame, Frame::Response(response.clone()));
assert_eq!(serde_json::to_value(response).unwrap(), wire);
}
}
}
Loading