Skip to main content

dig_node_control_interface/
envelope.rs

1//! The minimal JSON-RPC 2.0 envelope the control catalog rides in.
2//!
3//! Transport-agnostic: a [`JsonRpcRequest`] carries a control method + its typed params, and a
4//! [`JsonRpcResponse`] carries either a `result` or an `error` ([`ControlError`]). The catalog does
5//! not own the transport (dig-ipc session, loopback-mTLS HTTP/WebSocket) — only these payload
6//! shapes that travel over it. The shapes are byte-identical to what dig-node emits so both sides
7//! serialize the same bytes.
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::error::ControlError;
13
14/// A JSON-RPC 2.0 request id: a number, a string, or null. dig-node accepts all three and echoes
15/// the id verbatim on the response.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(untagged)]
18pub enum RequestId {
19    /// A numeric id (the common case).
20    Number(i64),
21    /// A string id.
22    String(String),
23    /// A null id.
24    Null,
25}
26
27impl From<i64> for RequestId {
28    fn from(n: i64) -> Self {
29        RequestId::Number(n)
30    }
31}
32
33impl From<&str> for RequestId {
34    fn from(s: &str) -> Self {
35        RequestId::String(s.to_string())
36    }
37}
38
39/// A JSON-RPC 2.0 request: `{jsonrpc:"2.0", id, method, params}`.
40///
41/// `params` is a raw [`Value`] so the envelope stays method-agnostic; the typed params structs in
42/// [`crate::params`] serialize into it via [`crate::traits::build_request`].
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct JsonRpcRequest {
45    /// Always `"2.0"`.
46    pub jsonrpc: String,
47    /// The request id, echoed on the response.
48    pub id: RequestId,
49    /// The control method's wire name (see [`crate::ControlMethod::name`]).
50    pub method: String,
51    /// The method params object.
52    pub params: Value,
53}
54
55impl JsonRpcRequest {
56    /// Build a request for `method` with `params`, stamping `jsonrpc:"2.0"`.
57    pub fn new(id: RequestId, method: impl Into<String>, params: Value) -> JsonRpcRequest {
58        JsonRpcRequest {
59            jsonrpc: "2.0".to_string(),
60            id,
61            method: method.into(),
62            params,
63        }
64    }
65}
66
67/// A JSON-RPC 2.0 response: exactly one of `result` or `error` is present.
68///
69/// Modelled as two `Option` fields (matching the wire) rather than an enum, because dig-node emits
70/// the flat `{jsonrpc, id, result}` / `{jsonrpc, id, error}` shapes and clients read them directly.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct JsonRpcResponse {
73    /// Always `"2.0"`.
74    pub jsonrpc: String,
75    /// The id echoed from the request.
76    pub id: RequestId,
77    /// The success result (present iff `error` is absent).
78    #[serde(skip_serializing_if = "Option::is_none", default)]
79    pub result: Option<Value>,
80    /// The error (present iff `result` is absent).
81    #[serde(skip_serializing_if = "Option::is_none", default)]
82    pub error: Option<ControlError>,
83}
84
85impl JsonRpcResponse {
86    /// Build a success response carrying `result`.
87    pub fn success(id: RequestId, result: Value) -> JsonRpcResponse {
88        JsonRpcResponse {
89            jsonrpc: "2.0".to_string(),
90            id,
91            result: Some(result),
92            error: None,
93        }
94    }
95
96    /// Build an error response carrying `error`.
97    pub fn error(id: RequestId, error: ControlError) -> JsonRpcResponse {
98        JsonRpcResponse {
99            jsonrpc: "2.0".to_string(),
100            id,
101            result: None,
102            error: Some(error),
103        }
104    }
105
106    /// The result value if this is a success, else the [`ControlError`].
107    pub fn into_result(self) -> Result<Value, ControlError> {
108        match (self.result, self.error) {
109            (Some(v), _) => Ok(v),
110            (None, Some(e)) => Err(e),
111            (None, None) => Err(ControlError::of(
112                crate::error::ControlErrorCode::ControlError,
113                "malformed JSON-RPC response: neither result nor error present",
114            )),
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::error::ControlErrorCode;
123    use serde_json::json;
124
125    #[test]
126    fn request_serializes_to_the_canonical_shape() {
127        let req = JsonRpcRequest::new(1.into(), "control.status", json!({}));
128        let v = serde_json::to_value(&req).unwrap();
129        assert_eq!(
130            v,
131            json!({"jsonrpc":"2.0","id":1,"method":"control.status","params":{}})
132        );
133    }
134
135    #[test]
136    fn request_id_supports_number_string_and_null() {
137        assert_eq!(serde_json::to_value(RequestId::from(7)).unwrap(), json!(7));
138        assert_eq!(
139            serde_json::to_value(RequestId::from("abc")).unwrap(),
140            json!("abc")
141        );
142        assert_eq!(serde_json::to_value(RequestId::Null).unwrap(), json!(null));
143        assert_eq!(
144            serde_json::from_value::<RequestId>(json!("z")).unwrap(),
145            RequestId::String("z".into())
146        );
147    }
148
149    #[test]
150    fn success_response_omits_the_error_field() {
151        let resp = JsonRpcResponse::success(1.into(), json!({"ok":true}));
152        let v = serde_json::to_value(&resp).unwrap();
153        assert_eq!(v, json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}}));
154        assert_eq!(resp.into_result().unwrap(), json!({"ok":true}));
155    }
156
157    #[test]
158    fn error_response_omits_the_result_field_and_unwraps_to_err() {
159        let resp = JsonRpcResponse::error(
160            1.into(),
161            ControlError::of(ControlErrorCode::Unauthorized, "no token"),
162        );
163        let v = serde_json::to_value(&resp).unwrap();
164        assert_eq!(v["error"]["data"]["code"], "UNAUTHORIZED");
165        assert!(v.get("result").is_none());
166        assert!(resp.into_result().is_err());
167    }
168
169    #[test]
170    fn a_response_with_neither_field_is_a_control_error() {
171        let resp = JsonRpcResponse {
172            jsonrpc: "2.0".into(),
173            id: RequestId::Number(1),
174            result: None,
175            error: None,
176        };
177        let err = resp.into_result().unwrap_err();
178        assert_eq!(err.code_enum(), Some(ControlErrorCode::ControlError));
179    }
180}