Skip to main content

rithmic_rs/util/
unknown_message.rs

1//! Frames whose `template_id` this crate doesn't map.
2
3use prost::bytes::Bytes;
4use std::fmt;
5
6/// Payload bytes rendered by the [`fmt::Display`] and [`fmt::Debug`] impls
7/// before eliding. [`UnknownTemplateMessage::payload_hex`] is the full form.
8const MAX_RENDERED_BYTES: usize = 32;
9
10/// A frame whose `template_id` this crate has no message definition for.
11///
12/// The payload is kept as received, so a template this crate doesn't map can
13/// still be handled downstream: [`decode_as`](Self::decode_as) decodes it into a
14/// type you generate yourself, and [`payload_hex`](Self::payload_hex) dumps it
15/// for later.
16///
17/// # Examples
18///
19/// A frame off a subscription stream arrives as
20/// [`RithmicMessage::UnknownTemplate`](crate::rti::messages::RithmicMessage::UnknownTemplate)
21/// with `error: None`. Log it, then decode it into a type generated in your own
22/// crate from the `.proto`; [`crate::prost`] is re-exported so the generated
23/// code can't drift from the version this crate decodes with.
24///
25/// ```
26/// use rithmic_rs::prost;
27/// use rithmic_rs::rti::messages::RithmicMessage;
28///
29/// // Generated in your crate by prost-build, once you know what 358 maps to.
30/// #[derive(Clone, PartialEq, prost::Message)]
31/// pub struct Template358 {
32///     #[prost(string, optional, tag = "110100")]
33///     pub symbol: Option<String>,
34/// }
35///
36/// fn on_message(message: &RithmicMessage) {
37///     let RithmicMessage::UnknownTemplate(frame) = message else {
38///         return;
39///     };
40///
41///     // template_id=358 (84 bytes) a2e135054d45535536aae13503434d45…+52B
42///     tracing::warn!(payload = %frame.payload_hex(), "unmapped template: {frame}");
43///
44///     if frame.template_id == 358 {
45///         if let Ok(decoded) = frame.decode_as::<Template358>() {
46///             println!("{:?}", decoded.symbol);
47///         }
48///     }
49/// }
50/// ```
51///
52/// [`payload_hex`](Self::payload_hex) is untruncated, so a frame captured in
53/// production can be replayed in a test through
54/// [`from_payload_hex`](Self::from_payload_hex).
55#[derive(Clone, PartialEq, Eq)]
56#[non_exhaustive]
57pub struct UnknownTemplateMessage {
58    /// The `template_id` read from the frame header.
59    pub template_id: i32,
60    /// The complete message body, exactly as received.
61    ///
62    /// Only the 4-byte big-endian length prefix is stripped, as every other
63    /// decoder here does; it carries nothing beyond `payload.len()`.
64    pub payload: Bytes,
65}
66
67impl UnknownTemplateMessage {
68    /// Build a frame from its `template_id` and body.
69    pub fn new(template_id: i32, payload: Bytes) -> Self {
70        Self {
71            template_id,
72            payload,
73        }
74    }
75
76    /// Decode the payload into a caller-supplied protobuf type.
77    ///
78    /// Generate the type from the `.proto` in your own crate and decode into it.
79    /// Uses the [`crate::prost`] re-export, so types generated against
80    /// `rithmic_rs::prost` are compatible by construction.
81    ///
82    /// # Errors
83    ///
84    /// [`prost::DecodeError`] when the bytes are structurally incompatible with
85    /// `M`, such as a wire-type conflict on a field `M` declares.
86    ///
87    /// `Ok` is not proof the type was guessed right. Protobuf skips fields the
88    /// target doesn't declare, so an unrelated payload usually decodes into a
89    /// mostly-empty value.
90    pub fn decode_as<M: prost::Message + Default>(&self) -> Result<M, prost::DecodeError> {
91        M::decode(self.payload.clone())
92    }
93
94    /// The whole payload as lowercase hex, no truncation, no `0x` prefix.
95    ///
96    /// `Display` elides the payload to stay readable in a log line; this
97    /// doesn't.
98    pub fn payload_hex(&self) -> String {
99        use fmt::Write;
100
101        let mut hex = String::with_capacity(self.payload.len() * 2);
102
103        for byte in &self.payload {
104            // Writing to a String is infallible.
105            let _ = write!(hex, "{byte:02x}");
106        }
107
108        hex
109    }
110
111    /// Rebuild a frame from a [`payload_hex`](Self::payload_hex) dump.
112    ///
113    /// Ignores a leading `0x` and any whitespace, including newlines from a
114    /// wrapped log line. `None` unless `hex` holds an even number of hex
115    /// digits; empty input yields an empty payload.
116    pub fn from_payload_hex(template_id: i32, hex: &str) -> Option<Self> {
117        let hex = hex.trim();
118        let hex = hex
119            .strip_prefix("0x")
120            .or(hex.strip_prefix("0X"))
121            .unwrap_or(hex);
122
123        let digits: Vec<u8> = hex
124            .chars()
125            .filter(|character| !character.is_whitespace())
126            .map(|character| character.to_digit(16).map(|digit| digit as u8))
127            .collect::<Option<_>>()?;
128
129        if digits.len() % 2 != 0 {
130            return None;
131        }
132
133        let payload: Vec<u8> = digits
134            .chunks_exact(2)
135            .map(|pair| (pair[0] << 4) | pair[1])
136            .collect();
137
138        Some(Self::new(template_id, Bytes::from(payload)))
139    }
140}
141
142impl fmt::Display for UnknownTemplateMessage {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        write!(
145            f,
146            "template_id={} ({} bytes)",
147            self.template_id,
148            self.payload.len()
149        )?;
150
151        if self.payload.is_empty() {
152            return Ok(());
153        }
154
155        write!(f, " {}", Hex(&self.payload))
156    }
157}
158
159impl fmt::Debug for UnknownTemplateMessage {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        // The derive would dump the payload as a list of byte literals.
162        f.debug_struct("UnknownTemplateMessage")
163            .field("template_id", &self.template_id)
164            .field("payload_len", &self.payload.len())
165            .field("payload", &format_args!("{}", Hex(&self.payload)))
166            .finish()
167    }
168}
169
170/// Renders bytes as hex, capped at [`MAX_RENDERED_BYTES`].
171struct Hex<'a>(&'a [u8]);
172
173impl fmt::Display for Hex<'_> {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        for byte in self.0.iter().take(MAX_RENDERED_BYTES) {
176            write!(f, "{byte:02x}")?;
177        }
178
179        let remaining = self.0.len().saturating_sub(MAX_RENDERED_BYTES);
180
181        if remaining > 0 {
182            write!(f, "…+{remaining}B")?;
183        }
184
185        Ok(())
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use prost::Message;
192
193    use super::*;
194    use crate::rti::{RequestCancelAllOrders, RithmicOrderNotification};
195
196    fn frame<M: Message>(template_id: i32, message: &M) -> UnknownTemplateMessage {
197        UnknownTemplateMessage {
198            template_id,
199            payload: Bytes::from(message.encode_to_vec()),
200        }
201    }
202
203    fn notification() -> RithmicOrderNotification {
204        RithmicOrderNotification {
205            template_id: 358,
206            basket_id: Some("9214-2".to_string()),
207            symbol: Some("MESU6".to_string()),
208            price: Some(6412.25),
209            ..RithmicOrderNotification::default()
210        }
211    }
212
213    #[test]
214    fn decodes_into_a_caller_supplied_type() {
215        let original = notification();
216
217        let decoded: RithmicOrderNotification = frame(358, &original)
218            .decode_as()
219            .expect("payload round-trips into the matching type");
220
221        assert_eq!(decoded, original);
222    }
223
224    #[test]
225    fn decode_as_reports_structurally_incompatible_bytes() {
226        // template_id (154467) is an int32 everywhere, so sending it as
227        // length-delimited conflicts with what the target declares.
228        // Key = (154467 << 3) | 2, varint-encoded, then a 2-byte string.
229        let payload = vec![0x9a, 0xb6, 0x4b, 0x02, b'h', b'i'];
230
231        let frame = UnknownTemplateMessage {
232            template_id: 358,
233            payload: Bytes::from(payload),
234        };
235
236        assert!(frame.decode_as::<RequestCancelAllOrders>().is_err());
237    }
238
239    #[test]
240    fn decode_as_can_succeed_against_the_wrong_type() {
241        // Guards the documented caveat: unknown fields are skipped, so this
242        // decodes fine and drops everything but template_id.
243        let decoded = frame(358, &notification())
244            .decode_as::<RequestCancelAllOrders>()
245            .expect("unknown fields are skipped, so this decodes");
246
247        assert_eq!(decoded.template_id, 358);
248        assert_eq!(decoded.account_id, None);
249    }
250
251    #[test]
252    fn payload_hex_round_trips_verbatim() {
253        let captured = frame(358, &notification());
254        let hex = captured.payload_hex();
255
256        // Complete, unlike Display.
257        assert_eq!(hex.len(), captured.payload.len() * 2);
258        assert!(hex.chars().all(|character| character.is_ascii_hexdigit()));
259
260        let replayed = UnknownTemplateMessage::from_payload_hex(358, &hex)
261            .expect("payload_hex output parses back");
262
263        assert_eq!(replayed, captured);
264    }
265
266    #[test]
267    fn from_payload_hex_tolerates_copy_paste() {
268        let expected = UnknownTemplateMessage {
269            template_id: 358,
270            payload: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
271        };
272
273        for input in [
274            "deadbeef",
275            "DEADBEEF",
276            "0xdeadbeef",
277            " dead beef\n",
278            "dead\nbeef",
279        ] {
280            assert_eq!(
281                UnknownTemplateMessage::from_payload_hex(358, input).as_ref(),
282                Some(&expected),
283                "{input:?}"
284            );
285        }
286    }
287
288    #[test]
289    fn from_payload_hex_rejects_malformed_input() {
290        // Odd digit count, and a non-hex character.
291        assert_eq!(UnknownTemplateMessage::from_payload_hex(358, "abc"), None);
292        assert_eq!(UnknownTemplateMessage::from_payload_hex(358, "zz"), None);
293    }
294
295    #[test]
296    fn display_elides_a_long_payload() {
297        let frame = UnknownTemplateMessage {
298            template_id: 358,
299            payload: Bytes::from(vec![0xab; MAX_RENDERED_BYTES + 20]),
300        };
301
302        let rendered = frame.to_string();
303
304        assert!(
305            rendered.starts_with("template_id=358 (52 bytes) "),
306            "{rendered}"
307        );
308        assert!(rendered.ends_with("…+20B"), "{rendered}");
309        assert_eq!(frame.payload_hex().len(), 104);
310    }
311}