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:1303`), 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
42fn classify_at(value: Json, top_level: bool) -> Result<ParseValue, ParseError> {
43    match value {
44        Json::Null => Ok(ParseValue::Null),
45        Json::Bool(b) => Ok(ParseValue::Bool(b)),
46        Json::Number(n) => n
47            .as_f64()
48            .map(ParseValue::Number)
49            .ok_or_else(|| ParseError::invalid_json(format!("number out of range: {n}"))),
50        Json::String(s) => Ok(ParseValue::String(s)),
51        Json::Array(items) => items
52            .into_iter()
53            .map(classify_nested)
54            .collect::<Result<Vec<_>, _>>()
55            .map(ParseValue::Array),
56        Json::Object(map) => classify_object(map, top_level),
57    }
58}
59
60fn classify_object(
61    map: serde_json::Map<String, Json>,
62    top_level: bool,
63) -> Result<ParseValue, ParseError> {
64    let tag = match map.get("__type") {
65        Some(Json::String(t)) => t.clone(),
66        // A non-string `__type` is not a tagged value. Upstream's checks are all string
67        // comparisons, so it falls through to being an ordinary object.
68        _ => return plain_object(map),
69    };
70
71    match tag.as_str() {
72        "Date" => {
73            let iso = require_str(&map, "iso", "Date")?;
74            Ok(ParseValue::Date(ParseDate::parse_iso(iso)?))
75        }
76        "Pointer" => Ok(ParseValue::Pointer {
77            class_name: require_str(&map, "className", "Pointer")?.to_string(),
78            object_id: require_str(&map, "objectId", "Pointer")?.to_string(),
79        }),
80        "GeoPoint" => Ok(ParseValue::GeoPoint {
81            latitude: require_f64(&map, "latitude", "GeoPoint")?,
82            longitude: require_f64(&map, "longitude", "GeoPoint")?,
83        }),
84        "Bytes" => {
85            let b64 = require_str(&map, "base64", "Bytes")?;
86            base64_decode(b64)
87                .map(ParseValue::Bytes)
88                .ok_or_else(|| ParseError::incorrect_type("invalid base64 in Bytes".to_string()))
89        }
90        "File" => Ok(ParseValue::File {
91            name: require_str(&map, "name", "File")?.to_string(),
92            url: match map.get("url") {
93                Some(Json::String(u)) => Some(u.clone()),
94                _ => None,
95            },
96        }),
97        "Polygon" => {
98            let coords = match map.get("coordinates") {
99                Some(Json::Array(a)) => a,
100                _ => {
101                    return Err(ParseError::incorrect_type(
102                        "Polygon requires a coordinates array".to_string(),
103                    ))
104                }
105            };
106            let mut out = Vec::with_capacity(coords.len());
107            for pair in coords {
108                match pair {
109                    // Latitude first. See the note on ParseValue::Polygon.
110                    Json::Array(p) if p.len() == 2 => {
111                        let lat = p[0].as_f64();
112                        let lng = p[1].as_f64();
113                        match (lat, lng) {
114                            (Some(a), Some(b)) => out.push((a, b)),
115                            _ => {
116                                return Err(ParseError::incorrect_type(
117                                    "Polygon coordinates must be numbers".to_string(),
118                                ))
119                            }
120                        }
121                    }
122                    _ => {
123                        return Err(ParseError::incorrect_type(
124                            "Polygon coordinates must be [latitude, longitude] pairs".to_string(),
125                        ))
126                    }
127                }
128            }
129            Ok(ParseValue::Polygon(out))
130        }
131        "Relation" => Ok(ParseValue::Relation {
132            class_name: require_str(&map, "className", "Relation")?.to_string(),
133        }),
134        other => {
135            if top_level {
136                // Matches `validateObject`. The message shape is upstream's.
137                Err(ParseError::new(
138                    ErrorCode::IncorrectType,
139                    format!("invalid type: {other}"),
140                ))
141            } else {
142                // Nested: kept verbatim, because upstream does not recurse.
143                plain_object(map)
144            }
145        }
146    }
147}
148
149fn plain_object(map: serde_json::Map<String, Json>) -> Result<ParseValue, ParseError> {
150    let mut out = ParseMap::with_capacity(map.len());
151    for (k, v) in map {
152        out.insert(k, classify_nested(v)?);
153    }
154    Ok(ParseValue::Object(out))
155}
156
157fn require_str<'a>(
158    map: &'a serde_json::Map<String, Json>,
159    key: &str,
160    tag: &str,
161) -> Result<&'a str, ParseError> {
162    match map.get(key) {
163        Some(Json::String(s)) => Ok(s),
164        _ => Err(ParseError::incorrect_type(format!(
165            "{tag} requires a string {key}"
166        ))),
167    }
168}
169
170fn require_f64(
171    map: &serde_json::Map<String, Json>,
172    key: &str,
173    tag: &str,
174) -> Result<f64, ParseError> {
175    match map.get(key).and_then(|v| v.as_f64()) {
176        Some(n) => Ok(n),
177        None => Err(ParseError::incorrect_type(format!(
178            "{tag} requires a numeric {key}"
179        ))),
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::value::deep_strict_eq;
187
188    fn j(s: &str) -> Json {
189        serde_json::from_str(s).expect("test literal must be valid JSON")
190    }
191
192    /// The property that matters most: anything we can emit, we can read back to the same value.
193    fn round_trips(src: &str) {
194        let v = classify(j(src)).expect("classify failed");
195        let encoded = v.to_json();
196        assert_eq!(encoded, src, "encoding changed the bytes");
197        let again = classify(j(&encoded)).expect("re-classify failed");
198        assert!(deep_strict_eq(&v, &again), "value changed on round trip");
199    }
200
201    #[test]
202    fn primitives_round_trip() {
203        for s in [
204            "null",
205            "true",
206            "false",
207            "0",
208            "100",
209            "-1.5",
210            "0.000001",
211            "100000000000000000000",
212            r#""hello""#,
213            r#""with \"quotes\" and \n""#,
214            "[]",
215            "[1,2,3]",
216            "{}",
217        ] {
218            round_trips(s);
219        }
220    }
221
222    #[test]
223    fn tagged_types_round_trip() {
224        round_trips(r#"{"__type":"Date","iso":"2026-08-14T13:34:33.581Z"}"#);
225        round_trips(r#"{"__type":"Pointer","className":"_User","objectId":"abc123"}"#);
226        round_trips(r#"{"__type":"GeoPoint","latitude":40,"longitude":-75.5}"#);
227        round_trips(r#"{"__type":"Bytes","base64":"aGVsbG8="}"#);
228        round_trips(r#"{"__type":"File","name":"a.png","url":"http://x/a.png"}"#);
229        round_trips(r#"{"__type":"File","name":"a.png"}"#);
230        round_trips(r#"{"__type":"Polygon","coordinates":[[0,0],[1,0],[1,1],[0,0]]}"#);
231        round_trips(r#"{"__type":"Relation","className":"Post"}"#);
232    }
233
234    #[test]
235    fn bytes_decode_to_real_octets() {
236        let v = classify(j(r#"{"__type":"Bytes","base64":"aGVsbG8="}"#)).unwrap();
237        match v {
238            ParseValue::Bytes(b) => assert_eq!(b, b"hello"),
239            other => panic!("expected Bytes, got {other:?}"),
240        }
241    }
242
243    #[test]
244    fn object_key_order_survives_decoding() {
245        let src = r#"{"zebra":1,"apple":2,"mango":3}"#;
246        let v = classify(j(src)).unwrap();
247        assert_eq!(v.to_json(), src, "key order must survive the decoder too");
248    }
249
250    /// UPSTREAM-QUIRK. Rejecting nested unknown types would be tidier and would refuse writes
251    /// parse-server accepts.
252    #[test]
253    fn unknown_type_is_rejected_at_top_level_and_kept_when_nested() {
254        let err = classify(j(r#"{"__type":"Wat","x":1}"#)).unwrap_err();
255        assert_eq!(err.code, ErrorCode::IncorrectType);
256
257        // Nested inside a plain object: preserved verbatim, no error.
258        let nested = classify(j(r#"{"field":{"__type":"Wat","x":1}}"#)).unwrap();
259        assert_eq!(nested.to_json(), r#"{"field":{"__type":"Wat","x":1}}"#);
260
261        // Nested inside an array: same.
262        let in_array = classify(j(r#"[{"__type":"Wat"}]"#)).unwrap();
263        assert_eq!(in_array.to_json(), r#"[{"__type":"Wat"}]"#);
264    }
265
266    #[test]
267    fn a_non_string_type_tag_is_just_an_object() {
268        // Upstream compares __type against strings, so a numeric one is not a tagged value.
269        let v = classify(j(r#"{"__type":7}"#)).unwrap();
270        assert_eq!(v.to_json(), r#"{"__type":7}"#);
271    }
272
273    #[test]
274    fn malformed_tagged_values_carry_the_right_code() {
275        for (src, code) in [
276            (
277                r#"{"__type":"Pointer","className":"A"}"#,
278                ErrorCode::IncorrectType,
279            ),
280            (
281                r#"{"__type":"GeoPoint","latitude":"x","longitude":1}"#,
282                ErrorCode::IncorrectType,
283            ),
284            (
285                r#"{"__type":"Bytes","base64":"not base64!!"}"#,
286                ErrorCode::IncorrectType,
287            ),
288            (
289                r#"{"__type":"Polygon","coordinates":[[1]]}"#,
290                ErrorCode::IncorrectType,
291            ),
292            (
293                r#"{"__type":"Date","iso":"nonsense"}"#,
294                ErrorCode::InvalidJson,
295            ),
296        ] {
297            let e = classify(j(src)).unwrap_err();
298            assert_eq!(e.code, code, "wrong code for {src}");
299        }
300    }
301
302    #[test]
303    fn null_is_a_value_not_an_absence() {
304        // Whether a null clears or skips a field is a schema decision, not a decoder one.
305        let v = classify(j(r#"{"a":null}"#)).unwrap();
306        assert_eq!(v.to_json(), r#"{"a":null}"#);
307    }
308
309    #[test]
310    fn deeply_nested_structures_survive() {
311        let src = r#"{"a":[{"b":[{"__type":"Pointer","className":"C","objectId":"x"}]}]}"#;
312        round_trips(src);
313    }
314}