Skip to main content

dig_rpc_types/
envelope.rs

1//! JSON-RPC 2.0 envelope types.
2//!
3//! # Background
4//!
5//! The DIG RPC wire protocol is strict JSON-RPC 2.0 per
6//! [`jsonrpc.org/specification`](https://www.jsonrpc.org/specification).
7//! Every request carries:
8//!
9//! - `jsonrpc: "2.0"` (we enforce this via the [`Version`] type);
10//! - `id` — a correlation handle;
11//! - `method` — snake_case method name;
12//! - optional `params` — method-specific payload.
13//!
14//! Every response carries `jsonrpc`, `id`, and exactly one of `result` or
15//! `error` (never both, never neither). The `serde(untagged)` attribute on
16//! [`JsonRpcResponseBody`] encodes that either/or.
17//!
18//! # Why a custom `Version` type
19//!
20//! Without the `Version` tag, clients could send any string and servers
21//! could accept it. Having a zero-sized struct whose `serde` impl hard-codes
22//! `"2.0"` makes the check structural: a response with `jsonrpc: "1.0"`
23//! fails to deserialize into `JsonRpcResponse` without any hand-coded check.
24
25use serde::{Deserialize, Serialize};
26
27use crate::errors::ErrorCode;
28
29/// A JSON-RPC 2.0 request envelope.
30///
31/// `P` is the method-specific parameter type. For most callers, that's a
32/// concrete `{Method}Request` struct from [`crate::fullnode`] or
33/// [`crate::validator`]. Generic callers (proxies, middleware) can use
34/// `serde_json::Value`.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct JsonRpcRequest<P = serde_json::Value> {
37    /// Protocol version; must serialize to the literal `"2.0"`.
38    pub jsonrpc: Version,
39    /// Correlation id. Returned unchanged in the response.
40    pub id: RequestId,
41    /// Snake_case method name (e.g., `"get_blockchain_state"`).
42    pub method: String,
43    /// Method-specific parameters. Absent for methods that take none.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub params: Option<P>,
46}
47
48/// A JSON-RPC 2.0 response envelope.
49///
50/// Exactly one of `result` or `error` is present — enforced by the
51/// untagged [`JsonRpcResponseBody`] enum.
52///
53/// `R` is the method-specific result type. Generic callers use
54/// `serde_json::Value`.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct JsonRpcResponse<R = serde_json::Value> {
57    /// Protocol version; must serialize to the literal `"2.0"`.
58    pub jsonrpc: Version,
59    /// Correlation id copied from the matching request.
60    pub id: RequestId,
61    /// Either `result` or `error`, never both, never neither.
62    #[serde(flatten)]
63    pub body: JsonRpcResponseBody<R>,
64}
65
66/// The body of a response: either a successful result or an error.
67///
68/// Uses `serde(untagged)` so the JSON output is flat — `{"result": ...}`
69/// or `{"error": ...}` — matching the JSON-RPC 2.0 spec.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(untagged)]
72pub enum JsonRpcResponseBody<R> {
73    /// The method succeeded; `result` carries the response.
74    Success {
75        /// The method-specific result payload.
76        result: R,
77    },
78    /// The method failed; `error` carries the failure details.
79    Error {
80        /// The error envelope.
81        error: JsonRpcError,
82    },
83}
84
85/// Per-request correlation id.
86///
87/// JSON-RPC 2.0 permits numeric, string, or null ids. Servers echo the id
88/// unchanged in the matching response. Null is reserved for notifications
89/// (fire-and-forget) — DIG RPC does not currently use notifications but we
90/// accept the variant for spec compliance.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
92#[serde(untagged)]
93pub enum RequestId {
94    /// Numeric id (the typical choice).
95    Num(u64),
96    /// String id (useful for UUID correlation).
97    Str(String),
98    /// Null id (spec-allowed for notifications).
99    Null,
100}
101
102/// The JSON-RPC protocol version marker.
103///
104/// Zero-sized; `serde` encodes/decodes it as the literal string `"2.0"`.
105/// Any other value fails deserialization with no manual validation
106/// required.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct Version;
109
110impl Serialize for Version {
111    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
112        s.serialize_str("2.0")
113    }
114}
115
116impl<'de> Deserialize<'de> for Version {
117    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
118        let s = String::deserialize(d)?;
119        if s == "2.0" {
120            Ok(Version)
121        } else {
122            Err(serde::de::Error::custom(format!(
123                "expected jsonrpc version \"2.0\", got {s:?}"
124            )))
125        }
126    }
127}
128
129impl Default for Version {
130    fn default() -> Self {
131        Self
132    }
133}
134
135/// The error body of a JSON-RPC 2.0 failure response.
136///
137/// Carries the numeric [`ErrorCode`], a human message, and optional
138/// `data` for method-specific error context (stack trace, invalid-field
139/// names, etc.).
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct JsonRpcError {
142    /// The stable numeric error code.
143    pub code: ErrorCode,
144    /// Human-readable short description.
145    pub message: String,
146    /// Optional structured data for richer client handling.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub data: Option<serde_json::Value>,
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    /// **Proves:** serializing a request with `jsonrpc: Version` produces
156    /// the literal string `"2.0"` on the wire.
157    ///
158    /// **Why it matters:** Any strict JSON-RPC 2.0 server will reject a
159    /// request whose `jsonrpc` field is not exactly `"2.0"`. If `Version`
160    /// ever serialised as something else (unit struct `{}`, or `"2"`), all
161    /// DIG clients would become uninterop-able.
162    ///
163    /// **Catches:** a `#[derive(Serialize)]` regression on `Version` (which
164    /// would serialize as `null` for a unit struct), or an accidental swap
165    /// to a typed version wrapper.
166    #[test]
167    fn version_serialises_as_two_point_zero() {
168        let s = serde_json::to_string(&Version).unwrap();
169        assert_eq!(s, "\"2.0\"");
170    }
171
172    /// **Proves:** attempting to deserialize a non-`"2.0"` jsonrpc field
173    /// returns an error rather than silently accepting it.
174    ///
175    /// **Why it matters:** JSON-RPC 1.0 responses have a very different
176    /// shape (positional params, no `code`/`message` split in errors). If
177    /// we accepted `jsonrpc: "1.0"`, downstream deserialization into the
178    /// 2.0 response body would produce garbage.
179    ///
180    /// **Catches:** a regression where `Version::deserialize` accepts any
181    /// string, or where the struct field is marked `#[serde(default)]`.
182    #[test]
183    fn version_rejects_non_2_0() {
184        let r: Result<Version, _> = serde_json::from_str(r#""1.0""#);
185        assert!(r.is_err());
186        let r: Result<Version, _> = serde_json::from_str(r#""3"#);
187        assert!(r.is_err());
188    }
189
190    /// **Proves:** `RequestId` round-trips through JSON for all three
191    /// variants (Num, Str, Null).
192    ///
193    /// **Why it matters:** Some clients (browser `fetch`, Go `encoding/json`)
194    /// default to string ids; others use integers. The server must accept
195    /// both and echo them unchanged.
196    ///
197    /// **Catches:** a regression where `RequestId` loses the untagged serde
198    /// attribute and becomes tagged (which would force `{"Num": 42}` JSON).
199    #[test]
200    fn request_id_roundtrip() {
201        for (rid, expected) in [
202            (RequestId::Num(42), "42"),
203            (RequestId::Str("abc".into()), "\"abc\""),
204            (RequestId::Null, "null"),
205        ] {
206            let s = serde_json::to_string(&rid).unwrap();
207            assert_eq!(s, expected);
208            let back: RequestId = serde_json::from_str(&s).unwrap();
209            assert_eq!(back, rid);
210        }
211    }
212
213    /// **Proves:** a success response serializes as `{"result": ...}` and
214    /// decodes back to the `Success` variant; the same for error.
215    ///
216    /// **Why it matters:** The untagged enum attribute is load-bearing —
217    /// without it, responses would serialize as `{"Success": {"result": ...}}`
218    /// which no JSON-RPC client understands. This test pins the wire shape.
219    ///
220    /// **Catches:** dropping `#[serde(untagged)]` from
221    /// [`JsonRpcResponseBody`]; accidentally reordering variants (serde tries
222    /// them in declaration order — reorder would change which variant wins
223    /// for ambiguous inputs).
224    #[test]
225    fn response_body_success_and_error_round_trip() {
226        let ok: JsonRpcResponse<u32> = JsonRpcResponse {
227            jsonrpc: Version,
228            id: RequestId::Num(1),
229            body: JsonRpcResponseBody::Success { result: 7 },
230        };
231        let s = serde_json::to_string(&ok).unwrap();
232        assert!(s.contains("\"result\":7"), "actual: {s}");
233        assert!(!s.contains("\"error\""), "must not contain error: {s}");
234
235        let err: JsonRpcResponse<u32> = JsonRpcResponse {
236            jsonrpc: Version,
237            id: RequestId::Num(2),
238            body: JsonRpcResponseBody::Error {
239                error: JsonRpcError {
240                    code: ErrorCode::MethodNotFound,
241                    message: "no such method".into(),
242                    data: None,
243                },
244            },
245        };
246        let s = serde_json::to_string(&err).unwrap();
247        assert!(s.contains("\"error\""), "actual: {s}");
248        assert!(!s.contains("\"result\""), "must not contain result: {s}");
249    }
250
251    /// **Proves:** a parsed request without `params` deserializes cleanly —
252    /// the field is optional.
253    ///
254    /// **Why it matters:** Methods like `get_blockchain_state` take no
255    /// params. JSON-RPC clients often omit the field entirely (`{"jsonrpc":
256    /// "2.0", "id": 1, "method": "get_blockchain_state"}`). If `params`
257    /// were a required field, these requests would fail deserialization.
258    ///
259    /// **Catches:** dropping the `Option<P>` wrap on `params`, or removing
260    /// the `skip_serializing_if` that keeps the field out of the wire when
261    /// `None`.
262    #[test]
263    fn request_without_params_deserialises() {
264        let raw = r#"{"jsonrpc":"2.0","id":1,"method":"get_blockchain_state"}"#;
265        let req: JsonRpcRequest<serde_json::Value> = serde_json::from_str(raw).unwrap();
266        assert_eq!(req.method, "get_blockchain_state");
267        assert!(req.params.is_none());
268    }
269}