Skip to main content

pamoja_codec/
transcode.rs

1//! Conversion between the JSON and CBOR wire formats.
2//!
3//! [`Codec`](crate::Codec) is generic over the value being carried, which suits
4//! Rust callers that have a typed payload. Callers arriving through a language
5//! binding do not: they hold a document their own runtime already speaks as JSON,
6//! and what they need from this crate is the compact form to put on a metered
7//! link. These two functions are that operation, transcoding a whole document
8//! between the two formats without a Rust type for it.
9//!
10//! Object keys come back in sorted order rather than the order they were written
11//! in, because the intermediate value holds them in a sorted map. That makes the
12//! output canonical, which is what a signed or deduplicated payload wants, but it
13//! means a round trip is faithful to the document's content and not to its
14//! original byte layout.
15
16use pamoja_core::{Error, Result};
17
18/// Converts a JSON document into its CBOR encoding.
19///
20/// # Arguments
21///
22/// * `json` - the UTF-8 JSON document to convert.
23///
24/// # Returns
25///
26/// The CBOR encoding of the same document, which is typically a good deal
27/// smaller and is what a constrained device or metered link should carry.
28///
29/// # Errors
30///
31/// Returns [`Error::Codec`] if `json` is not a valid JSON document, or if the
32/// document cannot be written as CBOR.
33///
34/// # Examples
35///
36/// ```
37/// use pamoja_codec::json_to_cbor;
38///
39/// let cbor = json_to_cbor(br#"{"c":21.5}"#).unwrap();
40/// assert!(cbor.len() < br#"{"c":21.5}"#.len());
41/// ```
42pub fn json_to_cbor(json: &[u8]) -> Result<Vec<u8>> {
43    let value: serde_json::Value =
44        serde_json::from_slice(json).map_err(|error| Error::Codec(error.to_string()))?;
45    let mut buffer = Vec::new();
46    ciborium::into_writer(&value, &mut buffer).map_err(|error| Error::Codec(error.to_string()))?;
47    Ok(buffer)
48}
49
50/// Converts a CBOR document into its JSON encoding.
51///
52/// # Arguments
53///
54/// * `cbor` - the CBOR document to convert.
55///
56/// # Returns
57///
58/// The UTF-8 JSON encoding of the same document, suitable for handing back to a
59/// runtime that reads JSON natively.
60///
61/// # Errors
62///
63/// Returns [`Error::Codec`] if `cbor` is not a valid CBOR document, or if it
64/// holds a construct with no JSON equivalent, such as a non-string map key.
65///
66/// # Examples
67///
68/// ```
69/// use pamoja_codec::{cbor_to_json, json_to_cbor};
70///
71/// let cbor = json_to_cbor(br#"{"c":21.5}"#).unwrap();
72/// assert_eq!(cbor_to_json(&cbor).unwrap(), br#"{"c":21.5}"#);
73/// ```
74pub fn cbor_to_json(cbor: &[u8]) -> Result<Vec<u8>> {
75    let value: ciborium::Value =
76        ciborium::from_reader(cbor).map_err(|error| Error::Codec(error.to_string()))?;
77    let value: serde_json::Value = value
78        .deserialized()
79        .map_err(|error| Error::Codec(error.to_string()))?;
80    serde_json::to_vec(&value).map_err(|error| Error::Codec(error.to_string()))
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn object_keys_come_back_sorted() {
89        // The intermediate value holds keys in a sorted map, so the output is
90        // canonical rather than a replay of the input's byte order.
91        let cbor = json_to_cbor(br#"{"c":21.5,"a":1}"#).expect("to cbor");
92        assert_eq!(
93            cbor_to_json(&cbor).expect("to json"),
94            br#"{"a":1,"c":21.5}"#
95        );
96    }
97
98    #[test]
99    fn round_trips_a_document() {
100        let json = br#"{"battery":88,"id":"probe-1","reading":21.5}"#;
101        let cbor = json_to_cbor(json).expect("to cbor");
102        assert_eq!(cbor_to_json(&cbor).expect("to json"), json);
103    }
104
105    #[test]
106    fn cbor_is_smaller_than_the_json_it_came_from() {
107        let json = br#"{"a":1,"b":2,"c":3,"d":4,"e":5}"#;
108        let cbor = json_to_cbor(json).expect("to cbor");
109        assert!(cbor.len() < json.len());
110    }
111
112    #[test]
113    fn round_trips_nested_and_empty_containers() {
114        let json = br#"{"empty":{},"list":[1,[2,3],{"deep":true}],"none":null}"#;
115        let cbor = json_to_cbor(json).expect("to cbor");
116        assert_eq!(cbor_to_json(&cbor).expect("to json"), json);
117    }
118
119    #[test]
120    fn invalid_json_is_a_codec_error() {
121        assert!(matches!(json_to_cbor(b"not json"), Err(Error::Codec(_))));
122    }
123
124    #[test]
125    fn invalid_cbor_is_a_codec_error() {
126        assert!(matches!(cbor_to_json(&[0xff, 0xff]), Err(Error::Codec(_))));
127    }
128
129    #[test]
130    fn a_non_string_map_key_has_no_json_form() {
131        // CBOR allows an integer map key; JSON does not, so the conversion fails
132        // rather than inventing a key.
133        let mut cbor = Vec::new();
134        let value = ciborium::Value::Map(vec![(
135            ciborium::Value::Integer(1.into()),
136            ciborium::Value::Bool(true),
137        )]);
138        ciborium::into_writer(&value, &mut cbor).expect("write cbor");
139        assert!(matches!(cbor_to_json(&cbor), Err(Error::Codec(_))));
140    }
141
142    #[test]
143    fn a_document_transcodes_to_the_bytes_rfc_8949_fixes() {
144        // RFC 8949 encodes this document as a two-entry map with text keys, and 21.5 in
145        // the shortest form it allows, which is a half-precision float. Pinning the bytes
146        // catches an encoder that is wrong but self-consistent.
147        let json = br#"{"c":21.5,"ok":true}"#;
148        let cbor = json_to_cbor(json).expect("a valid document");
149        assert_eq!(
150            cbor,
151            [0xA2, 0x61, 0x63, 0xF9, 0x4D, 0x60, 0x62, 0x6F, 0x6B, 0xF5]
152        );
153        assert_eq!(cbor_to_json(&cbor).expect("a valid document"), json);
154    }
155}