// json_utils.mbt — private decode helpers shared by all lody codecs.
//
// Leniency rules (mirroring upstream TypeScript optionality):
// - optional fields: absent key and explicit JSON null both decode to None
// - required fields: absent or null key is a decode error
// - present but mistyped fields always error, naming the contract scope
// (`lody.`) and the wire field name
// - unknown fields are ignored so newer senders stay decodable
// - numbers must be integral where the contract model is Int/Int64; the
// check is exact (no silent truncation)
///|
/// Internal decode failure carrying a human-readable message. Public codecs
/// catch this and surface it as `Result`'s `Err` string.
priv suberror DecodeError {
Msg(String)
}
///|
fn object_fields(
value : Json,
ctx : String,
) -> Map[String, Json] raise DecodeError {
guard value is Json::Object(fields) else {
raise DecodeError::Msg("\{ctx} must be a JSON object")
}
fields
}
///|
fn req_string(
fields : Map[String, Json],
ctx : String,
key : String,
) -> String raise DecodeError {
match fields.get(key) {
Some(Json::String(s)) => s
Some(Json::Null) | None =>
raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a string")
}
}
///|
fn opt_string(
fields : Map[String, Json],
ctx : String,
key : String,
) -> String? raise DecodeError {
match fields.get(key) {
None => None
Some(Json::Null) => None
Some(Json::String(s)) => Some(s)
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a string")
}
}
///|
fn req_bool(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Bool raise DecodeError {
match fields.get(key) {
Some(Json::True) => true
Some(Json::False) => false
Some(Json::Null) | None =>
raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a boolean")
}
}
///|
fn opt_bool(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Bool? raise DecodeError {
match fields.get(key) {
None => None
Some(Json::Null) => None
Some(Json::True) => Some(true)
Some(Json::False) => Some(false)
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a boolean")
}
}
///|
fn req_int(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Int raise DecodeError {
match fields.get(key) {
Some(Json::Number(d, ..)) =>
if d != d.trunc() {
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be an integer")
} else {
d.to_int64().to_int()
}
Some(Json::Null) | None =>
raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a number")
}
}
///|
fn opt_int(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Int? raise DecodeError {
match fields.get(key) {
None => None
Some(Json::Null) => None
Some(Json::Number(d, ..)) =>
if d != d.trunc() {
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be an integer")
} else {
Some(d.to_int64().to_int())
}
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a number")
}
}
///|
fn req_int64(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Int64 raise DecodeError {
match fields.get(key) {
Some(Json::Number(d, ..)) =>
if d != d.trunc() {
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be an integer")
} else {
d.to_int64()
}
Some(Json::Null) | None =>
raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a number")
}
}
///|
fn opt_int64(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Int64? raise DecodeError {
match fields.get(key) {
None => None
Some(Json::Null) => None
Some(Json::Number(d, ..)) =>
if d != d.trunc() {
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be an integer")
} else {
Some(d.to_int64())
}
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a number")
}
}
///|
fn req_double(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Double raise DecodeError {
match fields.get(key) {
Some(Json::Number(d, ..)) => d
Some(Json::Null) | None =>
raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a number")
}
}
///|
fn opt_double(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Double? raise DecodeError {
match fields.get(key) {
None => None
Some(Json::Null) => None
Some(Json::Number(d, ..)) => Some(d)
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be a number")
}
}
///|
/// Required but nullable integer (upstream `number | null` without `?`):
/// the key must be present, but its value may be null.
fn req_int64_nullable(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Int64? raise DecodeError {
match fields.get(key) {
Some(Json::Null) => None
Some(Json::Number(d, ..)) =>
if d != d.trunc() {
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be an integer")
} else {
Some(d.to_int64())
}
Some(_) =>
raise DecodeError::Msg(
"\{ctx}: field \"\{key}\" must be a number or null",
)
None => raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
}
}
///|
/// Required but nullable sub-object (upstream `T | null` without `?`):
/// the key must be present, but its value may be null.
fn[T] req_sub_nullable(
fields : Map[String, Json],
ctx : String,
key : String,
parse : (Json) -> Result[T, String],
) -> T? raise DecodeError {
match fields.get(key) {
Some(Json::Null) => None
Some(value) =>
match parse(value) {
Ok(parsed) => Some(parsed)
Err(message) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\": \{message}")
}
None => raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
}
}
///|
fn req_array(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Array[Json] raise DecodeError {
match fields.get(key) {
Some(Json::Array(items)) => items
Some(Json::Null) | None =>
raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be an array")
}
}
///|
fn opt_object(
fields : Map[String, Json],
ctx : String,
key : String,
) -> Map[String, Json]? raise DecodeError {
match fields.get(key) {
None => None
Some(Json::Null) => None
Some(Json::Object(inner)) => Some(inner)
Some(_) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\" must be an object")
}
}
///|
fn[T] opt_sub(
fields : Map[String, Json],
ctx : String,
key : String,
parse : (Json) -> Result[T, String],
) -> T? raise DecodeError {
match fields.get(key) {
None => None
Some(Json::Null) => None
Some(value) =>
match parse(value) {
Ok(parsed) => Some(parsed)
Err(message) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\": \{message}")
}
}
}
///|
/// Decode an optional string-literal-union field already read as `String?`
/// through its enum's `of_wire`.
fn[T] opt_wire(
ctx : String,
key : String,
raw : String?,
of_wire : (String) -> Result[T, String],
) -> T? raise DecodeError {
match raw {
None => None
Some(s) => Some(wire_of(ctx, key, of_wire(s)))
}
}
///|
fn[T] req_sub(
fields : Map[String, Json],
ctx : String,
key : String,
parse : (Json) -> Result[T, String],
) -> T raise DecodeError {
match fields.get(key) {
Some(Json::Null) | None =>
raise DecodeError::Msg("\{ctx}: missing required field \"\{key}\"")
Some(value) =>
match parse(value) {
Ok(parsed) => parsed
Err(message) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\": \{message}")
}
}
}
///|
/// Decode a string-literal-union wire value through an enum's `of_wire`,
/// propagating its message as a decode error.
fn[T] wire_of(
ctx : String,
key : String,
parsed : Result[T, String],
) -> T raise DecodeError {
match parsed {
Ok(value) => value
Err(message) =>
raise DecodeError::Msg("\{ctx}: field \"\{key}\": \{message}")
}
}