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