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 alloc::format;
17use alloc::string::ToString;
18use alloc::vec::Vec;
19
20use pamoja_core::{Error, Result};
21
22use crate::cbor::{read_error, write_error};
23
24/// Converts a JSON document into its CBOR encoding.
25///
26/// # Arguments
27///
28/// * `json` - the UTF-8 JSON document to convert.
29///
30/// # Returns
31///
32/// The CBOR encoding of the same document, which is typically a good deal
33/// smaller and is what a constrained device or metered link should carry.
34///
35/// # Errors
36///
37/// Returns [`Error::Codec`] if `json` is not a valid JSON document, or if the
38/// document cannot be written as CBOR.
39///
40/// # Examples
41///
42/// ```
43/// use pamoja_codec::json_to_cbor;
44///
45/// let cbor = json_to_cbor(br#"{"c":21.5}"#).unwrap();
46/// assert!(cbor.len() < br#"{"c":21.5}"#.len());
47/// ```
48pub fn json_to_cbor(json: &[u8]) -> Result<Vec<u8>> {
49    let value: serde_json::Value =
50        serde_json::from_slice(json).map_err(|error| Error::Codec(error.to_string()))?;
51    let mut buffer = Vec::new();
52    ciborium::into_writer(&value, &mut buffer).map_err(write_error)?;
53    Ok(buffer)
54}
55
56/// Converts a CBOR document into its JSON encoding.
57///
58/// # Arguments
59///
60/// * `cbor` - the CBOR document to convert.
61///
62/// # Returns
63///
64/// The UTF-8 JSON encoding of the same document, suitable for handing back to a
65/// runtime that reads JSON natively.
66///
67/// # Errors
68///
69/// Returns [`Error::Codec`] if `cbor` is not a valid CBOR document, or if it
70/// holds a construct with no JSON equivalent, such as a non-string map key.
71///
72/// # Examples
73///
74/// ```
75/// use pamoja_codec::{cbor_to_json, json_to_cbor};
76///
77/// let cbor = json_to_cbor(br#"{"c":21.5}"#).unwrap();
78/// assert_eq!(cbor_to_json(&cbor).unwrap(), br#"{"c":21.5}"#);
79/// ```
80pub fn cbor_to_json(cbor: &[u8]) -> Result<Vec<u8>> {
81    let value: ciborium::Value = ciborium::from_reader(cbor).map_err(read_error)?;
82    let value: serde_json::Value =
83        value
84            .deserialized()
85            .map_err(|ciborium::value::Error::Custom(message)| {
86                Error::Codec(format!("the CBOR has no JSON form: {message}"))
87            })?;
88    serde_json::to_vec(&value).map_err(|error| Error::Codec(error.to_string()))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn object_keys_come_back_sorted() {
97        // The intermediate value holds keys in a sorted map, so the output is
98        // canonical rather than a replay of the input's byte order.
99        let cbor = json_to_cbor(br#"{"c":21.5,"a":1}"#).expect("to cbor");
100        assert_eq!(
101            cbor_to_json(&cbor).expect("to json"),
102            br#"{"a":1,"c":21.5}"#
103        );
104    }
105
106    #[test]
107    fn round_trips_a_document() {
108        let json = br#"{"battery":88,"id":"probe-1","reading":21.5}"#;
109        let cbor = json_to_cbor(json).expect("to cbor");
110        assert_eq!(cbor_to_json(&cbor).expect("to json"), json);
111    }
112
113    #[test]
114    fn cbor_is_smaller_than_the_json_it_came_from() {
115        let json = br#"{"a":1,"b":2,"c":3,"d":4,"e":5}"#;
116        let cbor = json_to_cbor(json).expect("to cbor");
117        assert!(cbor.len() < json.len());
118    }
119
120    #[test]
121    fn round_trips_nested_and_empty_containers() {
122        let json = br#"{"empty":{},"list":[1,[2,3],{"deep":true}],"none":null}"#;
123        let cbor = json_to_cbor(json).expect("to cbor");
124        assert_eq!(cbor_to_json(&cbor).expect("to json"), json);
125    }
126
127    #[test]
128    fn invalid_json_is_a_codec_error() {
129        assert!(matches!(json_to_cbor(b"not json"), Err(Error::Codec(_))));
130    }
131
132    #[test]
133    fn invalid_cbor_is_a_codec_error() {
134        assert!(matches!(cbor_to_json(&[0xff, 0xff]), Err(Error::Codec(_))));
135    }
136
137    #[test]
138    fn a_document_cut_short_says_so_in_words() {
139        let cbor = json_to_cbor(br#"{"c":21.5}"#).expect("to cbor");
140        match cbor_to_json(&cbor[..cbor.len() - 1]) {
141            Err(Error::Codec(reason)) => {
142                assert_eq!(reason, "the CBOR ends part-way through a value");
143            }
144            other => panic!("a document cut short decoded: {other:?}"),
145        }
146    }
147
148    #[test]
149    fn a_non_string_map_key_has_no_json_form() {
150        // CBOR allows an integer map key; JSON does not, so the conversion fails
151        // rather than inventing a key.
152        let mut cbor = Vec::new();
153        let value = ciborium::Value::Map(vec![(
154            ciborium::Value::Integer(1.into()),
155            ciborium::Value::Bool(true),
156        )]);
157        ciborium::into_writer(&value, &mut cbor).expect("write cbor");
158        match cbor_to_json(&cbor) {
159            Err(Error::Codec(reason)) => {
160                assert!(
161                    reason.starts_with("the CBOR has no JSON form: "),
162                    "{reason}"
163                );
164            }
165            other => panic!("an integer key was given a JSON form: {other:?}"),
166        }
167    }
168
169    #[test]
170    fn a_document_transcodes_to_the_bytes_rfc_8949_fixes() {
171        // RFC 8949 encodes this document as a two-entry map with text keys, and 21.5 in
172        // the shortest form it allows, which is a half-precision float. Pinning the bytes
173        // catches an encoder that is wrong but self-consistent.
174        let json = br#"{"c":21.5,"ok":true}"#;
175        let cbor = json_to_cbor(json).expect("a valid document");
176        assert_eq!(
177            cbor,
178            [0xA2, 0x61, 0x63, 0xF9, 0x4D, 0x60, 0x62, 0x6F, 0x6B, 0xF5]
179        );
180        assert_eq!(cbor_to_json(&cbor).expect("a valid document"), json);
181    }
182}