1use serde::{Serialize, de::DeserializeOwned};
4use serde_json::Value;
5use serde_path_to_error::Segment;
6
7use super::ClientError;
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ResponseDecodeKind {
12 InvalidJson,
13 UnknownField,
14 MissingField,
15 InvalidType,
16 InvalidValue,
17}
18
19impl ResponseDecodeKind {
20 fn description(self) -> &'static str {
21 match self {
22 Self::InvalidJson => "invalid JSON",
23 Self::UnknownField => "unknown field",
24 Self::MissingField => "missing required field",
25 Self::InvalidType => "invalid field type",
26 Self::InvalidValue => "invalid field value",
27 }
28 }
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct ResponseDiagnostic {
33 pub path: String,
36 pub kind: ResponseDecodeKind,
37}
38
39fn protocol_error(
40 operation: &str,
41 diagnostic: ResponseDiagnostic,
42 success_status_received: bool,
43) -> ClientError {
44 let location = if diagnostic.path.is_empty() {
45 "the response root"
46 } else {
47 &diagnostic.path
48 };
49 ClientError::Protocol {
50 message: format!(
51 "the {operation} response contains {} at {location}",
52 diagnostic.kind.description()
53 ),
54 diagnostic: Some(diagnostic),
55 success_status_received,
56 }
57}
58
59pub(super) fn parse_json(body: &[u8], operation: &str) -> Result<Value, ClientError> {
60 serde_json::from_slice(body).map_err(|_| {
61 protocol_error(
62 operation,
63 ResponseDiagnostic {
64 path: String::new(),
65 kind: ResponseDecodeKind::InvalidJson,
66 },
67 true,
68 )
69 })
70}
71
72pub(super) fn decode_json<T: DeserializeOwned>(
73 body: &[u8],
74 operation: &str,
75) -> Result<T, ClientError> {
76 let mut deserializer = serde_json::Deserializer::from_slice(body);
77 let value = serde_path_to_error::deserialize(&mut deserializer)
78 .map_err(|error| decoding_error(error, operation, true))?;
79 deserializer.end().map_err(|_| {
80 protocol_error(
81 operation,
82 ResponseDiagnostic {
83 path: String::new(),
84 kind: ResponseDecodeKind::InvalidJson,
85 },
86 true,
87 )
88 })?;
89 Ok(value)
90}
91
92pub(super) fn decode_value<T: DeserializeOwned>(
93 value: Value,
94 operation: &str,
95 success_status_received: bool,
96) -> Result<T, ClientError> {
97 serde_path_to_error::deserialize(value)
98 .map_err(|error| decoding_error(error, operation, success_status_received))
99}
100
101fn decoding_error(
102 error: serde_path_to_error::Error<serde_json::Error>,
103 operation: &str,
104 success_status_received: bool,
105) -> ClientError {
106 if error.inner().is_syntax() || error.inner().is_eof() {
107 return protocol_error(
108 operation,
109 ResponseDiagnostic {
110 path: String::new(),
111 kind: ResponseDecodeKind::InvalidJson,
112 },
113 success_status_received,
114 );
115 }
116 let message = error.inner().to_string();
119 let (kind, field) = if message.starts_with("unknown field `") {
120 (ResponseDecodeKind::UnknownField, None)
121 } else if message.starts_with("missing field `") {
122 (ResponseDecodeKind::MissingField, quoted_field(&message))
123 } else if message.starts_with("invalid type:") {
124 (ResponseDecodeKind::InvalidType, None)
125 } else {
126 (ResponseDecodeKind::InvalidValue, None)
127 };
128 let mut segments: Vec<String> = error
129 .path()
130 .iter()
131 .map(|segment| match segment {
132 Segment::Seq { index } => index.to_string(),
133 Segment::Map { key } => key.clone(),
134 Segment::Enum { .. } => "?".to_owned(),
135 Segment::Unknown => "?".to_owned(),
136 })
137 .collect();
138 if let Some(field) = field {
139 if segments.last().map(String::as_str) != Some(field) {
140 segments.push(field.to_owned());
141 }
142 }
143 let mut path = String::new();
144 for segment in segments.iter().take(16) {
145 path.push('/');
146 let safe: String = segment
149 .chars()
150 .take(80)
151 .flat_map(char::escape_default)
152 .collect();
153 path.push_str(&safe.replace('~', "~0").replace('/', "~1"));
154 if segment.chars().count() > 80 {
155 path.push_str("[truncated]");
156 }
157 }
158 if segments.len() > 16 {
159 path.push_str("/[truncated]");
160 }
161 protocol_error(
162 operation,
163 ResponseDiagnostic { path, kind },
164 success_status_received,
165 )
166}
167
168fn quoted_field(message: &str) -> Option<&str> {
169 message.split('`').nth(1)
170}
171
172#[cfg(test)]
173mod tests {
174 use serde_json::json;
175
176 use super::*;
177 use crate::client::{ListDocument, ListSummary};
178
179 #[test]
180 fn strict_diagnostics_identify_fields_without_rendering_values() {
181 for (response, expected_path, expected_kind) in [
182 (
183 json!([{"_id":"list-1","title":"Todo","modifiedAt":null,"cardsModifiedAt":null,"future":"secret-value"}]),
184 "/0/future",
185 ResponseDecodeKind::UnknownField,
186 ),
187 (
188 json!([{"_id":"list-1","modifiedAt":null,"cardsModifiedAt":null}]),
189 "/0/title",
190 ResponseDecodeKind::MissingField,
191 ),
192 (
193 json!([{"_id":"list-1","title":"Todo","modifiedAt":{"secret-value":true},"cardsModifiedAt":null}]),
194 "/0/modifiedAt",
195 ResponseDecodeKind::InvalidType,
196 ),
197 ] {
198 let error =
199 decode_value::<Vec<ListSummary>>(response, "list collection", true).unwrap_err();
200 assert!(!format!("{error:?}").contains("secret-value"));
201 let ClientError::Protocol {
202 diagnostic: Some(diagnostic),
203 success_status_received: true,
204 ..
205 } = error
206 else {
207 panic!("expected a strict decoding diagnostic")
208 };
209 assert_eq!(diagnostic.path, expected_path);
210 assert_eq!(diagnostic.kind, expected_kind);
211 }
212 }
213
214 #[test]
215 fn nested_diagnostics_and_invalid_json_preserve_locations() {
216 let response = json!({
217 "_id":"list-1","title":"Todo","archived":false,"boardId":"board-1",
218 "createdAt":"2026-08-28T00:00:00Z","modifiedAt":"2026-08-28T00:00:00Z","type":"list",
219 "wipLimit":{"value":3,"enabled":true,"soft":false,"future":true}
220 });
221 let error = decode_value::<ListDocument>(response, "list", true).unwrap_err();
222 let ClientError::Protocol {
223 diagnostic: Some(diagnostic),
224 ..
225 } = error
226 else {
227 panic!("expected diagnostic")
228 };
229 assert_eq!(diagnostic.path, "/wipLimit/future");
230 assert_eq!(diagnostic.kind, ResponseDecodeKind::UnknownField);
231 for body in [b"{secret-value".as_slice(), b"{} secret-value"] {
232 let error = decode_json::<Value>(body, "list").unwrap_err();
233 assert!(!format!("{error:?}").contains("secret-value"));
234 let ClientError::Protocol {
235 diagnostic: Some(diagnostic),
236 ..
237 } = error
238 else {
239 panic!("expected diagnostic")
240 };
241 assert_eq!(diagnostic.path, "");
242 assert_eq!(diagnostic.kind, ResponseDecodeKind::InvalidJson);
243 }
244 }
245
246 #[test]
247 fn server_controlled_paths_are_bounded_and_terminal_safe() {
248 let key = format!("future\u{1b}[31m/~{}", "x".repeat(500));
249 let response = json!([{"_id":"list-1","title":"Todo","modifiedAt":null,"cardsModifiedAt":null,key:true}]);
250 let error =
251 decode_value::<Vec<ListSummary>>(response, "list collection", true).unwrap_err();
252 assert!(!error.to_string().contains('\u{1b}'));
253 let ClientError::Protocol {
254 diagnostic: Some(diagnostic),
255 ..
256 } = error
257 else {
258 panic!("expected diagnostic")
259 };
260 assert!(diagnostic.path.contains("~1~0"));
261 assert!(diagnostic.path.ends_with("[truncated]"));
262 assert!(diagnostic.path.len() < 150);
263 }
264
265 #[test]
266 fn byte_decoding_rejects_duplicate_fields_without_echoing_values() {
267 let body = br#"{"_id":"list-1","_id":"secret-value","title":"Todo","modifiedAt":null,"cardsModifiedAt":null}"#;
268 let error = decode_json::<ListSummary>(body, "list").unwrap_err();
269 assert!(!format!("{error:?}").contains("secret-value"));
270 assert!(matches!(
271 error,
272 ClientError::Protocol {
273 diagnostic: Some(_),
274 ..
275 }
276 ));
277 }
278}