Skip to main content

zerodds_amqp_endpoint/
mapping.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Body-Encoding-Mode-Mapping (Pass-Through / JSON / AMQP-Native).
5//!
6//! Spec-Quelle: dds-amqp-1.0-beta1.pdf §8.1 Body Encoding Modes.
7
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10
11/// Spec §8.1 — Body-Encoding-Mode pro Topic-Mapping.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum BodyEncodingMode {
14    /// Spec §8.1.1 — Pass-Through (XCDR2-Bytes verbatim).
15    #[default]
16    PassThrough,
17    /// Spec §8.1.2 — JSON-Mapping.
18    Json,
19    /// Spec §8.1.3 — AMQP-Native typed mapping.
20    AmqpNative,
21}
22
23/// Mapping-Fehler.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum MappingError {
26    /// JSON-Body ist nicht UTF-8.
27    InvalidUtf8,
28    /// JSON-Body ist nicht gueltiges JSON.
29    InvalidJson(String),
30    /// Pass-Through-Body ist leer (typischerweise illegal).
31    EmptyBody,
32}
33
34/// Spec §8.1 — Encode eines DDS-Sample-Bodies in den AMQP-Body.
35///
36/// Returns Tuple (content_type, body_bytes).
37///
38/// # Errors
39/// `MappingError`.
40pub fn encode_dds_to_amqp_body(
41    sample_xcdr2: &[u8],
42    mode: BodyEncodingMode,
43) -> Result<(&'static str, Vec<u8>), MappingError> {
44    if sample_xcdr2.is_empty() {
45        return Err(MappingError::EmptyBody);
46    }
47    match mode {
48        BodyEncodingMode::PassThrough => Ok(("application/vnd.dds.xcdr2", sample_xcdr2.to_vec())),
49        BodyEncodingMode::Json => {
50            // Wir koennen XCDR2 nicht ohne Type-Information in JSON
51            // umsetzen — Spec §8.1.2 verlangt Type-Reflektion. Hier
52            // liefern wir nur Pass-Through-Base64 als Fallback;
53            // der Caller mit echtem TypeObject kann eine richtige
54            // Konvertierung liefern.
55            let mut json = String::from("{\"_xcdr2\":\"");
56            for b in sample_xcdr2 {
57                let _ = core::fmt::Write::write_fmt(&mut json, core::format_args!("{b:02x}"));
58            }
59            json.push_str("\"}");
60            Ok(("application/json", json.into_bytes()))
61        }
62        BodyEncodingMode::AmqpNative => {
63            Ok(("application/vnd.dds.amqp-native", sample_xcdr2.to_vec()))
64        }
65    }
66}
67
68/// Spec §8.1 — Parse eines AMQP-Bodies in einen DDS-Sample-Body
69/// (XCDR2-Bytes).
70///
71/// # Errors
72/// `MappingError`.
73pub fn parse_amqp_body(body: &[u8], content_type: Option<&str>) -> Result<Vec<u8>, MappingError> {
74    if body.is_empty() {
75        return Err(MappingError::EmptyBody);
76    }
77    let mode = match content_type {
78        Some("application/vnd.dds.xcdr2") => BodyEncodingMode::PassThrough,
79        Some("application/json") => BodyEncodingMode::Json,
80        Some("application/vnd.dds.amqp-native") => BodyEncodingMode::AmqpNative,
81        _ => BodyEncodingMode::PassThrough, // default
82    };
83    match mode {
84        BodyEncodingMode::PassThrough | BodyEncodingMode::AmqpNative => Ok(body.to_vec()),
85        BodyEncodingMode::Json => {
86            let s = core::str::from_utf8(body).map_err(|_| MappingError::InvalidUtf8)?;
87            // Suchen wir das _xcdr2-Hex-Field aus unserem Reverse-
88            // Encoding (rudimentaerer Roundtrip ohne Type-Info).
89            let key = "\"_xcdr2\":\"";
90            let start = s
91                .find(key)
92                .ok_or_else(|| MappingError::InvalidJson("missing _xcdr2 field".to_string()))?;
93            let hex_start = start + key.len();
94            let hex_end = s[hex_start..]
95                .find('"')
96                .ok_or_else(|| MappingError::InvalidJson("unterminated _xcdr2 hex".to_string()))?;
97            let hex = &s[hex_start..hex_start + hex_end];
98            decode_hex(hex).map_err(|e| MappingError::InvalidJson(e.to_string()))
99        }
100    }
101}
102
103fn decode_hex(s: &str) -> Result<Vec<u8>, &'static str> {
104    if s.len() % 2 != 0 {
105        return Err("hex length not even");
106    }
107    let mut out = Vec::with_capacity(s.len() / 2);
108    let bytes = s.as_bytes();
109    let mut i = 0;
110    while i < bytes.len() {
111        let hi = hex_digit(bytes[i])?;
112        let lo = hex_digit(bytes[i + 1])?;
113        out.push((hi << 4) | lo);
114        i += 2;
115    }
116    Ok(out)
117}
118
119const fn hex_digit(b: u8) -> Result<u8, &'static str> {
120    match b {
121        b'0'..=b'9' => Ok(b - b'0'),
122        b'a'..=b'f' => Ok(b - b'a' + 10),
123        b'A'..=b'F' => Ok(b - b'A' + 10),
124        _ => Err("invalid hex digit"),
125    }
126}
127
128#[cfg(test)]
129#[allow(clippy::expect_used)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn passthrough_round_trip_preserves_bytes() {
135        let sample = alloc::vec![0xDE, 0xAD, 0xBE, 0xEF];
136        let (ct, body) =
137            encode_dds_to_amqp_body(&sample, BodyEncodingMode::PassThrough).expect("encode");
138        assert_eq!(ct, "application/vnd.dds.xcdr2");
139        let parsed = parse_amqp_body(&body, Some(ct)).expect("parse");
140        assert_eq!(parsed, sample);
141    }
142
143    #[test]
144    fn json_round_trip_via_hex_field() {
145        let sample = alloc::vec![0x01, 0x02, 0x03, 0xFE];
146        let (ct, body) = encode_dds_to_amqp_body(&sample, BodyEncodingMode::Json).expect("encode");
147        assert_eq!(ct, "application/json");
148        let parsed = parse_amqp_body(&body, Some(ct)).expect("parse");
149        assert_eq!(parsed, sample);
150    }
151
152    #[test]
153    fn amqp_native_uses_correct_content_type() {
154        let sample = alloc::vec![0xCA, 0xFE];
155        let (ct, _) =
156            encode_dds_to_amqp_body(&sample, BodyEncodingMode::AmqpNative).expect("encode");
157        assert_eq!(ct, "application/vnd.dds.amqp-native");
158    }
159
160    #[test]
161    fn empty_body_yields_error_in_encode() {
162        assert!(matches!(
163            encode_dds_to_amqp_body(&[], BodyEncodingMode::PassThrough),
164            Err(MappingError::EmptyBody)
165        ));
166    }
167
168    #[test]
169    fn empty_body_yields_error_in_parse() {
170        assert!(matches!(
171            parse_amqp_body(&[], None),
172            Err(MappingError::EmptyBody)
173        ));
174    }
175
176    #[test]
177    fn unknown_content_type_falls_back_to_pass_through() {
178        let body = alloc::vec![1, 2, 3];
179        let parsed = parse_amqp_body(&body, Some("application/octet-stream")).expect("parse");
180        assert_eq!(parsed, body);
181    }
182
183    #[test]
184    fn invalid_json_yields_error() {
185        let r = parse_amqp_body(b"{invalid", Some("application/json"));
186        assert!(matches!(r, Err(MappingError::InvalidJson(_))));
187    }
188
189    #[test]
190    fn default_mode_is_pass_through() {
191        assert_eq!(BodyEncodingMode::default(), BodyEncodingMode::PassThrough);
192    }
193}