dig-rpc-types 0.1.0

JSON-RPC wire types shared by the DIG Network fullnode + validator RPC servers and their clients. Pure types — no I/O, no async, no logic.
Documentation
//! JSON-RPC 2.0 envelope types.
//!
//! # Background
//!
//! The DIG RPC wire protocol is strict JSON-RPC 2.0 per
//! [`jsonrpc.org/specification`](https://www.jsonrpc.org/specification).
//! Every request carries:
//!
//! - `jsonrpc: "2.0"` (we enforce this via the [`Version`] type);
//! - `id` — a correlation handle;
//! - `method` — snake_case method name;
//! - optional `params` — method-specific payload.
//!
//! Every response carries `jsonrpc`, `id`, and exactly one of `result` or
//! `error` (never both, never neither). The `serde(untagged)` attribute on
//! [`JsonRpcResponseBody`] encodes that either/or.
//!
//! # Why a custom `Version` type
//!
//! Without the `Version` tag, clients could send any string and servers
//! could accept it. Having a zero-sized struct whose `serde` impl hard-codes
//! `"2.0"` makes the check structural: a response with `jsonrpc: "1.0"`
//! fails to deserialize into `JsonRpcResponse` without any hand-coded check.

use serde::{Deserialize, Serialize};

use crate::errors::ErrorCode;

/// A JSON-RPC 2.0 request envelope.
///
/// `P` is the method-specific parameter type. For most callers, that's a
/// concrete `{Method}Request` struct from [`crate::fullnode`] or
/// [`crate::validator`]. Generic callers (proxies, middleware) can use
/// `serde_json::Value`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest<P = serde_json::Value> {
    /// Protocol version; must serialize to the literal `"2.0"`.
    pub jsonrpc: Version,
    /// Correlation id. Returned unchanged in the response.
    pub id: RequestId,
    /// Snake_case method name (e.g., `"get_blockchain_state"`).
    pub method: String,
    /// Method-specific parameters. Absent for methods that take none.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub params: Option<P>,
}

/// A JSON-RPC 2.0 response envelope.
///
/// Exactly one of `result` or `error` is present — enforced by the
/// untagged [`JsonRpcResponseBody`] enum.
///
/// `R` is the method-specific result type. Generic callers use
/// `serde_json::Value`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse<R = serde_json::Value> {
    /// Protocol version; must serialize to the literal `"2.0"`.
    pub jsonrpc: Version,
    /// Correlation id copied from the matching request.
    pub id: RequestId,
    /// Either `result` or `error`, never both, never neither.
    #[serde(flatten)]
    pub body: JsonRpcResponseBody<R>,
}

/// The body of a response: either a successful result or an error.
///
/// Uses `serde(untagged)` so the JSON output is flat — `{"result": ...}`
/// or `{"error": ...}` — matching the JSON-RPC 2.0 spec.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum JsonRpcResponseBody<R> {
    /// The method succeeded; `result` carries the response.
    Success {
        /// The method-specific result payload.
        result: R,
    },
    /// The method failed; `error` carries the failure details.
    Error {
        /// The error envelope.
        error: JsonRpcError,
    },
}

/// Per-request correlation id.
///
/// JSON-RPC 2.0 permits numeric, string, or null ids. Servers echo the id
/// unchanged in the matching response. Null is reserved for notifications
/// (fire-and-forget) — DIG RPC does not currently use notifications but we
/// accept the variant for spec compliance.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(untagged)]
pub enum RequestId {
    /// Numeric id (the typical choice).
    Num(u64),
    /// String id (useful for UUID correlation).
    Str(String),
    /// Null id (spec-allowed for notifications).
    Null,
}

/// The JSON-RPC protocol version marker.
///
/// Zero-sized; `serde` encodes/decodes it as the literal string `"2.0"`.
/// Any other value fails deserialization with no manual validation
/// required.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Version;

impl Serialize for Version {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str("2.0")
    }
}

impl<'de> Deserialize<'de> for Version {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        if s == "2.0" {
            Ok(Version)
        } else {
            Err(serde::de::Error::custom(format!(
                "expected jsonrpc version \"2.0\", got {s:?}"
            )))
        }
    }
}

impl Default for Version {
    fn default() -> Self {
        Self
    }
}

