use serde_json::Value as Json;
use crate::date::ParseDate;
use crate::error::{ErrorCode, ParseError};
use crate::value::{base64_decode, ParseMap, ParseValue};
pub fn classify(value: Json) -> Result<ParseValue, ParseError> {
classify_at(value, true)
}
pub fn classify_nested(value: Json) -> Result<ParseValue, ParseError> {
classify_at(value, false)
}
fn classify_at(value: Json, top_level: bool) -> Result<ParseValue, ParseError> {
match value {
Json::Null => Ok(ParseValue::Null),
Json::Bool(b) => Ok(ParseValue::Bool(b)),
Json::Number(n) => n
.as_f64()
.map(ParseValue::Number)
.ok_or_else(|| ParseError::invalid_json(format!("number out of range: {n}"))),
Json::String(s) => Ok(ParseValue::String(s)),
Json::Array(items) => items
.into_iter()
.map(classify_nested)
.collect::<Result<Vec<_>, _>>()
.map(ParseValue::Array),
Json::Object(map) => classify_object(map, top_level),
}
}
fn classify_object(
map: serde_json::Map<String, Json>,
top_level: bool,
) -> Result<ParseValue, ParseError> {
let tag = match map.get("__type") {
Some(Json::String(t)) => t.clone(),
_ => return plain_object(map),
};
match tag.as_str() {
"Date" => {
let iso = require_str(&map, "iso", "Date")?;
Ok(ParseValue::Date(ParseDate::parse_iso(iso)?))
}
"Pointer" => Ok(ParseValue::Pointer {
class_name: require_str(&map, "className", "Pointer")?.to_string(),
object_id: require_str(&map, "objectId", "Pointer")?.to_string(),
}),
"GeoPoint" => Ok(ParseValue::GeoPoint {
latitude: require_f64(&map, "latitude", "GeoPoint")?,
longitude: require_f64(&map, "longitude", "GeoPoint")?,
}),
"Bytes" => {
let b64 = require_str(&map, "base64", "Bytes")?;
base64_decode(b64)
.map(ParseValue::Bytes)
.ok_or_else(|| ParseError::incorrect_type("invalid base64 in Bytes".to_string()))
}
"File" => Ok(ParseValue::File {
name: require_str(&map, "name", "File")?.to_string(),
url: match map.get("url") {
Some(Json::String(u)) => Some(u.clone()),
_ => None,
},
}),
"Polygon" => {
let coords = match map.get("coordinates") {
Some(Json::Array(a)) => a,
_ => {
return Err(ParseError::incorrect_type(
"Polygon requires a coordinates array".to_string(),
))
}
};
let mut out = Vec::with_capacity(coords.len());
for pair in coords {
match pair {
Json::Array(p) if p.len() == 2 => {
let lat = p[0].as_f64();
let lng = p[1].as_f64();
match (lat, lng) {
(Some(a), Some(b)) => out.push((a, b)),
_ => {
return Err(ParseError::incorrect_type(
"Polygon coordinates must be numbers".to_string(),
))
}
}
}
_ => {
return Err(ParseError::incorrect_type(
"Polygon coordinates must be [latitude, longitude] pairs".to_string(),
))
}
}
}
Ok(ParseValue::Polygon(out))
}
"Relation" => Ok(ParseValue::Relation {
class_name: require_str(&map, "className", "Relation")?.to_string(),
}),
other => {
if top_level {
Err(ParseError::new(
ErrorCode::IncorrectType,
format!("invalid type: {other}"),
))
} else {
plain_object(map)
}
}
}
}
fn plain_object(map: serde_json::Map<String, Json>) -> Result<ParseValue, ParseError> {
let mut out = ParseMap::with_capacity(map.len());
for (k, v) in map {
out.insert(k, classify_nested(v)?);
}
Ok(ParseValue::Object(out))
}
fn require_str<'a>(
map: &'a serde_json::Map<String, Json>,
key: &str,
tag: &str,
) -> Result<&'a str, ParseError> {
match map.get(key) {
Some(Json::String(s)) => Ok(s),
_ => Err(ParseError::incorrect_type(format!(
"{tag} requires a string {key}"
))),
}
}
fn require_f64(
map: &serde_json::Map<String, Json>,
key: &str,
tag: &str,
) -> Result<f64, ParseError> {
match map.get(key).and_then(|v| v.as_f64()) {
Some(n) => Ok(n),
None => Err(ParseError::incorrect_type(format!(
"{tag} requires a numeric {key}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::deep_strict_eq;
fn j(s: &str) -> Json {
serde_json::from_str(s).expect("test literal must be valid JSON")
}
fn round_trips(src: &str) {
let v = classify(j(src)).expect("classify failed");
let encoded = v.to_json();
assert_eq!(encoded, src, "encoding changed the bytes");
let again = classify(j(&encoded)).expect("re-classify failed");
assert!(deep_strict_eq(&v, &again), "value changed on round trip");
}
#[test]
fn primitives_round_trip() {
for s in [
"null",
"true",
"false",
"0",
"100",
"-1.5",
"0.000001",
"100000000000000000000",
r#""hello""#,
r#""with \"quotes\" and \n""#,
"[]",
"[1,2,3]",
"{}",
] {
round_trips(s);
}
}
#[test]
fn tagged_types_round_trip() {
round_trips(r#"{"__type":"Date","iso":"2026-08-14T13:34:33.581Z"}"#);
round_trips(r#"{"__type":"Pointer","className":"_User","objectId":"abc123"}"#);
round_trips(r#"{"__type":"GeoPoint","latitude":40,"longitude":-75.5}"#);
round_trips(r#"{"__type":"Bytes","base64":"aGVsbG8="}"#);
round_trips(r#"{"__type":"File","name":"a.png","url":"http://x/a.png"}"#);
round_trips(r#"{"__type":"File","name":"a.png"}"#);
round_trips(r#"{"__type":"Polygon","coordinates":[[0,0],[1,0],[1,1],[0,0]]}"#);
round_trips(r#"{"__type":"Relation","className":"Post"}"#);
}
#[test]
fn bytes_decode_to_real_octets() {
let v = classify(j(r#"{"__type":"Bytes","base64":"aGVsbG8="}"#)).unwrap();
match v {
ParseValue::Bytes(b) => assert_eq!(b, b"hello"),
other => panic!("expected Bytes, got {other:?}"),
}
}
#[test]
fn object_key_order_survives_decoding() {
let src = r#"{"zebra":1,"apple":2,"mango":3}"#;
let v = classify(j(src)).unwrap();
assert_eq!(v.to_json(), src, "key order must survive the decoder too");
}
#[test]
fn unknown_type_is_rejected_at_top_level_and_kept_when_nested() {
let err = classify(j(r#"{"__type":"Wat","x":1}"#)).unwrap_err();
assert_eq!(err.code, ErrorCode::IncorrectType);
let nested = classify(j(r#"{"field":{"__type":"Wat","x":1}}"#)).unwrap();
assert_eq!(nested.to_json(), r#"{"field":{"__type":"Wat","x":1}}"#);
let in_array = classify(j(r#"[{"__type":"Wat"}]"#)).unwrap();
assert_eq!(in_array.to_json(), r#"[{"__type":"Wat"}]"#);
}
#[test]
fn a_non_string_type_tag_is_just_an_object() {
let v = classify(j(r#"{"__type":7}"#)).unwrap();
assert_eq!(v.to_json(), r#"{"__type":7}"#);
}
#[test]
fn malformed_tagged_values_carry_the_right_code() {
for (src, code) in [
(
r#"{"__type":"Pointer","className":"A"}"#,
ErrorCode::IncorrectType,
),
(
r#"{"__type":"GeoPoint","latitude":"x","longitude":1}"#,
ErrorCode::IncorrectType,
),
(
r#"{"__type":"Bytes","base64":"not base64!!"}"#,
ErrorCode::IncorrectType,
),
(
r#"{"__type":"Polygon","coordinates":[[1]]}"#,
ErrorCode::IncorrectType,
),
(
r#"{"__type":"Date","iso":"nonsense"}"#,
ErrorCode::InvalidJson,
),
] {
let e = classify(j(src)).unwrap_err();
assert_eq!(e.code, code, "wrong code for {src}");
}
}
#[test]
fn null_is_a_value_not_an_absence() {
let v = classify(j(r#"{"a":null}"#)).unwrap();
assert_eq!(v.to_json(), r#"{"a":null}"#);
}
#[test]
fn deeply_nested_structures_survive() {
let src = r#"{"a":[{"b":[{"__type":"Pointer","className":"C","objectId":"x"}]}]}"#;
round_trips(src);
}
}