diff --git a/CHANGELOG.md b/CHANGELOG.md index e03b06d2..f175c9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/bsk-protocol/src/frame.rs b/crates/bsk-protocol/src/frame.rs index b80eec9b..9af29730 100644 --- a/crates/bsk-protocol/src/frame.rs +++ b/crates/bsk-protocol/src/frame.rs @@ -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(default, deserialize_with = "de_present_field")] error: Option, } @@ -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, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + impl Serialize for Frame { fn serialize(&self, serializer: S) -> Result where @@ -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::(wire.clone()).is_err()); + assert!(serde_json::from_value::(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::(wire.clone()).is_err()); + assert!(serde_json::from_value::(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::(wire).is_err()); + assert!(serde_json::from_str::(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::(wire).is_err()); + assert!(serde_json::from_str::(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); + } + } }