parse_rust_core/decode.rs
1//! `classify`: `serde_json::Value` to [`ParseValue`].
2//!
3//! The inverse of [`ParseValue::to_json`]. Everything above `parse-rust-core` needs this, because a
4//! request body arrives as untyped JSON and has to become a typed value before any pipeline can
5//! reason about it.
6//!
7//! Two upstream behaviors shape the signature, and both are easy to get wrong in the safer
8//! direction:
9//!
10//! **Unknown `__type` is rejected at the top level and preserved when nested.**
11//! `validateObject` raises `INCORRECT_TYPE` for an unrecognized `__type`, but it does not
12//! recurse, so a nested one is stored verbatim as an ordinary object
13//! (`SchemaController.js:1304`), and it is reproduced deliberately. A recursive
14//! rejection would be tidier and would reject writes parse-server accepts.
15//!
16//! **A literal `null` is a value, not an absence.** It classifies as [`ParseValue::Null`] here.
17//! The rule that writing `null` never creates a field lives in the schema controller, not in the
18//! decoder, because it is a schema decision rather than a parsing one.
19
20use serde_json::Value as Json;
21
22use crate::date::ParseDate;
23use crate::error::{ErrorCode, ParseError};
24use crate::value::{base64_decode, ParseMap, ParseValue};
25
26/// Decode a client-supplied JSON value.
27///
28/// Top-level semantics: an unrecognized `__type` is an error. Use this for the values of an
29/// object body's own fields.
30pub fn classify(value: Json) -> Result<ParseValue, ParseError> {
31 classify_at(value, true)
32}
33
34/// Decode a value that sits inside an array or a plain object.
35///
36/// Differs from [`classify`] only in that an unrecognized `__type` is kept as a plain object
37/// rather than rejected, which is what upstream does.
38pub fn classify_nested(value: Json) -> Result<ParseValue, ParseError> {
39 classify_at(value, false)
40}
41
42/// Which of upstream's two atom transforms applies at a given position.
43///
44/// **The two recognize different tag lists, and which one applies is a property of the field, not
45/// of the value** (`MongoTransform.js:655-662`). That is why this is an argument to
46/// [`recognize_atom`] at lowering time rather than a choice made by the parser: the parser does not
47/// have the schema, and guessing is wrong in both directions. Guessing top-level makes a GeoPoint
48/// compared against an `Array` field match a row upstream does not return, because the extra key
49/// upstream would have compared is discarded; guessing interior makes a GeoPoint compared against a
50/// `GeoPoint` field fail to match a row upstream does return, for the mirror reason.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum AtomPosition {
53 /// `transformInteriorAtom` (`MongoTransform.js:566-584`): **Pointer, Date and Bytes only.**
54 ///
55 /// GeoPoint, File, Polygon and Relation are deliberately absent. Upstream leaves them as plain
56 /// objects here, so they are compared whole, extra keys included. Its fourth arm is `$regex`,
57 /// which is not a `__type` and is handled by the lowering.
58 Interior,
59 /// `transformTopLevelAtom` (`MongoTransform.js:594-652`): **Pointer, Date, Bytes, GeoPoint,
60 /// Polygon and File.**
61 ///
62 /// Six, not seven. `Relation` has no arm and no coder, so at the top level it is not an atom at
63 /// all and the caller refuses it. "Every Parse type" is the summary that reads right and is
64 /// wrong by one, and the one it is wrong by is reachable: `{"field": {"__type": "Relation"}}`
65 /// answers 107 upstream.
66 TopLevel,
67}
68
69/// The tags each position recognizes, which is the whole of the difference between them.
70const INTERIOR_TAGS: [&str; 3] = ["Pointer", "Date", "Bytes"];
71const TOP_LEVEL_TAGS: [&str; 6] = ["Pointer", "Date", "Bytes", "GeoPoint", "Polygon", "File"];
72
73/// Rebuild a recognized `__type` envelope on an otherwise-raw value. Never recurses.
74///
75/// The asymmetry this preserves is upstream's, measured at the pin against an `$in` over an array
76/// field:
77///
78/// | operand | upstream |
79/// |---|---|
80/// | element **is** a Pointer | matches |
81/// | element **is** a Pointer plus an unknown key | still matches |
82/// | Pointer **nested** in a plain object | matches |
83/// | Pointer nested in a plain object, plus an unknown key | **does not match** |
84///
85/// Upstream *reconstructs* a recognized atom from its declared keys, so an extra key on the atom
86/// itself is discarded and cannot affect the comparison, while a plain object is returned untouched
87/// and is therefore compared whole. Decoding all the way down, which is what [`classify`] does,
88/// collapses those two rows into one: the nested extra key is dropped, the operand compares equal,
89/// and the query matches a row upstream does not return.
90///
91/// A value that is already a decoded atom is returned unchanged, so a constraint built in Rust
92/// rather than parsed from a request passes through untouched.
93///
94/// **Payload validation here is stricter than upstream's, deliberately.** Every coder's
95/// `isValidJSON` is the tag test and nothing else (`MongoTransform.js`, the five `*Coder` objects),
96/// so upstream converts a malformed envelope instead of declining it, and what it converts it to is
97/// not worth reproducing. Measured against a running server at the pin: `{"__type":"Date"}` becomes
98/// an Invalid Date and matches nothing, `{"__type":"GeoPoint"}` becomes `[null, null]` and matches
99/// nothing, `{"__type":"Pointer","className":"C"}` compares against the literal string
100/// `C$undefined`, `{"__type":"File"}` yields `undefined` and so compares as `null`, matching rows
101/// where the field is null or absent, and `{"__type":"Bytes"}` raises a Node `TypeError` rather
102/// than a Parse error, which is a 500. Every one of those is a comparison against a value the
103/// client never wrote, or a crash.
104///
105/// Declining to recognize a malformed envelope leaves it a plain object, which the caller then
106/// refuses with upstream's own 107 for a non-atom. **Narrowing in every case**, which is the safe
107/// direction: parse-rust refuses where upstream answers with a garbage match. Recorded as a Tier 2
108/// divergence.
109///
110/// An earlier version of this note called the File case a broadening bug that returned every row,
111/// and cited the security carve-out. That was wrong, and wrong in an instructive way: it read
112/// `JSON.stringify` dropping an `undefined` key as the key being absent from the query. The driver
113/// serializes it as `null`, so the constraint is applied and narrows. A rendering is not a
114/// behavior. Verified against a live server rather than against a transform's return value.
115pub fn recognize_atom(value: ParseValue, position: AtomPosition) -> ParseValue {
116 let recognized = match &value {
117 ParseValue::Object(map) => match map.get("__type") {
118 Some(ParseValue::String(tag)) => match position {
119 AtomPosition::Interior => INTERIOR_TAGS.contains(&tag.as_str()),
120 AtomPosition::TopLevel => TOP_LEVEL_TAGS.contains(&tag.as_str()),
121 },
122 _ => false,
123 },
124 _ => false,
125 };
126 if !recognized {
127 return value;
128 }
129 let Some(json) = to_json_value(&value) else {
130 return value;
131 };
132 match classify_at(json, false) {
133 // Only an actual atom counts. A malformed envelope is not one, and upstream's coders answer
134 // the same way: `DateCoder.isValidJSON` is a shape test, and a failed one falls to
135 // `return atom`. Listing the variants rather than excluding `Object` also keeps a decode
136 // that somehow produced a scalar from silently replacing the operand.
137 //
138 // **`Relation` is listed here even though no position admits the tag**, and that is
139 // deliberate rather than sloppy. This match asks "did the decode produce an atom", which is
140 // a different question from "is this tag recognized here", and the tag lists above are the
141 // single place the second question is answered. Leaving `Relation` out made the two guards
142 // redundant, so a mutation that put `Relation` back on the top-level list changed no
143 // observable behavior and the test written to catch it passed. One rule, one place.
144 Ok(
145 decoded @ (ParseValue::Date(_)
146 | ParseValue::Pointer { .. }
147 | ParseValue::GeoPoint { .. }
148 | ParseValue::Bytes(_)
149 | ParseValue::File { .. }
150 | ParseValue::Polygon(_)
151 | ParseValue::Relation { .. }),
152 ) => decoded,
153 _ => value,
154 }
155}
156
157/// The JSON a value encodes to.
158///
159/// Goes through [`ParseValue::to_json`] rather than restating every envelope form, so it cannot
160/// drift from the encoder: it *is* the encoder. Private because the only caller is
161/// [`recognize_atom`] and the failure case has no sensible general answer. `None` means
162/// `write_json` emitted something that is not JSON, which would be a bug in the encoder rather than
163/// in the input, and recognition answers it by declining to recognize.
164fn to_json_value(value: &ParseValue) -> Option<Json> {
165 serde_json::from_str(&value.to_json()).ok()
166}
167
168/// Decode without interpreting a single `__type` envelope, at any depth.
169///
170/// **For the two places that must keep what the client sent rather than what it meant.**
171/// [`classify`] and [`classify_nested`] both recognize `{"__type": "Date", ...}` and turn it into a
172/// `Date`, which is right for a column value and destructive everywhere else: the instant is
173/// re-rendered in UTC, base64 is re-padded, and any key beyond the ones the envelope declares is
174/// dropped, because a `ParseValue::Date` has nowhere to put it.
175///
176/// That loss is invisible locally, since parse-rust decodes its own storage the same way it encoded
177/// it. It is visible to the other node and to the database:
178///
179/// - **Schema metadata.** A `defaultValue` is stored, never enforced, and read back by whoever asks.
180/// Upstream stores the JSON it was sent, so a parse-server node reads back an offset instant, an
181/// unpadded base64 string and every extra key. Canonicalizing means it reads something the client
182/// never wrote.
183/// - **Query operands.** An operand is compared, not stored, and upstream compares it unconverted
184/// (`transformInteriorAtom` returns a generic object as-is). Decoding it first builds a *different
185/// predicate*: `$in` with a nested pointer carrying an extra key matches a row upstream does not
186/// return, because upstream is comparing three keys and parse-rust is comparing two.
187///
188/// Numbers still go through the same range check, so this is "no interpretation", not "no
189/// validation".
190pub fn classify_raw(value: Json) -> Result<ParseValue, ParseError> {
191 match value {
192 Json::Null => Ok(ParseValue::Null),
193 Json::Bool(b) => Ok(ParseValue::Bool(b)),
194 Json::Number(n) => n
195 .as_f64()
196 .map(ParseValue::Number)
197 .ok_or_else(|| ParseError::invalid_json(format!("number out of range: {n}"))),
198 Json::String(s) => Ok(ParseValue::String(s)),
199 Json::Array(items) => items
200 .into_iter()
201 .map(classify_raw)
202 .collect::<Result<Vec<_>, _>>()
203 .map(ParseValue::Array),
204 Json::Object(map) => {
205 let mut out = ParseMap::new();
206 for (k, v) in map {
207 out.insert(k, classify_raw(v)?);
208 }
209 Ok(ParseValue::Object(out))
210 }
211 }
212}
213
214fn classify_at(value: Json, top_level: bool) -> Result<ParseValue, ParseError> {
215 match value {
216 Json::Null => Ok(ParseValue::Null),
217 Json::Bool(b) => Ok(ParseValue::Bool(b)),
218 Json::Number(n) => n
219 .as_f64()
220 .map(ParseValue::Number)
221 .ok_or_else(|| ParseError::invalid_json(format!("number out of range: {n}"))),
222 Json::String(s) => Ok(ParseValue::String(s)),
223 Json::Array(items) => items
224 .into_iter()
225 .map(classify_nested)
226 .collect::<Result<Vec<_>, _>>()
227 .map(ParseValue::Array),
228 Json::Object(map) => classify_object(map, top_level),
229 }
230}
231
232fn classify_object(
233 map: serde_json::Map<String, Json>,
234 top_level: bool,
235) -> Result<ParseValue, ParseError> {
236 let tag = match map.get("__type") {
237 Some(Json::String(t)) => t.clone(),
238 // A non-string `__type` is not a tagged value. Upstream's checks are all string
239 // comparisons, so it falls through to being an ordinary object.
240 _ => return plain_object(map),
241 };
242
243 match tag.as_str() {
244 "Date" => {
245 let iso = require_str(&map, "iso", "Date")?;
246 Ok(ParseValue::Date(ParseDate::parse_iso(iso)?))
247 }
248 "Pointer" => Ok(ParseValue::Pointer {
249 class_name: require_str(&map, "className", "Pointer")?.to_string(),
250 object_id: require_str(&map, "objectId", "Pointer")?.to_string(),
251 }),
252 "GeoPoint" => Ok(ParseValue::GeoPoint {
253 latitude: require_f64(&map, "latitude", "GeoPoint")?,
254 longitude: require_f64(&map, "longitude", "GeoPoint")?,
255 }),
256 "Bytes" => {
257 let b64 = require_str(&map, "base64", "Bytes")?;
258 base64_decode(b64)
259 .map(ParseValue::Bytes)
260 .ok_or_else(|| ParseError::incorrect_type("invalid base64 in Bytes".to_string()))
261 }
262 "File" => Ok(ParseValue::File {
263 name: require_str(&map, "name", "File")?.to_string(),
264 url: match map.get("url") {
265 Some(Json::String(u)) => Some(u.clone()),
266 _ => None,
267 },
268 }),
269 "Polygon" => {
270 let coords = match map.get("coordinates") {
271 Some(Json::Array(a)) => a,
272 _ => {
273 return Err(ParseError::incorrect_type(
274 "Polygon requires a coordinates array".to_string(),
275 ))
276 }
277 };
278 let mut out = Vec::with_capacity(coords.len());
279 for pair in coords {
280 match pair {
281 // Latitude first. See the note on ParseValue::Polygon.
282 Json::Array(p) if p.len() == 2 => {
283 let lat = p[0].as_f64();
284 let lng = p[1].as_f64();
285 match (lat, lng) {
286 (Some(a), Some(b)) => out.push((a, b)),
287 _ => {
288 return Err(ParseError::incorrect_type(
289 "Polygon coordinates must be numbers".to_string(),
290 ))
291 }
292 }
293 }
294 _ => {
295 return Err(ParseError::incorrect_type(
296 "Polygon coordinates must be [latitude, longitude] pairs".to_string(),
297 ))
298 }
299 }
300 }
301 Ok(ParseValue::Polygon(out))
302 }
303 "Relation" => Ok(ParseValue::Relation {
304 class_name: require_str(&map, "className", "Relation")?.to_string(),
305 }),
306 other => {
307 if top_level {
308 // Matches `validateObject`. The message shape is upstream's.
309 Err(ParseError::new(
310 ErrorCode::IncorrectType,
311 format!("invalid type: {other}"),
312 ))
313 } else {
314 // Nested: kept verbatim, because upstream does not recurse.
315 plain_object(map)
316 }
317 }
318 }
319}
320
321fn plain_object(map: serde_json::Map<String, Json>) -> Result<ParseValue, ParseError> {
322 let mut out = ParseMap::with_capacity(map.len());
323 for (k, v) in map {
324 out.insert(k, classify_nested(v)?);
325 }
326 Ok(ParseValue::Object(out))
327}
328
329fn require_str<'a>(
330 map: &'a serde_json::Map<String, Json>,
331 key: &str,
332 tag: &str,
333) -> Result<&'a str, ParseError> {
334 match map.get(key) {
335 Some(Json::String(s)) => Ok(s),
336 _ => Err(ParseError::incorrect_type(format!(
337 "{tag} requires a string {key}"
338 ))),
339 }
340}
341
342fn require_f64(
343 map: &serde_json::Map<String, Json>,
344 key: &str,
345 tag: &str,
346) -> Result<f64, ParseError> {
347 match map.get(key).and_then(|v| v.as_f64()) {
348 Some(n) => Ok(n),
349 None => Err(ParseError::incorrect_type(format!(
350 "{tag} requires a numeric {key}"
351 ))),
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::value::deep_strict_eq;
359
360 fn j(s: &str) -> Json {
361 serde_json::from_str(s).expect("test literal must be valid JSON")
362 }
363
364 /// The property that matters most: anything we can emit, we can read back to the same value.
365 fn round_trips(src: &str) {
366 let v = classify(j(src)).expect("classify failed");
367 let encoded = v.to_json();
368 assert_eq!(encoded, src, "encoding changed the bytes");
369 let again = classify(j(&encoded)).expect("re-classify failed");
370 assert!(deep_strict_eq(&v, &again), "value changed on round trip");
371 }
372
373 #[test]
374 fn primitives_round_trip() {
375 for s in [
376 "null",
377 "true",
378 "false",
379 "0",
380 "100",
381 "-1.5",
382 "0.000001",
383 "100000000000000000000",
384 r#""hello""#,
385 r#""with \"quotes\" and \n""#,
386 "[]",
387 "[1,2,3]",
388 "{}",
389 ] {
390 round_trips(s);
391 }
392 }
393
394 #[test]
395 fn tagged_types_round_trip() {
396 round_trips(r#"{"__type":"Date","iso":"2026-08-14T13:34:33.581Z"}"#);
397 round_trips(r#"{"__type":"Pointer","className":"_User","objectId":"abc123"}"#);
398 round_trips(r#"{"__type":"GeoPoint","latitude":40,"longitude":-75.5}"#);
399 round_trips(r#"{"__type":"Bytes","base64":"aGVsbG8="}"#);
400 round_trips(r#"{"__type":"File","name":"a.png","url":"http://x/a.png"}"#);
401 round_trips(r#"{"__type":"File","name":"a.png"}"#);
402 round_trips(r#"{"__type":"Polygon","coordinates":[[0,0],[1,0],[1,1],[0,0]]}"#);
403 round_trips(r#"{"__type":"Relation","className":"Post"}"#);
404 }
405
406 #[test]
407 fn bytes_decode_to_real_octets() {
408 let v = classify(j(r#"{"__type":"Bytes","base64":"aGVsbG8="}"#)).unwrap();
409 match v {
410 ParseValue::Bytes(b) => assert_eq!(b, b"hello"),
411 other => panic!("expected Bytes, got {other:?}"),
412 }
413 }
414
415 #[test]
416 fn object_key_order_survives_decoding() {
417 let src = r#"{"zebra":1,"apple":2,"mango":3}"#;
418 let v = classify(j(src)).unwrap();
419 assert_eq!(v.to_json(), src, "key order must survive the decoder too");
420 }
421
422 /// UPSTREAM-QUIRK. Rejecting nested unknown types would be tidier and would refuse writes
423 /// parse-server accepts.
424 #[test]
425 fn unknown_type_is_rejected_at_top_level_and_kept_when_nested() {
426 let err = classify(j(r#"{"__type":"Wat","x":1}"#)).unwrap_err();
427 assert_eq!(err.code, ErrorCode::IncorrectType);
428
429 // Nested inside a plain object: preserved verbatim, no error.
430 let nested = classify(j(r#"{"field":{"__type":"Wat","x":1}}"#)).unwrap();
431 assert_eq!(nested.to_json(), r#"{"field":{"__type":"Wat","x":1}}"#);
432
433 // Nested inside an array: same.
434 let in_array = classify(j(r#"[{"__type":"Wat"}]"#)).unwrap();
435 assert_eq!(in_array.to_json(), r#"[{"__type":"Wat"}]"#);
436 }
437
438 #[test]
439 fn a_non_string_type_tag_is_just_an_object() {
440 // Upstream compares __type against strings, so a numeric one is not a tagged value.
441 let v = classify(j(r#"{"__type":7}"#)).unwrap();
442 assert_eq!(v.to_json(), r#"{"__type":7}"#);
443 }
444
445 #[test]
446 fn malformed_tagged_values_carry_the_right_code() {
447 for (src, code) in [
448 (
449 r#"{"__type":"Pointer","className":"A"}"#,
450 ErrorCode::IncorrectType,
451 ),
452 (
453 r#"{"__type":"GeoPoint","latitude":"x","longitude":1}"#,
454 ErrorCode::IncorrectType,
455 ),
456 (
457 r#"{"__type":"Bytes","base64":"not base64!!"}"#,
458 ErrorCode::IncorrectType,
459 ),
460 (
461 r#"{"__type":"Polygon","coordinates":[[1]]}"#,
462 ErrorCode::IncorrectType,
463 ),
464 (
465 r#"{"__type":"Date","iso":"nonsense"}"#,
466 ErrorCode::InvalidJson,
467 ),
468 ] {
469 let e = classify(j(src)).unwrap_err();
470 assert_eq!(e.code, code, "wrong code for {src}");
471 }
472 }
473
474 #[test]
475 fn null_is_a_value_not_an_absence() {
476 // Whether a null clears or skips a field is a schema decision, not a decoder one.
477 let v = classify(j(r#"{"a":null}"#)).unwrap();
478 assert_eq!(v.to_json(), r#"{"a":null}"#);
479 }
480
481 #[test]
482 fn deeply_nested_structures_survive() {
483 let src = r#"{"a":[{"b":[{"__type":"Pointer","className":"C","objectId":"x"}]}]}"#;
484 round_trips(src);
485 }
486}