Skip to main content

runx_contracts/
json.rs

1//! Boundary JSON model: deterministic value, object, and number types for cross-language contracts.
2use std::collections::BTreeMap;
3use std::fmt;
4
5use serde::de::{self, Visitor};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use serde_json::{Value, json};
8
9use crate::schema::RunxSchema;
10
11pub type JsonObject = BTreeMap<String, JsonValue>;
12
13impl RunxSchema for JsonValue {
14    fn json_schema() -> Value {
15        // An arbitrary JSON value: the committed schemas express this as an
16        // empty subschema (`{}`), which accepts anything.
17        json!({})
18    }
19}
20
21impl RunxSchema for JsonNumber {
22    fn json_schema() -> Value {
23        json!({ "type": "number" })
24    }
25}
26
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(untagged)]
29pub enum JsonValue {
30    Null,
31    Bool(bool),
32    Number(JsonNumber),
33    String(String),
34    Array(Vec<JsonValue>),
35    Object(JsonObject),
36}
37
38impl JsonValue {
39    #[must_use]
40    pub fn as_object(&self) -> Option<&JsonObject> {
41        match self {
42            Self::Object(value) => Some(value),
43            _ => None,
44        }
45    }
46
47    #[must_use]
48    pub fn as_str(&self) -> Option<&str> {
49        match self {
50            Self::String(value) => Some(value),
51            _ => None,
52        }
53    }
54
55    #[must_use]
56    pub fn as_bool(&self) -> Option<bool> {
57        match self {
58            Self::Bool(value) => Some(*value),
59            _ => None,
60        }
61    }
62
63    #[must_use]
64    pub fn as_array(&self) -> Option<&Vec<JsonValue>> {
65        match self {
66            Self::Array(value) => Some(value),
67            _ => None,
68        }
69    }
70}
71
72#[must_use]
73pub fn json_object(value: Option<&JsonValue>) -> Option<&JsonObject> {
74    value.and_then(JsonValue::as_object)
75}
76
77#[must_use]
78pub fn json_object_field<'a>(object: &'a JsonObject, field: &str) -> Option<&'a JsonObject> {
79    object.get(field).and_then(JsonValue::as_object)
80}
81
82#[must_use]
83pub fn json_string_field<'a>(object: &'a JsonObject, field: &str) -> Option<&'a str> {
84    object.get(field).and_then(JsonValue::as_str)
85}
86
87#[must_use]
88pub fn json_bool_field(object: &JsonObject, field: &str) -> Option<bool> {
89    object.get(field).and_then(JsonValue::as_bool)
90}
91
92/// Strict JSON number representation for public serde boundaries.
93///
94/// Public serialization rejects non-finite floats. Act assignment idempotency
95/// hashing deliberately uses a separate JSON.stringify-compatible writer that
96/// hashes non-finite floats as `null`.
97#[derive(Clone, Debug, PartialEq)]
98pub enum JsonNumber {
99    I64(i64),
100    U64(u64),
101    F64(f64),
102}
103
104impl JsonNumber {
105    #[must_use]
106    pub fn as_f64(&self) -> Option<f64> {
107        match self {
108            Self::I64(value) => Some(*value as f64),
109            Self::U64(value) => Some(*value as f64),
110            Self::F64(value) if value.is_finite() => Some(*value),
111            Self::F64(_) => None,
112        }
113    }
114}
115
116impl Serialize for JsonNumber {
117    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118    where
119        S: Serializer,
120    {
121        match *self {
122            Self::I64(value) => serializer.serialize_i64(value),
123            Self::U64(value) => serializer.serialize_u64(value),
124            Self::F64(value) if value.is_finite() && value.fract() == 0.0 => {
125                serialize_whole_f64(value, serializer)
126            }
127            Self::F64(value) if value.is_finite() => serializer.serialize_f64(value),
128            Self::F64(_) => Err(serde::ser::Error::custom(
129                "non-finite numbers are not valid JSON",
130            )),
131        }
132    }
133}
134
135fn serialize_whole_f64<S>(value: f64, serializer: S) -> Result<S::Ok, S::Error>
136where
137    S: Serializer,
138{
139    if value >= i64::MIN as f64 && value <= i64::MAX as f64 {
140        serializer.serialize_i64(value as i64)
141    } else if value >= 0.0 && value <= u64::MAX as f64 {
142        serializer.serialize_u64(value as u64)
143    } else {
144        serializer.serialize_f64(value)
145    }
146}
147
148impl<'de> Deserialize<'de> for JsonNumber {
149    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
150    where
151        D: Deserializer<'de>,
152    {
153        deserializer.deserialize_any(JsonNumberVisitor)
154    }
155}
156
157struct JsonNumberVisitor;
158
159impl Visitor<'_> for JsonNumberVisitor {
160    type Value = JsonNumber;
161
162    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
163        formatter.write_str("a finite JSON number")
164    }
165
166    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
167    where
168        E: de::Error,
169    {
170        Ok(JsonNumber::I64(value))
171    }
172
173    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
174    where
175        E: de::Error,
176    {
177        Ok(i64::try_from(value).map_or(JsonNumber::U64(value), JsonNumber::I64))
178    }
179
180    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
181    where
182        E: de::Error,
183    {
184        if value.is_finite() {
185            Ok(JsonNumber::F64(value))
186        } else {
187            Err(E::custom("non-finite numbers are not valid JSON"))
188        }
189    }
190}
191
192impl fmt::Display for JsonNumber {
193    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match *self {
195            Self::I64(value) => write!(formatter, "{value}"),
196            Self::U64(value) => write!(formatter, "{value}"),
197            Self::F64(value) if value.is_finite() && value == 0.0 => formatter.write_str("0"),
198            Self::F64(value) if value.is_finite() && value.fract() == 0.0 => {
199                write!(formatter, "{value:.0}")
200            }
201            Self::F64(value) => write!(formatter, "{value}"),
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::{JsonNumber, JsonValue};
209
210    #[test]
211    fn json_value_round_trips_objects_with_sorted_keys() -> Result<(), serde_json::Error> {
212        let value = JsonValue::Object(
213            [
214                ("z".to_owned(), JsonValue::String("last".to_owned())),
215                ("a".to_owned(), JsonValue::Number(JsonNumber::I64(1))),
216            ]
217            .into_iter()
218            .collect(),
219        );
220
221        let json = serde_json::to_string(&value)?;
222        let decoded: JsonValue = serde_json::from_str(&json)?;
223
224        assert_eq!(json, r#"{"a":1,"z":"last"}"#);
225        assert_eq!(decoded, value);
226        Ok(())
227    }
228
229    #[test]
230    fn json_value_preserves_fractional_numbers() -> Result<(), serde_json::Error> {
231        let value = JsonValue::Number(JsonNumber::F64(0.91));
232
233        let json = serde_json::to_string(&value)?;
234        let decoded: JsonValue = serde_json::from_str(&json)?;
235
236        assert_eq!(json, "0.91");
237        assert_eq!(decoded, value);
238        Ok(())
239    }
240
241    #[test]
242    fn json_number_serializes_whole_floats_as_json_integers() -> Result<(), serde_json::Error> {
243        let value = JsonValue::Number(JsonNumber::F64(1.0));
244
245        let json = serde_json::to_string(&value)?;
246
247        assert_eq!(json, "1");
248        Ok(())
249    }
250
251    #[test]
252    fn json_number_rejects_non_finite_float_serialization() {
253        let value = JsonValue::Number(JsonNumber::F64(f64::NAN));
254
255        let result = serde_json::to_string(&value);
256
257        assert!(result.is_err());
258    }
259}