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, ®).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, ®).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}