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}