Skip to main content

dig_rpc/
dispatch.rs

1//! JSON-RPC envelope → [`RpcApi::dispatch`] adapter.
2//!
3//! Given a raw `JsonRpcRequest<serde_json::Value>` and an `impl RpcApi`,
4//! produce a fully-formed `JsonRpcResponse<serde_json::Value>` suitable for
5//! return to the client. Wraps:
6//!
7//! 1. The method-not-found check (via the method registry).
8//! 2. The `RpcApi::dispatch` call itself, which the API implementor owns.
9//! 3. Panic catching (converts panics to `InternalError` envelopes).
10//! 4. The response-envelope assembly (success vs error body).
11
12use std::sync::Arc;
13
14use dig_rpc_types::envelope::{
15    JsonRpcError, JsonRpcRequest, JsonRpcResponse, JsonRpcResponseBody, Version,
16};
17use dig_rpc_types::errors::ErrorCode;
18use dig_service::RpcApi;
19
20use crate::method::MethodRegistry;
21
22/// Top-level dispatch.
23///
24/// Attempts the call under a `catch_unwind` so that a panic in a handler
25/// returns `InternalError` rather than tearing down the worker thread.
26pub async fn dispatch_envelope<R: RpcApi + ?Sized>(
27    req: JsonRpcRequest<serde_json::Value>,
28    api: &R,
29    registry: &MethodRegistry,
30) -> JsonRpcResponse<serde_json::Value> {
31    // Fast path: method registered?
32    if registry.get(&req.method).is_none() {
33        return JsonRpcResponse {
34            jsonrpc: Version,
35            id: req.id,
36            body: JsonRpcResponseBody::Error {
37                error: JsonRpcError {
38                    code: ErrorCode::MethodNotFound,
39                    message: format!("method {:?} not registered", req.method),
40                    data: None,
41                },
42            },
43        };
44    }
45
46    let method = req.method.clone();
47    let params = req.params.unwrap_or(serde_json::Value::Null);
48
49    // We don't use `catch_unwind` here (RpcApi::dispatch may hold !UnwindSafe
50    // state like Arc<Mutex<...>>). The panic-catch layer in the tower stack
51    // wraps the outer HTTP handler and converts panics to HTTP 500 +
52    // InternalError body. That covers the panic case without the
53    // UnwindSafe bound.
54    let result = api.dispatch(&method, params).await;
55
56    match result {
57        Ok(value) => JsonRpcResponse {
58            jsonrpc: Version,
59            id: req.id,
60            body: JsonRpcResponseBody::Success { result: value },
61        },
62        Err(err) => JsonRpcResponse {
63            jsonrpc: Version,
64            id: req.id,
65            body: JsonRpcResponseBody::Error { error: err },
66        },
67    }
68}
69
70/// Build an error envelope by-hand. Useful for middleware that rejects
71/// before dispatch runs (rate limit, unknown role, etc.).
72pub fn error_envelope(
73    id: dig_rpc_types::envelope::RequestId,
74    code: ErrorCode,
75    message: impl Into<String>,
76) -> JsonRpcResponse<serde_json::Value> {
77    JsonRpcResponse {
78        jsonrpc: Version,
79        id,
80        body: JsonRpcResponseBody::Error {
81            error: JsonRpcError {
82                code,
83                message: message.into(),
84                data: None,
85            },
86        },
87    }
88}
89
90/// Shared stub `RpcApi` — rejects every method with `InternalError`.
91/// Used internally as a placeholder when binaries build a server without
92/// an actual API implementation (e.g., doctests).
93///
94/// Kept `pub(crate)` so it's not part of the public API; downstream
95/// binaries supply their own `RpcApi`.
96#[cfg(test)]
97pub(crate) struct StubApi;
98
99#[cfg(test)]
100#[async_trait::async_trait]
101impl RpcApi for StubApi {
102    async fn dispatch(
103        &self,
104        method: &str,
105        _params: serde_json::Value,
106    ) -> Result<serde_json::Value, JsonRpcError> {
107        Err(JsonRpcError {
108            code: ErrorCode::InternalError,
109            message: format!("stub api does not implement {method:?}"),
110            data: None,
111        })
112    }
113}
114
115// Suppress unused-import warning when tests are not compiled.
116#[allow(dead_code)]
117#[doc(hidden)]
118pub(crate) fn _keep_arc_usage_if_any(_: Arc<()>) {}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::method::{MethodMeta, RateBucket};
124    use crate::role::Role;
125    use dig_rpc_types::envelope::RequestId;
126
127    /// **Proves:** dispatching an unregistered method returns a
128    /// `MethodNotFound` envelope with the original request id.
129    ///
130    /// **Why it matters:** If `dispatch_envelope` ignored the registry,
131    /// the server would fan out every unknown method to `RpcApi::dispatch`,
132    /// which typically returns `InternalError` — defeating the clean
133    /// "method not found" UX.
134    ///
135    /// **Catches:** a regression where the registry check is bypassed, or
136    /// where the id from the request is not echoed back.
137    #[tokio::test]
138    async fn unknown_method_returns_method_not_found() {
139        let api = StubApi;
140        let reg = MethodRegistry::new();
141        let req = JsonRpcRequest {
142            jsonrpc: Version,
143            id: RequestId::Num(7),
144            method: "nope".to_string(),
145            params: None,
146        };
147        let resp = dispatch_envelope(req, &api, &reg).await;
148        assert!(matches!(resp.id, RequestId::Num(7)));
149        match resp.body {
150            JsonRpcResponseBody::Error { error } => {
151                assert_eq!(error.code, ErrorCode::MethodNotFound);
152            }
153            _ => panic!("expected error response"),
154        }
155    }
156
157    /// **Proves:** when the API returns `Err(JsonRpcError)`, that error is
158    /// propagated into the response envelope unchanged.
159    ///
160    /// **Why it matters:** API implementors return typed errors to
161    /// distinguish e.g. `WalletLocked` from `InvalidParams`. If the
162    /// dispatch layer flattened all errors to `InternalError`, that
163    /// signal would be lost.
164    ///
165    /// **Catches:** a regression that wraps the inner error in a generic
166    /// outer one.
167    #[tokio::test]
168    async fn api_error_propagates() {
169        // Register the method so we pass the "unknown method" check.
170        let reg = MethodRegistry::new();
171        reg.register(MethodMeta::read(
172            "stub",
173            Role::Explorer,
174            RateBucket::ReadLight,
175        ));
176        let api = StubApi;
177        let req = JsonRpcRequest {
178            jsonrpc: Version,
179            id: RequestId::Num(1),
180            method: "stub".to_string(),
181            params: None,
182        };
183        let resp = dispatch_envelope(req, &api, &reg).await;
184        match resp.body {
185            JsonRpcResponseBody::Error { error } => {
186                assert_eq!(error.code, ErrorCode::InternalError);
187                assert!(error.message.contains("stub"));
188            }
189            _ => panic!("expected error response"),
190        }
191    }
192
193    /// **Proves:** `error_envelope` builds a well-formed error response
194    /// with the caller-supplied id / code / message.
195    ///
196    /// **Why it matters:** Middleware layers (rate-limit, allow-list) use
197    /// this helper to reject requests before dispatch. The envelope shape
198    /// must match what clients expect so their error-handling paths fire.
199    ///
200    /// **Catches:** a regression that omits `jsonrpc: "2.0"` or misaligns
201    /// the body tag.
202    #[test]
203    fn error_envelope_shape() {
204        let resp = error_envelope(RequestId::Num(5), ErrorCode::RateLimited, "slow down");
205        assert!(matches!(resp.id, RequestId::Num(5)));
206        match resp.body {
207            JsonRpcResponseBody::Error { error } => {
208                assert_eq!(error.code, ErrorCode::RateLimited);
209                assert_eq!(error.message, "slow down");
210            }
211            _ => panic!("expected error response"),
212        }
213    }
214}