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}