Skip to main content

kanade_shared/ipc/
envelope.rs

1//! JSON-RPC 2.0 envelope types for KLP (SPEC §2.12.3).
2//!
3//! Three message shapes flow over the framed transport (Named Pipe
4//! on Windows, Unix Domain Socket on Linux/macOS):
5//!
6//! - [`RpcRequest`] — `{jsonrpc, id, method, params}`. Carries an
7//!   `id` so the recipient can correlate the matching response.
8//! - [`RpcNotification`] — `{jsonrpc, method, params}`. No `id`,
9//!   no response. Used for server push (`notifications.new`,
10//!   `jobs.progress`, `state.changed`).
11//! - [`RpcResponse`] — `{jsonrpc, id, result|error}`. Exactly one
12//!   of `result` or `error` is present.
13//!
14//! [`RpcMessage`] is an untagged enum over the three for the read
15//! side of the connection (one decoder, three possible shapes). The
16//! write side picks the concrete type directly.
17//!
18//! `id` is modelled as a [`String`] to match SPEC §2.12.3's "UUID v7
19//! 推奨" guidance — JSON-RPC 2.0 allows numbers and null too, but
20//! KLP is a closed two-party protocol where both ends are ours, so
21//! we narrow to the form we actually use. Inbound non-string ids
22//! fail decode and the agent returns `InvalidRequest`.
23//!
24//! `params` and `result` are typed as [`serde_json::Value`] at the
25//! envelope layer so the dispatcher can route on `method` BEFORE
26//! committing to a payload schema. Each per-method module
27//! (`handshake`, `system`, `jobs`, …) then `serde_json::from_value`s
28//! into its strongly-typed params/result struct. This is a
29//! deliberate trade — one extra (de)serialise hop in exchange for
30//! the envelope staying method-agnostic, which is what makes the
31//! dispatcher implementable as a `match method.as_str()` block.
32
33use serde::de::DeserializeOwned;
34use serde::{Deserialize, Serialize};
35
36use super::error::RpcError;
37
38/// The version string every KLP message carries in the `jsonrpc`
39/// field. Pinned to `"2.0"` per the JSON-RPC spec; KLP doesn't
40/// negotiate a different RPC version — protocol evolution happens
41/// through the handshake's `protocol` field (SPEC §2.12.6).
42pub const JSONRPC_VERSION: &str = "2.0";
43
44/// Client → Agent request that expects a response (correlated by `id`).
45///
46/// SPEC shape:
47/// ```jsonc
48/// {"jsonrpc":"2.0","id":"01931a8e-...","method":"system.handshake",
49///  "params":{...}}
50/// ```
51#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
52pub struct RpcRequest {
53    pub jsonrpc: String,
54    pub id: String,
55    pub method: String,
56    /// `params` is wire-optional — methods like `system.ping` take
57    /// no arguments and SHOULD omit the field rather than send
58    /// `null`. Decoders see `serde_json::Value::Null` for either
59    /// form, so callers must not rely on absent-vs-null to carry
60    /// meaning.
61    #[serde(default, skip_serializing_if = "is_null")]
62    pub params: serde_json::Value,
63}
64
65/// Server-push or fire-and-forget message with no response (no `id`).
66///
67/// Used for `notifications.new`, `jobs.progress`, `state.changed`
68/// (Agent → Client) and, when needed, request-shaped Client → Agent
69/// messages that don't want a response (none currently — kept here
70/// for symmetry with JSON-RPC 2.0).
71#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
72pub struct RpcNotification {
73    pub jsonrpc: String,
74    pub method: String,
75    #[serde(default, skip_serializing_if = "is_null")]
76    pub params: serde_json::Value,
77}
78
79/// Response to a [`RpcRequest`]. Exactly one of `result` or `error`
80/// is populated — see [`RpcResponsePayload`].
81///
82/// Modelled as a struct with a flattened payload enum (rather than
83/// two field options) so the type system enforces the spec's
84/// "exactly one of" requirement: it's impossible to construct a
85/// response that has both, or neither.
86///
87/// `id` is [`Option<String>`] because JSON-RPC 2.0 mandates `null`
88/// for errors that fire BEFORE the request id can be parsed —
89/// [`super::error::ErrorKind::ParseError`] (the body wasn't valid
90/// JSON at all) and [`super::error::ErrorKind::InvalidRequest`]
91/// (envelope rejected). [`RpcResponse::err_anonymous`] is the
92/// dedicated constructor for that case; the happy-path [`Self::ok`]
93/// / [`Self::err`] keep the `String` ergonomic.
94#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
95pub struct RpcResponse {
96    pub jsonrpc: String,
97    pub id: Option<String>,
98    #[serde(flatten)]
99    pub payload: RpcResponsePayload,
100}
101
102/// Either-or payload for [`RpcResponse`]. `serde(untagged)` means
103/// each variant is recognised purely by which key (`result` or
104/// `error`) is present on the wire.
105#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
106#[serde(untagged)]
107pub enum RpcResponsePayload {
108    /// Success path. `result` may be any JSON value — including
109    /// `null` for void methods like `notifications.unsubscribe`.
110    Ok { result: serde_json::Value },
111    /// Failure path. See [`RpcError`] for the error model.
112    Err { error: RpcError },
113}
114
115/// Top-level decoded message for the agent's read loop. Inbound
116/// bytes are parsed into this enum once; the dispatcher then
117/// matches on the variant to route.
118///
119/// Untagged enum, decoded by trying variants in declaration order:
120/// `Response` first (it owns `result`/`error`, neither of which
121/// appear on requests), then `Request` (has both `id` and
122/// `method`), then `Notification` (has `method` but no `id`). The
123/// ordering matters — putting `Request` first would let it greedily
124/// match `{id, method, error}` because `params` is optional and the
125/// extra `error` field is silently ignored by serde-derived structs.
126#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
127#[serde(untagged)]
128pub enum RpcMessage {
129    Response(RpcResponse),
130    Request(RpcRequest),
131    Notification(RpcNotification),
132}
133
134impl RpcRequest {
135    /// Build a typed request. Serialises `params` to JSON eagerly so
136    /// later dispatch is cheap and the failure surface is just this
137    /// call — no surprises mid-send.
138    pub fn new<P: Serialize>(
139        id: impl Into<String>,
140        method: impl Into<String>,
141        params: &P,
142    ) -> Result<Self, serde_json::Error> {
143        Ok(Self {
144            jsonrpc: JSONRPC_VERSION.to_string(),
145            id: id.into(),
146            method: method.into(),
147            params: serde_json::to_value(params)?,
148        })
149    }
150}
151
152impl RpcNotification {
153    /// Build a typed notification (no id, no response).
154    pub fn new<P: Serialize>(
155        method: impl Into<String>,
156        params: &P,
157    ) -> Result<Self, serde_json::Error> {
158        Ok(Self {
159            jsonrpc: JSONRPC_VERSION.to_string(),
160            method: method.into(),
161            params: serde_json::to_value(params)?,
162        })
163    }
164}
165
166impl RpcResponse {
167    /// Build a success response for a given request `id` from a
168    /// typed result. `R = ()` is encoded as JSON `null`, matching
169    /// SPEC §2.12.7's `{"result":null}` for void method returns.
170    pub fn ok<R: Serialize>(id: impl Into<String>, result: &R) -> Result<Self, serde_json::Error> {
171        Ok(Self {
172            jsonrpc: JSONRPC_VERSION.to_string(),
173            id: Some(id.into()),
174            payload: RpcResponsePayload::Ok {
175                result: serde_json::to_value(result)?,
176            },
177        })
178    }
179
180    /// Build an error response correlated to a known request `id`.
181    pub fn err(id: impl Into<String>, error: RpcError) -> Self {
182        Self {
183            jsonrpc: JSONRPC_VERSION.to_string(),
184            id: Some(id.into()),
185            payload: RpcResponsePayload::Err { error },
186        }
187    }
188
189    /// Build an error response with `id: null` — the JSON-RPC 2.0
190    /// shape for errors that fire before the request id can be
191    /// parsed (`ParseError` on un-decodable JSON; `InvalidRequest`
192    /// on an envelope missing required fields). Distinct from
193    /// [`Self::err`] so the type system makes "I don't have an id
194    /// to correlate" an explicit choice.
195    pub fn err_anonymous(error: RpcError) -> Self {
196        Self {
197            jsonrpc: JSONRPC_VERSION.to_string(),
198            id: None,
199            payload: RpcResponsePayload::Err { error },
200        }
201    }
202}
203
204/// Decode a method's `params` payload from the envelope's
205/// [`serde_json::Value`] slot, treating `Value::Null` (the wire
206/// shape for an omitted `params` field, per SPEC §2.12.3) as
207/// equivalent to `P::default()`.
208///
209/// Solves the "empty params struct can't deserialize from null"
210/// hole: methods like `system.ping` SHOULD omit `params`
211/// (envelope.rs:55 doc), which arrives as `Value::Null`, but
212/// `serde_json::from_value::<PingParams>(Null)` would fail because
213/// PingParams expects an object. Routing every params decode
214/// through this helper lets the dispatcher accept both the
215/// canonical absent form and an explicit `params: {}` without
216/// per-method branching.
217///
218/// Wrong-shape non-null inputs (e.g. an array where an object is
219/// expected) still fail loudly through normal serde decoding — the
220/// helper only widens the null case.
221pub fn decode_params<P: DeserializeOwned + Default>(
222    value: serde_json::Value,
223) -> Result<P, serde_json::Error> {
224    if value.is_null() {
225        Ok(P::default())
226    } else {
227        serde_json::from_value(value)
228    }
229}
230
231fn is_null(v: &serde_json::Value) -> bool {
232    v.is_null()
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::ipc::error::ErrorKind;
239
240    #[derive(Serialize, Deserialize, Debug, PartialEq)]
241    struct DummyParams {
242        foo: String,
243        bar: u32,
244    }
245
246    #[test]
247    fn request_round_trips_through_json() {
248        let req = RpcRequest::new(
249            "u1",
250            "system.handshake",
251            &DummyParams {
252                foo: "hello".into(),
253                bar: 7,
254            },
255        )
256        .expect("encode");
257        let json = serde_json::to_string(&req).unwrap();
258        // Spot-check wire shape — `params` is nested, not flattened.
259        assert!(json.contains("\"jsonrpc\":\"2.0\""), "wire: {json}");
260        assert!(json.contains("\"method\":\"system.handshake\""));
261        assert!(json.contains("\"id\":\"u1\""));
262        let back: RpcRequest = serde_json::from_str(&json).unwrap();
263        assert_eq!(back.id, "u1");
264        assert_eq!(back.method, "system.handshake");
265        let p: DummyParams = serde_json::from_value(back.params).unwrap();
266        assert_eq!(p.foo, "hello");
267        assert_eq!(p.bar, 7);
268    }
269
270    #[test]
271    fn request_without_params_omits_field_on_wire() {
272        // SPEC §2.12.6's `system.ping` has no params — the
273        // serializer SHOULD drop the field rather than emit
274        // `"params":null`, since strict JSON-RPC parsers reject the
275        // latter for some methods.
276        let req = RpcRequest {
277            jsonrpc: JSONRPC_VERSION.into(),
278            id: "ping-1".into(),
279            method: "system.ping".into(),
280            params: serde_json::Value::Null,
281        };
282        let v = serde_json::to_value(&req).unwrap();
283        assert!(v.get("params").is_none(), "wire: {v:?}");
284    }
285
286    #[test]
287    fn notification_decodes_without_id() {
288        // SPEC §2.12.7 push: `notifications.new` arrives with no id.
289        let wire = r#"{"jsonrpc":"2.0","method":"notifications.new",
290                       "params":{"id":"notif-9f3a"}}"#;
291        let m: RpcMessage = serde_json::from_str(wire).unwrap();
292        match m {
293            RpcMessage::Notification(n) => {
294                assert_eq!(n.method, "notifications.new");
295                assert_eq!(n.params["id"], "notif-9f3a");
296            }
297            other => panic!("expected Notification, got {other:?}"),
298        }
299    }
300
301    #[test]
302    fn success_response_decodes_and_round_trips() {
303        let r =
304            RpcResponse::ok("u3", &serde_json::json!({"subscription":"sub-n-1"})).expect("encode");
305        let json = serde_json::to_string(&r).unwrap();
306        // Critical: `result` must appear on the wire, not nested in
307        // a `payload` field — the flatten attribute does the work.
308        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
309        assert!(v.get("result").is_some(), "wire: {v:?}");
310        assert!(v.get("error").is_none());
311        // And the message-level decoder must classify it as Response.
312        let m: RpcMessage = serde_json::from_str(&json).unwrap();
313        assert!(matches!(m, RpcMessage::Response(_)));
314    }
315
316    #[test]
317    fn error_response_decodes_and_round_trips() {
318        let r = RpcResponse::err(
319            "u5",
320            RpcError::new(
321                ErrorKind::Unauthorized,
322                "manifest 'reboot' has user_invokable=false",
323            ),
324        );
325        let json = serde_json::to_string(&r).unwrap();
326        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
327        assert!(v.get("error").is_some(), "wire: {v:?}");
328        assert!(v.get("result").is_none());
329        assert_eq!(v["error"]["code"], -32000);
330
331        // Round-trip preserves the discriminant.
332        let back: RpcResponse = serde_json::from_str(&json).unwrap();
333        match back.payload {
334            RpcResponsePayload::Err { error } => assert_eq!(error.code, -32000),
335            other => panic!("expected Err payload, got {other:?}"),
336        }
337    }
338
339    #[test]
340    fn message_decoder_distinguishes_request_from_response() {
341        // The tricky case: a Request and a Response both carry `id`.
342        // The decoder MUST recognise Response by the presence of
343        // `result` (or `error`), not by id-vs-method, because there
344        // are no required-method requests we send today that lack
345        // params.
346        let request_wire = r#"{"jsonrpc":"2.0","id":"u1","method":"system.ping"}"#;
347        let response_wire = r#"{"jsonrpc":"2.0","id":"u1","result":null}"#;
348
349        match serde_json::from_str::<RpcMessage>(request_wire).unwrap() {
350            RpcMessage::Request(r) => assert_eq!(r.method, "system.ping"),
351            other => panic!("expected Request, got {other:?}"),
352        }
353        match serde_json::from_str::<RpcMessage>(response_wire).unwrap() {
354            RpcMessage::Response(r) => assert_eq!(r.id.as_deref(), Some("u1")),
355            other => panic!("expected Response, got {other:?}"),
356        }
357    }
358
359    #[test]
360    fn void_result_serialises_as_null() {
361        // SPEC §2.12.7's unsubscribe response is `{"result":null}`.
362        let r = RpcResponse::ok("u4", &()).expect("encode");
363        let v = serde_json::to_value(&r).unwrap();
364        assert!(v["result"].is_null(), "wire: {v}");
365    }
366
367    #[test]
368    fn err_anonymous_serialises_id_as_null() {
369        // JSON-RPC 2.0 mandates `id: null` for errors that fire
370        // before the request id can be parsed (ParseError /
371        // InvalidRequest). Wire MUST carry `"id": null` literally,
372        // not omit the field.
373        let r = RpcResponse::err_anonymous(RpcError::bare(ErrorKind::ParseError));
374        let v = serde_json::to_value(&r).unwrap();
375        assert!(v["id"].is_null(), "wire: {v}");
376        assert_eq!(v["error"]["code"], -32700);
377    }
378
379    #[test]
380    fn anonymous_error_response_round_trips() {
381        let wire = r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}"#;
382        let back: RpcResponse = serde_json::from_str(wire).expect("decode");
383        assert!(back.id.is_none(), "decoded id should be None for null wire");
384        match back.payload {
385            RpcResponsePayload::Err { error } => assert_eq!(error.code, -32700),
386            other => panic!("expected Err payload, got {other:?}"),
387        }
388    }
389
390    // --- decode_params helper (Gemini #1 fix) ---
391
392    #[derive(Serialize, Deserialize, Default, Debug, PartialEq)]
393    struct EmptyParams {}
394
395    #[derive(Serialize, Deserialize, Default, Debug, PartialEq)]
396    struct WithDefaults {
397        #[serde(default)]
398        lines: u32,
399    }
400
401    #[test]
402    fn decode_params_treats_null_as_default() {
403        // The reason this helper exists: methods like system.ping
404        // SHOULD omit `params` (envelope wire-form), which decodes
405        // as Value::Null. Direct from_value::<EmptyParams>(Null)
406        // would fail; the helper routes it to P::default().
407        let p: EmptyParams = decode_params(serde_json::Value::Null).expect("null → default");
408        assert_eq!(p, EmptyParams {});
409
410        let p: WithDefaults = decode_params(serde_json::Value::Null).expect("null → default");
411        assert_eq!(p.lines, 0);
412    }
413
414    #[test]
415    fn decode_params_passes_through_explicit_object() {
416        // Non-null inputs go through normal serde — wrong shape
417        // still fails loudly so InvalidParams detection isn't
418        // weakened.
419        let p: WithDefaults =
420            decode_params(serde_json::json!({"lines": 42})).expect("explicit object");
421        assert_eq!(p.lines, 42);
422
423        let err: Result<WithDefaults, _> = decode_params(serde_json::json!(["wrong", "shape"]));
424        assert!(err.is_err(), "non-object input must still fail");
425    }
426}