dig-rpc-types 0.2.1

Canonical DIG-node JSON-RPC interface: request/response types, the method enum + tier classification, the error-code taxonomy, and an OpenRPC 1.2.6 document generator. The single source of truth both DIG node implementations depend on. Pure types — no I/O, no async, no server logic.
Documentation
//! JSON-RPC 2.0 envelope types.
//!
//! The DIG-node RPC wire is strict [JSON-RPC 2.0](https://www.jsonrpc.org/specification):
//! every request carries `jsonrpc: "2.0"`, an `id`, a `method`, and optional
//! `params`; every response carries `jsonrpc`, the echoed `id`, and exactly one
//! of `result` or `error`.
//!
//! # The [`Version`] marker
//!
//! [`Version`] is zero-sized and (de)serializes as the literal string `"2.0"`.
//! A response whose `jsonrpc` field is anything else fails to deserialize into
//! [`JsonRpcResponse`] with no hand-written check — the version guard is
//! structural.
//!
//! # The error envelope
//!
//! Errors follow the canonical DIG envelope
//! `{"code": <int>, "message": <str>, "data": {"code": <UPPER_SNAKE>, "origin": <origin>}}`.
//! See [`crate::error`] for the [`RpcError`] type and the single constructor
//! helper that mints it.

use serde::{Deserialize, Serialize};

use crate::error::RpcError;

/// A JSON-RPC 2.0 request envelope.
///
/// `P` is the method-specific parameter type. Concrete callers use a
/// `{Method}Params` struct from [`crate::types`]; generic callers (the server
/// dispatch, proxies, middleware) use `serde_json::Value`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JsonRpcRequest<P = serde_json::Value> {
    /// Protocol version; serializes to the literal `"2.0"`.
    pub jsonrpc: Version,
    /// Correlation id. Echoed unchanged in the response.
    pub id: RequestId,
    /// The method name (e.g. `"dig.getContent"`).
    pub method: String,
    /// Method-specific parameters. Absent for methods that take none.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub params: Option<P>,
}

impl<P> JsonRpcRequest<P> {
    /// Build a request with a numeric id and the given method + params.
    pub fn new(id: u64, method: impl Into<String>, params: P) -> Self {
        Self {
            jsonrpc: Version,
            id: RequestId::Num(id),
            method: method.into(),
            params: Some(params),
        }
    }
}

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

impl<R> JsonRpcResponse<R> {
    /// Build a success response echoing `id`.
    pub fn success(id: RequestId, result: R) -> Self {
        Self {
            jsonrpc: Version,
            id,
            body: JsonRpcResponseBody::Success { result },
        }
    }

    /// Build an error response echoing `id`.
    pub fn error(id: RequestId, error: RpcError) -> Self {
        Self {
            jsonrpc: Version,
            id,
            body: JsonRpcResponseBody::Error { error },
        }
    }

    /// The error, if this is an error response.
    pub fn as_error(&self) -> Option<&RpcError> {
        match &self.body {
            JsonRpcResponseBody::Error { error } => Some(error),
            JsonRpcResponseBody::Success { .. } => None,
        }
    }

    /// The result, if this is a success response.
    pub fn as_result(&self) -> Option<&R> {
        match &self.body {
            JsonRpcResponseBody::Success { result } => Some(result),
            JsonRpcResponseBody::Error { .. } => None,
        }
    }
}

/// The body of a response: either a successful result or an error.
///
/// `#[serde(untagged)]` keeps the JSON flat — `{"result": …}` or
/// `{"error": …}` — as the spec requires.
#[derive(Debug, Clone, PartialEq, 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 canonical error envelope.
    Error {
        /// The error object.
        error: RpcError,
    },
}

/// Per-request correlation id.
///
/// JSON-RPC 2.0 permits numeric, string, or null ids; a server echoes the id
/// unchanged. Null is reserved for notifications (DIG RPC does not use them but
/// accepts 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,
}

impl Default for RequestId {
    fn default() -> Self {
        RequestId::Num(1)
    }
}

/// The JSON-RPC protocol version marker.
///
/// Zero-sized; (de)serializes 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
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::{ErrorCode, ErrorOrigin};

    /// **Proves:** `Version` serializes to the literal `"2.0"`.
    /// **Catches:** a `#[derive(Serialize)]` regression that would emit `null`
    /// for a unit struct, or a swap to a typed wrapper.
    #[test]
    fn version_serialises_as_two_point_zero() {
        assert_eq!(serde_json::to_string(&Version).unwrap(), "\"2.0\"");
    }

    /// **Proves:** a non-`"2.0"` jsonrpc field is rejected.
    /// **Catches:** a `Version::deserialize` that accepts any string.
    #[test]
    fn version_rejects_non_2_0() {
        assert!(serde_json::from_str::<Version>(r#""1.0""#).is_err());
    }

    /// **Proves:** `RequestId` round-trips all three variants untagged.
    /// **Catches:** loss of `#[serde(untagged)]` (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);
            assert_eq!(serde_json::from_str::<RequestId>(&s).unwrap(), rid);
        }
    }

    /// **Proves:** success serializes as `{"result": …}` and error as
    /// `{"error": …}`, never both.
    /// **Catches:** dropping `#[serde(untagged)]` from the response body.
    #[test]
    fn response_body_success_and_error_shape() {
        let ok = JsonRpcResponse::success(RequestId::Num(1), serde_json::json!({"ok": true}));
        let s = serde_json::to_string(&ok).unwrap();
        assert!(s.contains("\"result\""), "{s}");
        assert!(!s.contains("\"error\""), "{s}");

        let err = JsonRpcResponse::<serde_json::Value>::error(
            RequestId::Num(2),
            RpcError::new(
                ErrorCode::MethodNotFound,
                "no such method",
                ErrorOrigin::Node,
            ),
        );
        let s = serde_json::to_string(&err).unwrap();
        assert!(s.contains("\"error\""), "{s}");
        assert!(!s.contains("\"result\""), "{s}");
    }

    /// **Proves:** a request without `params` deserializes cleanly.
    /// **Catches:** dropping the `Option<P>`/`skip_serializing_if` on `params`.
    #[test]
    fn request_without_params_deserialises() {
        let raw = r#"{"jsonrpc":"2.0","id":1,"method":"dig.health"}"#;
        let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
        assert_eq!(req.method, "dig.health");
        assert!(req.params.is_none());
    }

    /// **Proves:** the `as_error` / `as_result` accessors branch correctly.
    #[test]
    fn accessors_branch() {
        let ok = JsonRpcResponse::success(RequestId::Num(1), 7u32);
        assert_eq!(ok.as_result(), Some(&7));
        assert!(ok.as_error().is_none());
    }
}