Skip to main content

protocol_droid/domain/ir/
json.rs

1//! `JsonObject` — a validated JSON object, and the only way arbitrary JSON
2//! enters the IR.
3//!
4//! # Why a newtype rather than `serde_json::Value`
5//!
6//! The domain layer takes no `serde_json::Value` in its signatures. That rule
7//! is what keeps the domain free of the serializer, and it is worth restating
8//! *why* it exists: a `Value` in a signature means every layer above can hand
9//! the domain an arbitrary tree, so every domain function has to re-validate it
10//! and the type system stops saying where the shape was checked. `JsonObject`
11//! inverts that — by the time a caller has one, the value is known to be a JSON
12//! object, checked once, at the boundary.
13//!
14//! # What it stores, and why
15//!
16//! The original text, not a re-serialised tree. Three reasons:
17//!
18//! - **Losslessness.** Round-tripping through a typed tree can reorder keys and
19//!   change number formatting, and the bridge's contract is that a value it
20//!   did not understand comes out the other side byte-identical. Storing the
21//!   text makes that the default rather than something to test for.
22//! - **Forward compatibility.** A JSON schema in `2026-07-28` may nest shapes
23//!   this build has never heard of. Storing text means an unknown shape is not
24//!   a problem, which is the same reason the rest of the IR carries an
25//!   extension bag.
26//! - **The hot path.** Construction parses once to validate and then holds a
27//!   `&str`. Nothing downstream re-parses, which is what the no-double-parse
28//!   rule is protecting.
29//!
30//! The cost is that you cannot read a field out of a `JsonObject` without
31//! parsing. That is the right trade: callers who need structure go through the
32//! codecs, which parse at the transport boundary, and everything else treats
33//! the object as an opaque value to forward.
34
35use std::fmt;
36
37/// A JSON object, validated on construction.
38#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
39pub struct JsonObject {
40    text: String,
41}
42
43impl JsonObject {
44    /// Validates `text` as a JSON *object*.
45    ///
46    /// # Errors
47    ///
48    /// [`JsonObjectError::InvalidJson`] when the text is not well-formed JSON,
49    /// and [`JsonObjectError::NotAnObject`] when it parses but is not an
50    /// object — an array or a bare scalar is a different thing, and `2025-06-18`
51    /// `structuredContent` is specifically an object.
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use protocol_droid::domain::ir::JsonObject;
57    ///
58    /// let json = JsonObject::new(r#"{"temperature": 0.7}"#)?;
59    /// assert_eq!(json.as_str(), r#"{"temperature": 0.7}"#);
60    /// # Ok::<(), protocol_droid::domain::ir::JsonObjectError>(())
61    /// ```
62    pub fn new(text: impl Into<String>) -> Result<Self, JsonObjectError> {
63        let text = text.into();
64        // `serde_json` is used here and nowhere else in the IR: this is the
65        // boundary check, and its output never escapes.
66        match serde_json::from_str::<serde_json::Value>(&text) {
67            Ok(serde_json::Value::Object(_)) => Ok(Self { text }),
68            Ok(_) => Err(JsonObjectError::NotAnObject),
69            Err(error) => Err(JsonObjectError::InvalidJson {
70                offset: error.column(),
71            }),
72        }
73    }
74
75    /// The text, exactly as it was validated.
76    #[must_use]
77    pub fn as_str(&self) -> &str {
78        &self.text
79    }
80
81    /// Validates a `serde_json::Value` and stores it.
82    ///
83    /// A tool handler's result is built from data — the argument echoed back,
84    /// a computed value, an error message composed from a failure. Doing that
85    /// with `new` means `format!`ing a JSON document by hand, and a handler
86    /// that interpolates a peer's string into a JSON literal without escaping
87    /// it is an injection bug. This is the constructor that makes that mistake
88    /// unavailable.
89    ///
90    /// The value is serialised rather than kept, so a `JsonObject` is still one
91    /// validated `String` and still owns no borrowed peer's data.
92    ///
93    /// # Errors
94    ///
95    /// [`JsonObjectError::NotAnObject`] when the value is not an object.
96    /// [`JsonObjectError::InvalidJson`] when the value cannot be serialised,
97    /// which for a `Value` cannot happen — but the error type is the honest
98    /// one, and inventing a second error for an unreachable case would be a
99    /// promise this function cannot keep.
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use protocol_droid::domain::ir::JsonObject;
105    ///
106    /// let value = serde_json::json!({ "echoed": "quotes \" and \\ survive" });
107    /// let json = JsonObject::from_value(value)?;
108    /// assert_eq!(json.as_str(), r#"{"echoed":"quotes \" and \\ survive"}"#);
109    /// # Ok::<(), protocol_droid::domain::ir::JsonObjectError>(())
110    /// ```
111    #[expect(
112        clippy::needless_pass_by_value,
113        reason = "the caller hands over a Value it built and does not want back. \
114                  Almost every call site is `JsonObject::from_value(json!({..}))`, \
115                  and by reference would force each one to bind a temporary \
116                  first — the lint is right that the body only reads it, and \
117                  taking it is still the signature a caller wants."
118    )]
119    pub fn from_value(value: serde_json::Value) -> Result<Self, JsonObjectError> {
120        if !value.is_object() {
121            return Err(JsonObjectError::NotAnObject);
122        }
123        serde_json::to_string(&value)
124            .map(|text| Self { text })
125            .map_err(|error| JsonObjectError::InvalidJson {
126                offset: error.column(),
127            })
128    }
129
130    /// Consumes the object, yielding its text.
131    #[must_use]
132    pub fn into_string(self) -> String {
133        self.text
134    }
135}
136
137impl Default for JsonObject {
138    /// The empty object, `{}`.
139    ///
140    /// A tool handler returns a `JsonObject` and cannot return a `Result`,
141    /// because MCP reports a tool failure as a successful call carrying
142    /// `isError: true`. So a handler that cannot build the result it meant to
143    /// build needs somewhere to fall back to, and it needs to be infallible.
144    /// `{}` is that place: it is a valid document under every revision of the
145    /// protocol, so no fallback can itself be a parse error.
146    ///
147    /// Not `""`, which is the default a `String`-backed type reaches for and
148    /// which is not a JSON object at all.
149    fn default() -> Self {
150        Self {
151            text: String::from("{}"),
152        }
153    }
154}
155
156impl fmt::Debug for JsonObject {
157    /// Prints the text, since a `JsonObject` has no other meaningful state and
158    /// the text is what a caller debugging a lossless round trip needs to see.
159    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160        formatter
161            .debug_tuple("JsonObject")
162            .field(&self.text)
163            .finish()
164    }
165}
166
167impl fmt::Display for JsonObject {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        formatter.write_str(&self.text)
170    }
171}
172
173/// Why a string is not a [`JsonObject`].
174#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
175pub enum JsonObjectError {
176    /// The text is not well-formed JSON.
177    #[error("not well-formed JSON, first problem at column {offset}")]
178    InvalidJson {
179        /// The column the parser stopped at.
180        offset: usize,
181    },
182
183    /// The text parses, but as something other than an object.
184    #[error("parsed as JSON, but not as an object")]
185    NotAnObject,
186}
187
188#[cfg(test)]
189mod tests {
190    #![expect(
191        clippy::unwrap_used,
192        reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
193    )]
194
195    use super::{JsonObject, JsonObjectError};
196
197    #[test]
198    fn a_well_formed_object_is_accepted() {
199        let json = JsonObject::new(r#"{"a":1}"#).unwrap();
200        assert_eq!(json.as_str(), r#"{"a":1}"#);
201        // The empty object is an object.
202        assert!(JsonObject::new("{}").is_ok());
203        // Nesting is fine, and is exactly what a structured result is.
204        assert!(JsonObject::new(r#"{"a":{"b":[{"c":null}]}}"#).is_ok());
205    }
206
207    /// Losslessness is the whole reason this stores text, so it is asserted
208    /// rather than assumed.
209    #[test]
210    fn the_text_survives_unchanged() {
211        // Whitespace, key order and number formatting all preserved exactly.
212        for text in [
213            r#"{ "b" : 2 , "a" : 1 }"#,
214            r#"{"n":1.0}"#,
215            r#"{"n":1e10}"#,
216            r#"{"s":"üñí çödé 😀"}"#,
217            r#"{"escaped":"line\nbreak"}"#,
218        ] {
219            let json = JsonObject::new(text).unwrap();
220            assert_eq!(json.as_str(), text, "{text}");
221            assert_eq!(json.clone().into_string(), text, "{text}");
222        }
223    }
224
225    #[test]
226    fn a_non_object_is_rejected() {
227        for text in ["[]", r#""a string""#, "1", "true", "null"] {
228            assert_eq!(
229                JsonObject::new(text).unwrap_err(),
230                JsonObjectError::NotAnObject,
231                "{text}",
232            );
233        }
234    }
235
236    #[test]
237    fn malformed_json_is_rejected() {
238        for text in ["{", r#"{"a":}"#, "", "{'a':1}", r#"{"a":1,}"#] {
239            assert!(
240                matches!(
241                    JsonObject::new(text),
242                    Err(JsonObjectError::InvalidJson { .. })
243                ),
244                "{text} should be invalid",
245            );
246        }
247    }
248
249    #[test]
250    fn equality_and_hashing_use_the_text() {
251        use std::collections::HashSet;
252        let a = JsonObject::new(r#"{"x":1}"#).unwrap();
253        let b = JsonObject::new(r#"{"x":1}"#).unwrap();
254        let c = JsonObject::new(r#"{ "x" : 1 }"#).unwrap();
255        // Semantically equal but textually different, so *not* equal. This is
256        // the deliberate consequence of storing text: equality is textual.
257        assert_eq!(a, b);
258        assert_ne!(a, c);
259        let mut set = HashSet::new();
260        assert!(set.insert(a));
261        assert!(!set.insert(b));
262        assert!(set.insert(c));
263    }
264
265    #[test]
266    fn debug_and_display_show_the_text() {
267        let json = JsonObject::new(r#"{"k":"v"}"#).unwrap();
268        assert_eq!(json.to_string(), r#"{"k":"v"}"#);
269        // Debug quotes the text, so it is escaped — but it is still there, and
270        // naming the type is what makes a log line readable.
271        let debug = format!("{json:?}");
272        assert!(debug.starts_with("JsonObject("), "{debug}");
273        assert!(debug.contains('k'), "{debug}");
274    }
275}