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, ¶ms)?.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}