/// The error body of a JSON-RPC 2.0 failure response.
///
/// Carries the numeric [`ErrorCode`], a human message, and optional
/// `data` for method-specific error context (stack trace, invalid-field
/// names, etc.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
    /// The stable numeric error code.
    pub code: ErrorCode,
    /// Human-readable short description.
    pub message: String,
    /// Optional structured data for richer client handling.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<serde_json::Value>,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **Proves:** serializing a request with `jsonrpc: Version` produces
    /// the literal string `"2.0"` on the wire.
    ///
    /// **Why it matters:** Any strict JSON-RPC 2.0 server will reject a
    /// request whose `jsonrpc` field is not exactly `"2.0"`. If `Version`
    /// ever serialised as something else (unit struct `{}`, or `"2"`), all
    /// DIG clients would become uninterop-able.
    ///
    /// **Catches:** a `#[derive(Serialize)]` regression on `Version` (which
    /// would serialize as `null` for a unit struct), or an accidental swap
    /// to a typed version wrapper.
    #[test]
    fn version_serialises_as_two_point_zero() {
        let s = serde_json::to_string(&Version).unwrap();
        assert_eq!(s, "\"2.0\"");
    }

    /// **Proves:** attempting to deserialize a non-`"2.0"` jsonrpc field
    /// returns an error rather than silently accepting it.
    ///
    /// **Why it matters:** JSON-RPC 1.0 responses have a very different
    /// shape (positional params, no `code`/`message` split in errors). If
    /// we accepted `jsonrpc: "1.0"`, downstream deserialization into the
    /// 2.0 response body would produce garbage.
    ///
    /// **Catches:** a regression where `Version::deserialize` accepts any
    /// string, or where the struct field is marked `#[serde(default)]`.
    #[test]
    fn version_rejects_non_2_0() {
        let r: Result<Version, _> = serde_json::from_str(r#""1.0""#);
        assert!(r.is_err());
        let r: Result<Version, _> = serde_json::from_str(r#""3"#);
        assert!(r.is_err());
    }

    /// **Proves:** `RequestId` round-trips through JSON for all three
    /// variants (Num, Str, Null).
    ///
    /// **Why it matters:** Some clients (browser `fetch`, Go `encoding/json`)
    /// default to string ids; others use integers. The server must accept
    /// both and echo them unchanged.
    ///
    /// **Catches:** a regression where `RequestId` loses the untagged serde
    /// attribute and becomes tagged (which would force `{"Num": 42}` JSON).
    #[test]
    fn request_id_roundtrip() {
        for (rid, expected) in [
            (RequestId::Num(42), "42"),
            (RequestId::Str("abc".into()), "\"abc\""),
            (RequestId::Null, "null"),
        ] {
            let s = serde_json::to_string(&rid).unwrap();
            assert_eq!(s, expected);
            let back: RequestId = serde_json::from_str(&s).unwrap();
            assert_eq!(back, rid);
        }
    }

    /// **Proves:** a success response serializes as `{"result": ...}` and
    /// decodes back to the `Success` variant; the same for error.
    ///
    /// **Why it matters:** The untagged enum attribute is load-bearing —
    /// without it, responses would serialize as `{"Success": {"result": ...}}`
    /// which no JSON-RPC client understands. This test pins the wire shape.
    ///
    /// **Catches:** dropping `#[serde(untagged)]` from
    /// [`JsonRpcResponseBody`]; accidentally reordering variants (serde tries
    /// them in declaration order — reorder would change which variant wins
    /// for ambiguous inputs).
    #[test]
    fn response_body_success_and_error_round_trip() {
        let ok: JsonRpcResponse<u32> = JsonRpcResponse {
            jsonrpc: Version,
            id: RequestId::Num(1),
            body: JsonRpcResponseBody::Success { result: 7 },
        };
        let s = serde_json::to_string(&ok).unwrap();
        assert!(s.contains("\"result\":7"), "actual: {s}");
        assert!(!s.contains("\"error\""), "must not contain error: {s}");

        let err: JsonRpcResponse<u32> = JsonRpcResponse {
            jsonrpc: Version,
            id: RequestId::Num(2),
            body: JsonRpcResponseBody::Error {
                error: JsonRpcError {
                    code: ErrorCode::MethodNotFound,
                    message: "no such method".into(),
                    data: None,
                },
            },
        };
        let s = serde_json::to_string(&err).unwrap();
        assert!(s.contains("\"error\""), "actual: {s}");
        assert!(!s.contains("\"result\""), "must not contain result: {s}");
    }

    /// **Proves:** a parsed request without `params` deserializes cleanly —
    /// the field is optional.
    ///
    /// **Why it matters:** Methods like `get_blockchain_state` take no
    /// params. JSON-RPC clients often omit the field entirely (`{"jsonrpc":
    /// "2.0", "id": 1, "method": "get_blockchain_state"}`). If `params`
    /// were a required field, these requests would fail deserialization.
    ///
    /// **Catches:** dropping the `Option<P>` wrap on `params`, or removing
    /// the `skip_serializing_if` that keeps the field out of the wire when
    /// `None`.
    #[test]
    fn request_without_params_deserialises() {
        let raw = r#"{"jsonrpc":"2.0","id":1,"method":"get_blockchain_state"}"#;
        let req: JsonRpcRequest<serde_json::Value> = serde_json::from_str(raw).unwrap();
        assert_eq!(req.method, "get_blockchain_state");
        assert!(req.params.is_none());
    }
}