Skip to main content

protocol_droid/domain/jsonrpc/
reply.rs

1//! The reply document, assembled from a dispatched result and a request id.
2//!
3//! # Why this is not a codec
4//!
5//! [`Codec`](crate::domain::ir::Codec) lifts and lowers a
6//! [`ToolResult`](crate::domain::ir::ToolResult) and nothing else, so nothing
7//! in the crate yet turns a `Dispatcher` result plus a request id into the
8//! document a peer reads. Rather than reach past it, this module writes that
9//! document and says so where a reader will find it. A request-side codec
10//! landing later replaces these four functions and nothing else, because the
11//! serve loop knows no method names and no revision: it hands a result and an
12//! id to this module and writes the bytes it gets back.
13//!
14//! # Nothing is spliced, and that is the point
15//!
16//! The obvious implementation is string concatenation — take
17//! [`JsonObject::as_str`] and write it between two members. It is faster and
18//! it is **wrong**, and the reason is worth stating because the mistake is
19//! invisible in review and on the happy path.
20//!
21//! [`JsonObject::new`] validates its text and then stores it *verbatim*. JSON
22//! permits whitespace between tokens, so `JsonObject::new("{\n\"a\":1\n}")`
23//! succeeds and `as_str` hands back two raw newlines. Splicing that into a
24//! newline-delimited frame splits one reply into two lines, and a peer reading
25//! them sees two malformed frames instead of an answer. No test of the handler
26//! would notice, because the corruption happens afterwards, in the transport.
27//!
28//! So every document here is built as a [`serde_json::Value`] and serialised
29//! once. `Value`'s writer escapes control characters rather than passing them
30//! through, which makes a raw newline in a reply structurally impossible
31//! instead of merely unlikely — and it is one code path, so there is no second
32//! serialiser to drift from the first.
33//!
34//! # The id is re-escaped, not debug-printed
35//!
36//! [`WireId::to_json`] is the right answer for a caller splicing an id, and
37//! `format!("{id:?}")` is the wrong one in a way this crate has already paid
38//! for: `Debug` for a string is `escape_debug`, which writes DEL as `\u{7f}`.
39//! That is Rust syntax and not JSON, so the peer would see a parse error rather
40//! than its own id. Both builders below hand the id to `serde_json` as a
41//! `Value`, which escapes it the same way `to_json` does, for the same reason.
42//!
43//! # What a parse failure is answered with
44//!
45//! A frame that is not JSON has no id — there was never one to read — so
46//! [`unidentified_error_reply`] writes `"id": null`, which is what the
47//! specification requires and the only honest answer available. A caller that
48//! invents an id for a frame it could not parse is telling the peer something
49//! about a message the peer never successfully sent.
50
51use serde_json::{Map, Value};
52
53use crate::domain::ir::{IrErrorCode, JsonObject};
54use crate::domain::jsonrpc::WireId;
55
56/// Why a reply could not be assembled.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
58pub enum ReplyError {
59    /// The result could not be read back as JSON.
60    ///
61    /// Unreachable for a [`JsonObject`], which validated the text it stores.
62    /// It is a variant rather than an `unwrap` because the alternative is a
63    /// claim this module cannot make: a future constructor that stored
64    /// something it had not parsed would turn that `unwrap` into a panic on a
65    /// path that runs for every reply a server ever sends.
66    #[error("the result could not be read back as JSON")]
67    UnreadableResult,
68}
69
70/// The members every reply carries before its own.
71fn base_members() -> Map<String, Value> {
72    let mut members = Map::new();
73    members.insert("jsonrpc".to_owned(), Value::from("2.0"));
74    members
75}
76
77/// [`base_members`] plus the id the answer belongs to.
78fn reply_members(id: &WireId<'_>) -> Map<String, Value> {
79    let mut members = base_members();
80    members.insert("id".to_owned(), id_value(id));
81    members
82}
83
84/// The id as a `Value` — a number or a string, never a string containing
85/// digits.
86fn id_value(id: &WireId<'_>) -> Value {
87    match id {
88        WireId::Number(value) => Value::from(*value),
89        WireId::Text(text) => Value::from(text.as_ref()),
90    }
91}
92
93/// The `error` member.
94fn error_member(code: IrErrorCode, message: &str) -> Value {
95    error_member_with_data(code, message, None)
96}
97
98/// The `error` member, with the `data` a code may require.
99///
100/// `None` adds nothing at all rather than adding `"data": null`: the JSON-RPC
101/// specification marks `data` optional, and a peer that branches on its
102/// presence would see a member that says nothing.
103fn error_member_with_data(code: IrErrorCode, message: &str, data: Option<&Value>) -> Value {
104    let mut error = Map::new();
105    error.insert("code".to_owned(), Value::from(code.code()));
106    error.insert("message".to_owned(), Value::from(message));
107    if let Some(data) = data {
108        error.insert("data".to_owned(), data.clone());
109    }
110    Value::Object(error)
111}
112
113/// Reads a dispatched result back so it can be nested in a document.
114///
115/// The parse cannot fail — the text was validated when the `JsonObject` was
116/// built — but the signature says it can, because a function that can fail and
117/// claims it cannot is a promise this crate does not keep. The caller answers a
118/// genuine failure with an internal error rather than guessing.
119fn read_back(result: &JsonObject) -> Result<Value, ReplyError> {
120    serde_json::from_str::<Value>(result.as_str()).map_err(|_| ReplyError::UnreadableResult)
121}
122
123/// A successful reply to the request with this id.
124///
125/// # Errors
126///
127/// [`ReplyError::UnreadableResult`] when the result cannot be read back as
128/// JSON, which a validated [`JsonObject`] should not be able to cause.
129///
130/// # Examples
131///
132/// ```
133/// use protocol_droid::domain::jsonrpc::reply::result_reply;
134/// use protocol_droid::domain::ir::JsonObject;
135/// use protocol_droid::domain::jsonrpc::WireId;
136///
137/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
138/// let id = WireId::text("r1");
139/// let result = JsonObject::new(r#"{"tools":[]}"#)?;
140///
141/// let document = result_reply(&id, &result)?.to_string();
142/// assert_eq!(
143///     document,
144///     r#"{"id":"r1","jsonrpc":"2.0","result":{"tools":[]}}"#,
145/// );
146/// # Ok(())
147/// # }
148/// ```
149pub fn result_reply(id: &WireId<'_>, result: &JsonObject) -> Result<Value, ReplyError> {
150    let mut members = reply_members(id);
151    members.insert("result".to_owned(), read_back(result)?);
152    Ok(Value::Object(members))
153}
154
155/// A failed reply to the request with this id.
156///
157/// # Examples
158///
159/// ```
160/// use protocol_droid::domain::jsonrpc::reply::error_reply;
161/// use protocol_droid::domain::ir::IrErrorCode;
162/// use protocol_droid::domain::jsonrpc::WireId;
163///
164/// let id = WireId::Number(7);
165/// let document = error_reply(&id, IrErrorCode::MethodNotFound, "no such method").to_string();
166///
167/// assert_eq!(
168///     document,
169///     r#"{"error":{"code":-32601,"message":"no such method"},"id":7,"jsonrpc":"2.0"}"#,
170/// );
171/// ```
172#[must_use]
173pub fn error_reply(id: &WireId<'_>, code: IrErrorCode, message: &str) -> Value {
174    let mut members = reply_members(id);
175    members.insert("error".to_owned(), error_member(code, message));
176    Value::Object(members)
177}
178
179/// A failed reply carrying the structured `data` some codes require.
180///
181/// SEP-2575's `-32022` is the case this exists for: its `data` names the
182/// revision the client asked for and every revision this server speaks, and a
183/// client cannot retry correctly without both. `error_reply` cannot express
184/// that, so this is a second function rather than an optional argument on the
185/// first — every other error in the crate has no data, and a parameter they
186/// would all pass `None` to is a parameter nobody reads.
187///
188/// # Examples
189///
190/// ```
191/// use protocol_droid::domain::jsonrpc::reply::error_reply_with_data;
192/// use protocol_droid::domain::ir::{IrErrorCode, JsonObject};
193/// use protocol_droid::domain::jsonrpc::WireId;
194///
195/// let data = JsonObject::new(r#"{"requested":"2027-01-01","supported":["2026-07-28"]}"#)?;
196/// let document =
197///     error_reply_with_data(&WireId::text("r1"), IrErrorCode::UNSUPPORTED_PROTOCOL_VERSION,
198///         "unsupported version", &data).to_string();
199///
200/// assert!(document.contains(r#""code":-32022"#));
201/// assert!(document.contains(r#""supported":["2026-07-28"]"#));
202/// # Ok::<(), protocol_droid::domain::ir::JsonObjectError>(())
203/// ```
204#[must_use]
205pub fn error_reply_with_data(
206    id: &WireId<'_>,
207    code: IrErrorCode,
208    message: &str,
209    data: &JsonObject,
210) -> Value {
211    let Ok(data) = read_back(data) else {
212        // A `JsonObject` was validated when it was built, so this branch is
213        // unreachable in practice. Answering with the data-less error is worse
214        // than answering with something a client can act on, so the fallback
215        // is the one that still carries the code.
216        return error_reply(id, code, message);
217    };
218    let mut members = reply_members(id);
219    members.insert(
220        "error".to_owned(),
221        error_member_with_data(code, message, Some(&data)),
222    );
223    Value::Object(members)
224}
225
226/// A failed reply to a frame that never had an id to answer.
227///
228/// # Examples
229///
230/// ```
231/// use protocol_droid::domain::jsonrpc::reply::unidentified_error_reply;
232/// use protocol_droid::domain::ir::IrErrorCode;
233///
234/// let document =
235///     unidentified_error_reply(IrErrorCode::ParseError, "the frame is not valid JSON");
236///
237/// assert_eq!(document.get("id"), Some(&serde_json::Value::Null));
238/// ```
239#[must_use]
240pub fn unidentified_error_reply(code: IrErrorCode, message: &str) -> Value {
241    let mut members = base_members();
242    members.insert("id".to_owned(), Value::Null);
243    members.insert("error".to_owned(), error_member(code, message));
244    Value::Object(members)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::{ReplyError, base_members, error_reply, result_reply, unidentified_error_reply};
250    use crate::domain::ir::{IrErrorCode, JsonObject};
251    use crate::domain::jsonrpc::WireId;
252
253    /// What a test here can fail with, so a fallible call still propagates
254    /// instead of unwrapping.
255    #[derive(Debug)]
256    enum Failure {
257        /// A fixture was not a JSON object.
258        Object(crate::domain::ir::JsonObjectError),
259        /// A builder refused a value it should have accepted.
260        Reply(ReplyError),
261        /// A document this module produced did not read back as JSON, which a
262        /// `serde_json::Value` should make impossible.
263        Json(serde_json::Error),
264    }
265
266    impl std::fmt::Display for Failure {
267        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268            match self {
269                Self::Object(error) => write!(f, "bad fixture: {error}"),
270                Self::Reply(error) => write!(f, "reply refused: {error}"),
271                Self::Json(error) => write!(f, "a reply did not parse: {error}"),
272            }
273        }
274    }
275
276    impl std::error::Error for Failure {}
277
278    impl From<crate::domain::ir::JsonObjectError> for Failure {
279        fn from(error: crate::domain::ir::JsonObjectError) -> Self {
280            Self::Object(error)
281        }
282    }
283
284    impl From<ReplyError> for Failure {
285        fn from(error: ReplyError) -> Self {
286            Self::Reply(error)
287        }
288    }
289
290    impl From<serde_json::Error> for Failure {
291        fn from(error: serde_json::Error) -> Self {
292            Self::Json(error)
293        }
294    }
295
296    /// A successful reply carries the id, the version, and the result.
297    ///
298    /// Byte-exact on purpose. This is the byte layer, and the member order is
299    /// `serde_json::Map`'s — a `BTreeMap`, since `preserve_order` is off — so
300    /// the order is deterministic and a change to it would arrive as a
301    /// dependency change worth noticing, not as a stylistic preference.
302    #[test]
303    fn a_result_reply_carries_all_three() -> Result<(), Failure> {
304        let id = WireId::text("r1");
305        let result = JsonObject::new(r#"{"content":[]}"#)?;
306
307        let document = result_reply(&id, &result)?.to_string();
308
309        assert_eq!(
310            document,
311            r#"{"id":"r1","jsonrpc":"2.0","result":{"content":[]}}"#,
312        );
313        Ok(())
314    }
315
316    /// The id survives a round trip in both arms. A reply that quoted the
317    /// number `7` as `"7"` would still parse here, and would be the wrong
318    /// answer to a peer that is matching on type.
319    #[test]
320    fn the_id_keeps_its_type() -> Result<(), Failure> {
321        let numeric = result_reply(&WireId::Number(7), &JsonObject::default())?.to_string();
322        let textual = result_reply(&WireId::text("7"), &JsonObject::default())?.to_string();
323
324        assert!(numeric.contains(r#""id":7"#), "{numeric}");
325        assert!(textual.contains(r#""id":"7""#), "{textual}");
326        Ok(())
327    }
328
329    /// An id comes back as JSON, not as Rust syntax. `format!("{id:?}")` is the
330    /// wrong one-liner: `Debug` for `str` is `escape_debug`, which writes a
331    /// control character as `\u{7f}` — Rust syntax, and not a legal JSON
332    /// escape, which is `\u007f`.
333    ///
334    /// The claim is asserted as a round trip rather than as a byte comparison,
335    /// because what is being claimed is that the id survives as valid JSON, not
336    /// that this crate picks a particular escape spelling. DEL is a legal raw
337    /// byte inside a JSON string — only U+0000–U+001F, `"` and `\` must be
338    /// escaped — and `serde_json` accordingly passes it through, so a test
339    /// demanding `\u007f` would be testing that crate's escape table rather
340    /// than this module. The quote in the fixture is the member that genuinely
341    /// has to be escaped, and the round trip is what proves it was.
342    #[test]
343    fn an_escaped_id_is_escaped_as_json() -> Result<(), Failure> {
344        let id = WireId::text("a\u{7f}b\"c");
345
346        let document = result_reply(&id, &JsonObject::default())?.to_string();
347
348        // The Rust spelling is the bug this test exists to keep out.
349        assert!(!document.contains("u{7f}"), "{document}");
350        let read_back = serde_json::from_str::<serde_json::Value>(&document)?;
351        assert_eq!(read_back.get("id"), Some(&serde_json::json!("a\u{7f}b\"c")));
352        Ok(())
353    }
354
355    /// A result holding raw newlines is serialised with them escaped, so the
356    /// document cannot be split across two frames of a newline-delimited
357    /// stream. `JsonObject::new` stores its text verbatim, so this is reachable
358    /// from any handler that pretty-prints.
359    #[test]
360    fn a_pretty_printed_result_stays_on_one_line() -> Result<(), Failure> {
361        let result = JsonObject::new("{\n  \"a\": 1\n}")?;
362
363        let document = result_reply(&WireId::Number(1), &result)?.to_string();
364
365        assert!(!document.contains('\n'), "{document}");
366        Ok(())
367    }
368
369    /// An error reply carries the numeric code, not the variant name.
370    ///
371    /// `code` and `message` are members of the `error` **object**, not of the
372    /// response around it. Every revision's schema says so: `JSONRPCError` in
373    /// 2024-11-05 through 2025-06-18 carries an `error` object requiring
374    /// `code` and `message`, and `JSONRPCErrorResponse` in 2025-11-25 and
375    /// 2026-07-28 requires `error` and `jsonrpc`, with `error` a `$ref` to
376    /// `Error`. A response with `message` at the top level is not a JSON-RPC
377    /// error response at all.
378    ///
379    /// The bytes are pinned because this is a byte layer and the order is
380    /// deterministic rather than incidental: `serde_json::Map` is a `BTreeMap`
381    /// here (`preserve_order` is not enabled), so members serialise
382    /// alphabetically and a reorder would mean a dependency change, not a
383    /// stylistic one.
384    #[test]
385    fn an_error_reply_carries_the_numeric_code() {
386        let document =
387            error_reply(&WireId::Number(1), IrErrorCode::InvalidParams, "nope").to_string();
388
389        assert_eq!(
390            document,
391            r#"{"error":{"code":-32602,"message":"nope"},"id":1,"jsonrpc":"2.0"}"#,
392        );
393    }
394
395    /// A message that would break the log line it is quoted in is escaped.
396    #[test]
397    fn an_error_message_is_escaped() {
398        let document = error_reply(
399            &WireId::Number(1),
400            IrErrorCode::InternalError,
401            "line one\nline two",
402        )
403        .to_string();
404
405        assert!(!document.contains('\n'), "{document}");
406        assert!(document.contains(r"\n"), "{document}");
407    }
408
409    /// A frame with no id is answered with `null`, because there was no id to
410    /// read rather than because the id was the word "null".
411    #[test]
412    fn an_unidentified_error_carries_a_null_id() {
413        let document =
414            unidentified_error_reply(IrErrorCode::ParseError, "the frame is not valid JSON");
415
416        assert_eq!(document.get("id"), Some(&serde_json::Value::Null));
417    }
418
419    /// The two builders share one set of base members, so a future change to
420    /// them cannot land on the success path and miss the failure path.
421    #[test]
422    fn both_builders_start_from_the_same_members() {
423        let base = base_members();
424        let identified = error_reply(&WireId::Number(1), IrErrorCode::InternalError, "x");
425        let unidentified = unidentified_error_reply(IrErrorCode::InternalError, "x");
426
427        for document in [&identified, &unidentified] {
428            assert_eq!(document.get("jsonrpc"), base.get("jsonrpc"));
429        }
430    }
431}