Skip to main content

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}