Skip to main content

protocol_droid/application/jsonrpc/
document.rs

1//! The documents a client writes, and the one it reads back.
2//!
3//! # This is a mirror, not a second encoder
4//!
5//! A stdio client and a stdio server speak the same wire format, and the
6//! server's half of it is
7//! [`reply`](crate::domain::jsonrpc::reply). The two modules cannot be
8//! merged — one is a driving adapter, one is a driven one, and a client crate
9//! that cannot build a reply has no business knowing how — but they are one
10//! format with one shape, and this one is kept deliberately beside its
11//! counterpart: the members are inserted through [`serde_json::Map`], so
12//! `jsonrpc`, `id`, `method` and `params` serialise in the same order they
13//! always have, and a server reading a client request sees what it would see
14//! from any other client.
15//!
16//! # The id is re-escaped, not debug-printed
17//!
18//! [`RequestId::to_json`] is the wrong one-liner here for the same reason
19//! [`reply`](crate::domain::jsonrpc::reply) says so: `Debug` for a string is `escape_debug`, which writes DEL
20//! as `\u{7f}` — Rust syntax, and not a legal JSON escape. So the id is handed
21//! to `serde_json` as a [`Value`], which escapes it the way
22//! [`WireId::to_json`](crate::domain::jsonrpc::WireId::to_json) does, for the
23//! same reason, and a peer that chose an id full of control characters gets its
24//! own id back rather than a parse error against its own request.
25//!
26//! # A reply is matched by id, not taken on faith
27//!
28//! [`reply_result`] is given the id the request went out with and refuses a
29//! frame that answers a different one. A stdio server is free to send
30//! notifications and server-initiated requests on the same stream the
31//! replies arrive on, so the first frame after a request is not
32//! automatically that request's answer — and a client that assumed it was
33//! would hand its caller somebody else's result.
34//!
35//! A frame that is not a reply at all is `RequestError::NotAnObject` or
36//! [`RequestError::MissingResult`], not a silent skip. On stdio, stdout is
37//! the protocol; a peer that puts anything else there has broken it, and the
38//! useful thing to do with that is say so.
39
40use serde_json::{Map, Value};
41
42use crate::domain::RequestId;
43use crate::domain::ir::{IrErrorCode, JsonObject, MethodName};
44
45/// What a peer said in place of the message an error reply is required to
46/// carry. A constant rather than a per-call allocation, and a fixed string
47/// rather than nothing, because a peer that reports a failure without saying
48/// what it was is still reporting a failure.
49const NO_PEER_MESSAGE: &str = "the peer reported an error with no message";
50
51/// The request document, ready to frame.
52///
53/// # Errors
54///
55/// [`RequestError::UnreadableParams`] when the parameters cannot be read back
56/// as JSON, which a validated [`JsonObject`] should not be able to cause. It is
57/// a variant rather than an `unwrap` because the alternative is a claim this
58/// module cannot make, and because a panic on the path that runs for every
59/// request a client ever sends is the wrong place to defend a hypothesis.
60///
61/// # Examples
62///
63/// ```
64/// use protocol_droid::application::jsonrpc::document::request_document;
65/// use protocol_droid::domain::RequestId;
66/// use protocol_droid::domain::ir::{JsonObject, MethodName};
67///
68/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
69/// let method = MethodName::new("tools/list")?;
70/// let document = request_document(&RequestId::number(1), &method, &JsonObject::default())?;
71///
72/// assert_eq!(
73///     document.to_string(),
74///     r#"{"id":1,"jsonrpc":"2.0","method":"tools/list","params":{}}"#,
75/// );
76/// # Ok(())
77/// # }
78/// ```
79pub fn request_document(
80    id: &RequestId,
81    method: &MethodName,
82    params: &JsonObject,
83) -> Result<Value, RequestError> {
84    let mut members = base_members();
85    members.insert("id".to_owned(), id_value(id));
86    members.insert("method".to_owned(), Value::from(method.as_str()));
87    members.insert("params".to_owned(), read_back(params)?);
88    Ok(Value::Object(members))
89}
90
91/// The notification document, ready to frame.
92///
93/// A notification has no id and never gets a reply, so this is the request
94/// document without one rather than a request with a null id — the two are
95/// different on the wire and a peer reads the difference.
96///
97/// # Errors
98///
99/// As [`request_document`].
100///
101/// # Examples
102///
103/// ```
104/// use protocol_droid::application::jsonrpc::document::notification_document;
105/// use protocol_droid::domain::ir::{JsonObject, MethodName};
106///
107/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
108/// let method = MethodName::new("notifications/initialized")?;
109/// let document = notification_document(&method, &JsonObject::default())?;
110///
111/// assert_eq!(
112///     document.to_string(),
113///     r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
114/// );
115/// # Ok(())
116/// # }
117/// ```
118pub fn notification_document(
119    method: &MethodName,
120    params: &JsonObject,
121) -> Result<Value, RequestError> {
122    let mut members = base_members();
123    members.insert("method".to_owned(), Value::from(method.as_str()));
124    members.insert("params".to_owned(), read_back(params)?);
125    Ok(Value::Object(members))
126}
127
128/// The result out of a reply frame, if that frame is this request's answer.
129///
130/// # Errors
131///
132/// [`RequestError::UnreadableFrame`] when the frame is not JSON, or not an
133/// object; [`RequestError::IdMismatch`] when it answers a different request;
134/// [`RequestError::MissingResult`] when it is neither a result nor an error;
135/// [`RequestError::ResultNotAnObject`] when the result is not an object; and
136/// [`RequestError::PeerError`] when the peer reported a failure, which is the
137/// one of these a caller can act on by changing what it asked for.
138///
139/// The id comparison is the reason this takes `expected` rather than reading
140/// the frame's own: a stream carrying notifications as well as replies hands
141/// the caller frames in an order they did not choose.
142///
143/// # Examples
144///
145/// ```
146/// use protocol_droid::application::jsonrpc::document::reply_result;
147/// use protocol_droid::domain::RequestId;
148///
149/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
150/// let frame = r#"{"id":1,"jsonrpc":"2.0","result":{"tools":[]}}"#;
151///
152/// let result = reply_result(frame, &RequestId::number(1))?;
153/// assert_eq!(result.as_str(), r#"{"tools":[]}"#);
154/// # Ok(())
155/// # }
156/// ```
157pub fn reply_result(frame: &str, expected: &RequestId) -> Result<JsonObject, RequestError> {
158    let document: Value = serde_json::from_str(frame).map_err(|_| RequestError::UnreadableFrame)?;
159    let Value::Object(members) = &document else {
160        return Err(RequestError::UnreadableFrame);
161    };
162    match members.get("id") {
163        None => return Err(RequestError::MissingId),
164        Some(found) if found != &id_value(expected) => {
165            return Err(RequestError::IdMismatch {
166                expected: expected.redacted(),
167                found: redact_member(found),
168            });
169        }
170        Some(_) => {}
171    }
172    if let Some(error) = members.get("error") {
173        return Err(peer_error(error));
174    }
175    let Some(result) = members.get("result") else {
176        return Err(RequestError::MissingResult);
177    };
178    JsonObject::from_value(result.clone()).map_err(|_| RequestError::ResultNotAnObject)
179}
180
181/// The members every document on this transport carries.
182fn base_members() -> Map<String, Value> {
183    let mut members = Map::new();
184    members.insert("jsonrpc".to_owned(), Value::from("2.0"));
185    members
186}
187
188/// The id as a `Value` — a number or a string, never a string containing
189/// digits.
190///
191/// The four lines are deliberately identical to the ones in
192/// [`reply`](crate::domain::jsonrpc::reply). They could be shared and
193/// are not, because the two modules sit on opposite sides of the hexagonal
194/// boundary and a shared helper between a primary and a secondary adapter would
195/// be a module with no owner. The duplication is small, it is documented on
196/// both sides, and the alternative — a client that cannot build its own wire
197/// format — is worse.
198fn id_value(id: &RequestId) -> Value {
199    match id {
200        RequestId::Number(value) => Value::from(*value),
201        RequestId::Text(text) => Value::from(text.as_ref()),
202    }
203}
204
205/// Reads a validated object back so it can be nested in a document.
206///
207/// The parse cannot fail — the text was validated when the [`JsonObject`] was
208/// built — and the signature says it can anyway, for the reason
209/// [`RequestError::UnreadableParams`] does.
210fn read_back(params: &JsonObject) -> Result<Value, RequestError> {
211    serde_json::from_str::<Value>(params.as_str()).map_err(|_| RequestError::UnreadableParams)
212}
213
214/// The `error` member, as the failure it is.
215///
216/// An error member with no numeric code is not a JSON-RPC error and is
217/// reported as an unreadable frame rather than as a failure with an invented
218/// code, because a code this client made up is one the caller would then
219/// branch on.
220fn peer_error(error: &Value) -> RequestError {
221    let Value::Object(error) = error else {
222        return RequestError::UnreadableFrame;
223    };
224    let Some(code) = error.get("code").and_then(Value::as_i64) else {
225        return RequestError::UnreadableFrame;
226    };
227    let message = error
228        .get("message")
229        .and_then(Value::as_str)
230        .unwrap_or(NO_PEER_MESSAGE);
231    RequestError::PeerError {
232        code: IrErrorCode::from_code(code),
233        message: message.to_owned(),
234        data: error.get("data").map(Value::to_string),
235    }
236}
237
238/// A label for an id that was not the one we expected, without disclosing it.
239///
240/// Ids are peer-chosen and can carry anything, so a mismatch is reported the
241/// way a log line is: enough to correlate, not enough to disclose.
242fn redact_member(found: &Value) -> String {
243    match found {
244        Value::String(text) => RequestId::text(text).redacted(),
245        other => other.to_string(),
246    }
247}
248
249/// Why a document could not be built, or a frame could not be read as one.
250#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
251pub enum RequestError {
252    /// The parameters could not be read back as JSON.
253    ///
254    /// Unreachable for a [`JsonObject`], which validated the text it stores.
255    #[error("the params could not be read back as JSON")]
256    UnreadableParams,
257
258    /// A frame that was handed to [`reply_result`] is not a JSON object.
259    #[error("the frame is not a JSON object")]
260    UnreadableFrame,
261
262    /// A reply carried no id, so there is nothing to match the request against.
263    #[error("the frame carries no id")]
264    MissingId,
265
266    /// The frame answers a request other than the one being read.
267    #[error("the frame answers id {found}, not {expected}")]
268    IdMismatch {
269        /// The id the request went out with, redacted.
270        expected: String,
271        /// The id the frame carries, redacted.
272        found: String,
273    },
274
275    /// The frame is a JSON-RPC object with neither a result nor an error.
276    #[error("the frame carries neither a result nor an error")]
277    MissingResult,
278
279    /// The result was present but was not a JSON object.
280    #[error("the peer's result is not a JSON object")]
281    ResultNotAnObject,
282
283    /// The peer reported a failure, which is a result of the request and not a
284    /// fault in the transport.
285    #[error("the peer reported {code:?}: {message}")]
286    PeerError {
287        /// The code the peer sent.
288        code: IrErrorCode,
289        /// The message the peer sent.
290        message: String,
291        /// The error's `data` member as JSON text, when it sent one.
292        ///
293        /// Text rather than a `Value` so this type stays `Eq` and cheap to
294        /// clone; `-32022` and `-32021` carry what a caller needs to retry
295        /// (`supported`, `requiredCapabilities`) and drop it at their peril.
296        data: Option<String>,
297    },
298}
299
300#[cfg(test)]
301mod tests {
302    use serde_json::Value;
303
304    use assert_matches::assert_matches;
305
306    use super::{RequestError, notification_document, reply_result, request_document};
307    use crate::domain::RequestId;
308    use crate::domain::ir::{
309        IrErrorCode, JsonObject, JsonObjectError, MethodName, MethodNameError,
310    };
311
312    /// What a `?` in a test here can produce.
313    ///
314    /// The frames under test answer [`RequestError`], but the fixtures feeding
315    /// them are built by the IR's own parsers, which answer
316    /// [`MethodNameError`] and [`JsonObjectError`]. Collapsing the three lets
317    /// every test body be a plain `?` rather than a `map_err` per fixture, and
318    /// carrying the payloads means a failing test prints the parser's own
319    /// account of what it refused instead of a bare variant name.
320    #[derive(Debug)]
321    #[expect(dead_code, reason = "the payloads are read by the derived Debug")]
322    enum Failure {
323        /// A frame this module built or read did not survive the round trip.
324        Frame(RequestError),
325        /// A method name the IR would not admit.
326        Method(MethodNameError),
327        /// A params object the IR would not admit.
328        Params(JsonObjectError),
329    }
330
331    impl From<RequestError> for Failure {
332        fn from(error: RequestError) -> Self {
333            Self::Frame(error)
334        }
335    }
336
337    impl From<MethodNameError> for Failure {
338        fn from(error: MethodNameError) -> Self {
339            Self::Method(error)
340        }
341    }
342
343    impl From<JsonObjectError> for Failure {
344        fn from(error: JsonObjectError) -> Self {
345            Self::Params(error)
346        }
347    }
348
349    /// The four members a request carries, in the order they serialise, and
350    /// the same document any other client would put on the wire.
351    #[test]
352    fn a_request_carries_the_jsonrpc_member_id_method_and_params() -> Result<(), Failure> {
353        let method = MethodName::new("tools/list")?;
354        let params = JsonObject::new(r#"{"cursor":"c"}"#)?;
355
356        let document = request_document(&RequestId::number(7), &method, &params)?.to_string();
357
358        assert_eq!(
359            document,
360            r#"{"id":7,"jsonrpc":"2.0","method":"tools/list","params":{"cursor":"c"}}"#
361        );
362        Ok(())
363    }
364
365    /// A notification is a request without an id rather than a request with a
366    /// null one. A peer reads those two differently and this is the difference.
367    #[test]
368    fn a_notification_has_no_id() -> Result<(), Failure> {
369        let method = MethodName::new("notifications/initialized")?;
370
371        let document = notification_document(&method, &JsonObject::default())?.to_string();
372
373        assert!(!document.contains("\"id\""), "{document}");
374        Ok(())
375    }
376
377    /// A string id stays a JSON string. `format!("{id:?}")` would too, but
378    /// `Debug` is `escape_debug`, and a DEL in the id would be written as
379    /// `\u{7f}` — Rust syntax, and not a legal JSON escape, so the peer would
380    /// answer with a parse error against its own id.
381    #[test]
382    fn a_string_id_is_escaped_as_json() -> Result<(), Failure> {
383        let method = MethodName::new("tools/list")?;
384        let id = RequestId::text("a\u{7f}b\"c");
385
386        let document = request_document(&id, &method, &JsonObject::default())?.to_string();
387
388        assert!(!document.contains("u{7f}"), "{document}");
389        let read_back: Value =
390            serde_json::from_str(&document).map_err(|_| RequestError::UnreadableFrame)?;
391        assert_eq!(read_back.get("id"), Some(&Value::from("a\u{7f}b\"c")));
392        Ok(())
393    }
394
395    /// The result comes back as the object the peer sent, not as the whole
396    /// envelope: a caller asking `tools/list` wants the list.
397    #[test]
398    fn a_result_is_read_back_as_an_object() -> Result<(), Failure> {
399        let frame = r#"{"id":1,"jsonrpc":"2.0","result":{"tools":[]}}"#;
400
401        let result = reply_result(frame, &RequestId::number(1))?;
402
403        assert_eq!(result.as_str(), r#"{"tools":[]}"#);
404        Ok(())
405    }
406
407    /// A stream carrying notifications as well as replies hands the caller
408    /// frames in an order they did not choose, so a frame that answers a
409    /// different request is refused rather than handed over as this one's
410    /// answer.
411    #[test]
412    fn a_reply_for_another_request_is_refused() {
413        let frame = r#"{"id":2,"jsonrpc":"2.0","result":{}}"#;
414
415        assert_matches!(
416            reply_result(frame, &RequestId::number(1)),
417            Err(RequestError::IdMismatch { .. })
418        );
419    }
420
421    /// A text id and the number that spells it are different ids, which is the
422    /// point of the comparison being on the parsed value rather than on text.
423    #[test]
424    fn a_text_id_is_not_its_numeric_spelling() {
425        let frame = r#"{"id":7,"jsonrpc":"2.0","result":{}}"#;
426
427        assert_matches!(
428            reply_result(frame, &RequestId::text("7")),
429            Err(RequestError::IdMismatch { .. })
430        );
431    }
432
433    /// An error reply is a result of the request, and it arrives as the peer's
434    /// code and message rather than as a transport failure the caller cannot
435    /// tell from a broken pipe.
436    #[test]
437    fn an_error_reply_carries_the_code_the_peer_sent() {
438        let frame =
439            r#"{"error":{"code":-32601,"message":"no such method"},"id":1,"jsonrpc":"2.0"}"#;
440
441        assert_matches!(
442            reply_result(frame, &RequestId::number(1)),
443            Err(RequestError::PeerError {
444                code: IrErrorCode::MethodNotFound,
445                ..
446            })
447        );
448    }
449
450    /// A frame that is neither an object nor a result is a broken protocol,
451    /// and saying so beats handing the caller something they have to guess
452    /// about.
453    #[test]
454    fn a_frame_that_is_not_an_object_is_refused() {
455        assert_matches!(
456            reply_result("not json at all", &RequestId::number(1)),
457            Err(RequestError::UnreadableFrame)
458        );
459        assert_matches!(
460            reply_result("[1,2,3]", &RequestId::number(1)),
461            Err(RequestError::UnreadableFrame)
462        );
463    }
464
465    /// A frame with neither member is not an answer, and reading a `null`
466    /// result out of it would hand the caller a value the peer never sent.
467    #[test]
468    fn a_frame_with_neither_result_nor_error_is_refused() {
469        let frame = r#"{"id":1,"jsonrpc":"2.0"}"#;
470
471        assert_matches!(
472            reply_result(frame, &RequestId::number(1)),
473            Err(RequestError::MissingResult)
474        );
475    }
476
477    /// A reply whose result is not an object cannot become a [`JsonObject`],
478    /// and the port this feeds hands one back — so the mismatch is reported
479    /// rather than coerced.
480    #[test]
481    fn a_result_that_is_not_an_object_is_refused() {
482        let frame = r#"{"id":1,"jsonrpc":"2.0","result":[1,2,3]}"#;
483
484        assert_matches!(
485            reply_result(frame, &RequestId::number(1)),
486            Err(RequestError::ResultNotAnObject)
487        );
488    }
489}