Skip to main content

aion_integrations/jsonrpc/
envelope.rs

1//! The generic JSON-RPC 2.0 envelope types and the standard error codes.
2//!
3//! Lifted and generalised from Norn's in-tree MCP prior art (§9.4): the same
4//! `JsonRpcRequest`/`JsonRpcResponse`/`JsonRpcError` envelopes and `-32700/-32600/-32601/-32603`
5//! error codes, with the MCP-specific payloads dropped so the envelopes are harness-neutral.
6
7use serde::{Deserialize, Serialize};
8
9/// The JSON-RPC version string every envelope carries.
10pub const JSONRPC_VERSION: &str = "2.0";
11
12/// The standard JSON-RPC 2.0 error codes.
13///
14/// The reserved implementation range `-32000..=-32099` is left to the concrete adapter (e.g. an
15/// application `stale target` code); only the protocol-standard codes live here.
16pub mod error_codes {
17    /// Invalid JSON was received (the payload could not be parsed).
18    pub const PARSE_ERROR: i64 = -32700;
19    /// The JSON sent is not a valid Request object.
20    pub const INVALID_REQUEST: i64 = -32600;
21    /// The requested method does not exist or is not available on this peer.
22    pub const METHOD_NOT_FOUND: i64 = -32601;
23    /// Invalid method parameters.
24    pub const INVALID_PARAMS: i64 = -32602;
25    /// Internal JSON-RPC error.
26    pub const INTERNAL_ERROR: i64 = -32603;
27}
28
29/// A JSON-RPC request or response id.
30///
31/// JSON-RPC 2.0 permits a string, a number, or null as an id. Modelled as an untagged enum so it
32/// round-trips any peer's id shape verbatim and can be compared for correlation.
33#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
34#[serde(untagged)]
35pub enum JsonRpcId {
36    /// A numeric id (the shape this layer allocates for its own requests).
37    Number(u64),
38    /// A string id (accepted from a peer that allocates string ids).
39    Text(String),
40}
41
42impl JsonRpcId {
43    /// A numeric id.
44    #[must_use]
45    pub const fn number(value: u64) -> Self {
46        Self::Number(value)
47    }
48}
49
50/// A JSON-RPC 2.0 request: a call correlated by its `id`.
51#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
52pub struct JsonRpcRequest {
53    /// The JSON-RPC version, always [`JSONRPC_VERSION`].
54    pub jsonrpc: String,
55    /// The correlation id. A request always carries one (a notification does not).
56    pub id: JsonRpcId,
57    /// The method name (the adapter's own namespace).
58    pub method: String,
59    /// The method parameters; omitted on the wire when absent.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub params: Option<serde_json::Value>,
62}
63
64impl JsonRpcRequest {
65    /// Builds a request with the given id, method, and optional params.
66    #[must_use]
67    pub fn new(
68        id: JsonRpcId,
69        method: impl Into<String>,
70        params: Option<serde_json::Value>,
71    ) -> Self {
72        Self {
73            jsonrpc: JSONRPC_VERSION.to_owned(),
74            id,
75            method: method.into(),
76            params,
77        }
78    }
79}
80
81/// A JSON-RPC 2.0 notification: a one-way message with **no `id`**.
82///
83/// The `Option<id>` discrimination is structural: a notification is exactly a message that omits
84/// `id`, so it can never be correlated to (or mistaken for) a request/response.
85#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
86pub struct JsonRpcNotification {
87    /// The JSON-RPC version, always [`JSONRPC_VERSION`].
88    pub jsonrpc: String,
89    /// The method name (the adapter's own namespace).
90    pub method: String,
91    /// The notification parameters; omitted on the wire when absent.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub params: Option<serde_json::Value>,
94}
95
96impl JsonRpcNotification {
97    /// Builds a notification with the given method and optional params.
98    #[must_use]
99    pub fn new(method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
100        Self {
101            jsonrpc: JSONRPC_VERSION.to_owned(),
102            method: method.into(),
103            params,
104        }
105    }
106}
107
108/// A JSON-RPC 2.0 error object carried in a failed response.
109#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
110pub struct JsonRpcError {
111    /// The numeric error code (see [`error_codes`]).
112    pub code: i64,
113    /// A short human-readable description of the error.
114    pub message: String,
115    /// Optional structured error data.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub data: Option<serde_json::Value>,
118}
119
120impl JsonRpcError {
121    /// Builds an error object with a code and message.
122    #[must_use]
123    pub fn new(code: i64, message: impl Into<String>) -> Self {
124        Self {
125            code,
126            message: message.into(),
127            data: None,
128        }
129    }
130}
131
132/// A JSON-RPC 2.0 response: the reply to a request, correlated by matching `id`.
133///
134/// Exactly one of [`Self::result`] / [`Self::error`] is populated per the spec.
135#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
136pub struct JsonRpcResponse {
137    /// The JSON-RPC version, always [`JSONRPC_VERSION`].
138    pub jsonrpc: String,
139    /// The id of the request this response answers.
140    pub id: JsonRpcId,
141    /// The success payload, when the call succeeded.
142    ///
143    /// `None` means the `result` key was ABSENT from the frame; a present `"result": null`
144    /// decodes to `Some(Value::Null)` (via [`deserialize_present_value`]). The spec makes the
145    /// distinction load-bearing: `null` is a legal success payload, while a response carrying
146    /// neither `result` nor `error` is a broken frame — the two must not be conflated.
147    #[serde(
148        default,
149        deserialize_with = "deserialize_present_value",
150        skip_serializing_if = "Option::is_none"
151    )]
152    pub result: Option<serde_json::Value>,
153    /// The error payload, when the call failed.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub error: Option<JsonRpcError>,
156}
157
158/// Deserializes a field whose KEY PRESENCE matters: serde invokes `deserialize_with` only when
159/// the key is present, so this captures the value verbatim — including JSON `null`, which becomes
160/// `Some(Value::Null)` instead of collapsing into the absent-key `None` that plain
161/// `Option<Value>` would produce. The absent-key case is covered by `#[serde(default)]`.
162fn deserialize_present_value<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>
163where
164    D: serde::Deserializer<'de>,
165{
166    serde_json::Value::deserialize(deserializer).map(Some)
167}
168
169impl JsonRpcResponse {
170    /// Builds a success response for the given request id.
171    #[must_use]
172    pub fn success(id: JsonRpcId, result: serde_json::Value) -> Self {
173        Self {
174            jsonrpc: JSONRPC_VERSION.to_owned(),
175            id,
176            result: Some(result),
177            error: None,
178        }
179    }
180
181    /// Builds an error response for the given request id.
182    #[must_use]
183    pub fn failure(id: JsonRpcId, error: JsonRpcError) -> Self {
184        Self {
185            jsonrpc: JSONRPC_VERSION.to_owned(),
186            id,
187            result: None,
188            error: Some(error),
189        }
190    }
191}
192
193/// A single decoded inbound JSON-RPC message, classified by the `Option<id>` discrimination.
194///
195/// A frame with `method` + `id` is a [`Self::Request`]; a frame with `method` and no `id` is a
196/// [`Self::Notification`]; a frame with `id` and `result`/`error` (no `method`) is a
197/// [`Self::Response`]. This is the structural result/event split the whole design relies on: a
198/// notification can never carry an id, so it can never be captured as a correlated response.
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub enum IncomingMessage {
201    /// A peer-initiated call awaiting a response.
202    Request(JsonRpcRequest),
203    /// A peer-initiated one-way message (no response).
204    Notification(JsonRpcNotification),
205    /// A reply to one of our outstanding requests.
206    Response(JsonRpcResponse),
207}
208
209impl IncomingMessage {
210    /// Classifies a decoded JSON value into the correct message kind.
211    ///
212    /// # Errors
213    ///
214    /// Returns the decode error when the value matches none of the three JSON-RPC shapes.
215    pub fn from_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
216        let has_method = value.get("method").is_some();
217        let has_id = value.get("id").is_some_and(|id| !id.is_null());
218        if has_method {
219            if has_id {
220                serde_json::from_value(value).map(Self::Request)
221            } else {
222                serde_json::from_value(value).map(Self::Notification)
223            }
224        } else {
225            serde_json::from_value(value).map(Self::Response)
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use serde_json::json;
233
234    use super::{
235        IncomingMessage, JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcRequest,
236        JsonRpcResponse, error_codes,
237    };
238
239    #[test]
240    fn request_serialises_with_id_and_omits_absent_params() -> Result<(), serde_json::Error> {
241        let request = JsonRpcRequest::new(JsonRpcId::number(1), "run/execute", None);
242        let value = serde_json::to_value(&request)?;
243        assert_eq!(value["jsonrpc"], "2.0");
244        assert_eq!(value["id"], 1);
245        assert_eq!(value["method"], "run/execute");
246        assert!(value.get("params").is_none(), "absent params are omitted");
247        Ok(())
248    }
249
250    #[test]
251    fn notification_never_carries_an_id() -> Result<(), serde_json::Error> {
252        let notification = JsonRpcNotification::new("event/message", Some(json!({ "text": "hi" })));
253        let value = serde_json::to_value(&notification)?;
254        assert!(value.get("id").is_none(), "notifications carry no id");
255        assert_eq!(value["params"]["text"], "hi");
256        Ok(())
257    }
258
259    #[test]
260    fn classifies_request_notification_and_response() -> Result<(), serde_json::Error> {
261        let request = IncomingMessage::from_value(json!({
262            "jsonrpc": "2.0", "id": 7, "method": "intervene/inject", "params": {}
263        }))?;
264        assert!(matches!(request, IncomingMessage::Request(_)));
265
266        let notification = IncomingMessage::from_value(json!({
267            "jsonrpc": "2.0", "method": "event/stop", "params": {}
268        }))?;
269        assert!(matches!(notification, IncomingMessage::Notification(_)));
270
271        let response = IncomingMessage::from_value(json!({
272            "jsonrpc": "2.0", "id": 7, "result": { "ok": true }
273        }))?;
274        assert!(matches!(response, IncomingMessage::Response(_)));
275        Ok(())
276    }
277
278    #[test]
279    fn a_null_id_is_treated_as_a_notification_not_a_request() -> Result<(), serde_json::Error> {
280        let message = IncomingMessage::from_value(json!({
281            "jsonrpc": "2.0", "id": null, "method": "event/raw", "params": {}
282        }))?;
283        assert!(
284            matches!(message, IncomingMessage::Notification(_)),
285            "a null id must not make a method-bearing frame a correlated request"
286        );
287        Ok(())
288    }
289
290    #[test]
291    fn error_response_carries_result_none() {
292        let response = JsonRpcResponse::failure(
293            JsonRpcId::number(3),
294            JsonRpcError::new(error_codes::METHOD_NOT_FOUND, "method not found"),
295        );
296        assert!(response.result.is_none());
297        assert!(
298            matches!(response.error, Some(error) if error.code == error_codes::METHOD_NOT_FOUND),
299            "an error response carries the code"
300        );
301    }
302
303    #[test]
304    fn a_present_null_result_decodes_as_some_null() -> Result<(), serde_json::Error> {
305        // `"result": null` is a legal success payload and must stay distinguishable from a frame
306        // that carries no `result` key at all.
307        let response: JsonRpcResponse = serde_json::from_value(json!({
308            "jsonrpc": "2.0", "id": 1, "result": null
309        }))?;
310        assert_eq!(response.result, Some(serde_json::Value::Null));
311        let wire = serde_json::to_value(&response)?;
312        assert!(
313            wire.as_object()
314                .is_some_and(|frame| frame.contains_key("result")),
315            "a present null result round-trips with the key on the wire"
316        );
317        Ok(())
318    }
319
320    #[test]
321    fn an_absent_result_decodes_as_none() -> Result<(), serde_json::Error> {
322        // A frame with neither `result` nor `error` (a broken peer) decodes with `result: None`,
323        // never fabricated into a null payload.
324        let response: JsonRpcResponse = serde_json::from_value(json!({
325            "jsonrpc": "2.0", "id": 1
326        }))?;
327        assert!(response.result.is_none());
328        assert!(response.error.is_none());
329        let wire = serde_json::to_value(&response)?;
330        assert!(
331            wire.as_object()
332                .is_some_and(|frame| !frame.contains_key("result")),
333            "an absent result stays absent on the wire"
334        );
335        Ok(())
336    }
337
338    #[test]
339    fn string_ids_round_trip() -> Result<(), serde_json::Error> {
340        let id = JsonRpcId::Text("abc".to_owned());
341        let value = serde_json::to_value(&id)?;
342        let decoded: JsonRpcId = serde_json::from_value(value)?;
343        assert_eq!(decoded, id);
344        Ok(())
345    }
346}