Skip to main content

ruststream_rumqttc/
message.rs

1//! [`MqttMessage`] and the mapping between `RustStream` headers and MQTT 5 properties.
2//!
3//! User properties carry headers natively; the well-known `content-type`, `reply-to`, and
4//! `correlation-id` headers ride the matching first-class MQTT 5 properties, so no envelope
5//! format is invented and non-Rust peers see plain MQTT messages.
6
7use std::str;
8
9use bytes::Bytes;
10use rumqttc::v5::mqttbytes::QoS;
11use rumqttc::v5::mqttbytes::v5::{Publish, PublishProperties};
12use ruststream::{AckError, HeaderMap, IncomingMessage, OutgoingMessage, Partitioned, Str};
13
14use crate::broker::Link;
15use crate::error::MqttError;
16use crate::filter::Qos;
17#[cfg(feature = "testing")]
18use crate::in_process::InFlight;
19
20/// The header keys the MQTT 5 first-class properties map onto, built once so that mapping a
21/// delivery copies nothing for them.
22const CONTENT_TYPE: Str = Str::from_static("content-type");
23const REPLY_TO: Str = Str::from_static("reply-to");
24const CORRELATION_ID: Str = Str::from_static("correlation-id");
25
26/// The header that carries a message's partition key, which keyed worker lanes
27/// (`workers(n, by_key)`) order by.
28///
29/// MQTT has no partitions, so the key travels as this header, a user property on the wire, and
30/// the publisher sets it. The runtime hashes the value to pick a lane: the messages of one key run
31/// one after another in the order they arrived, and different keys run side by side. A message
32/// without the header takes the next lane in turn and keeps no order with any other.
33///
34/// # Examples
35///
36/// ```
37/// # mod demo {
38/// use ruststream_rumqttc::PARTITION_KEY_HEADER;
39/// use ruststream_rumqttc::prelude::*;
40/// use serde::{Deserialize, Serialize};
41///
42/// #[derive(Deserialize, Serialize, Outgoing)]
43/// #[outgoing(name = "orders/placed")]
44/// struct Order {
45///     id: u64,
46///     tenant: String,
47/// }
48///
49/// /// Eight lanes keyed by tenant: one tenant's orders run in order, different tenants at once.
50/// #[subscriber("orders/placed", workers(8, by_key))]
51/// async fn place(order: &Order) -> HandlerOutcome {
52///     tracing::info!(order.id, tenant = %order.tenant, "order placed");
53///     HandlerOutcome::ack()
54/// }
55///
56/// /// The checkout keys every order it places by its tenant.
57/// #[subscriber("checkout/completed")]
58/// async fn checkout(order: &Order, Out(orders): Out<impl Publisher>) -> HandlerOutcome {
59///     let mut headers = HeaderMap::new();
60///     headers.insert(PARTITION_KEY_HEADER, order.tenant.clone());
61///     if orders.message(order).with_headers(headers).publish().await.is_err() {
62///         return HandlerOutcome::retry();
63///     }
64///     HandlerOutcome::ack()
65/// }
66///
67/// #[ruststream::app]
68/// fn app() -> impl App {
69///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
70///         MqttBroker::new("mqtt://localhost:1883", "orders-svc"),
71///         |b| {
72///             b.include(place);
73///             b.include(checkout).out(DefaultSlot, Publish::default()).build();
74///         },
75///     )
76/// }
77/// # }
78/// # fn main() {}
79/// ```
80pub const PARTITION_KEY_HEADER: &str = "partition-key";
81
82/// A message delivered by an [`MqttSubscriber`](crate::MqttSubscriber).
83///
84/// `ack` acknowledges through the protocol for `QoS` 1 (`PUBACK`) and `QoS` 2 (`PUBREC`, with the
85/// client completing the handshake); `QoS` 0 deliveries report
86/// [`AckError::Unsupported`]. MQTT has no negative acknowledgement, so `nack(requeue = true)`
87/// reports [`AckError::Unsupported`] as well - unacknowledged messages redeliver when the
88/// session resumes - and `nack(requeue = false)` acknowledges (dropping is the only terminal
89/// outcome the protocol offers).
90///
91/// A handler's `HandlerOutcome::retry()` settles through that refused negative acknowledgement, so
92/// it does not retry inside the live connection: the delivery stays unacknowledged and comes back
93/// only when a persistent session resumes. `retry_after` with a retry publisher is the outcome
94/// that retries within the session. The site's
95/// [retry](https://powersemmi.github.io/ruststream-rumqttc/latest/components/settlement/retry/) and
96/// [retry_after](https://powersemmi.github.io/ruststream-rumqttc/latest/components/settlement/retry-after/)
97/// pages show both in a service.
98///
99/// A delivery reports no redelivery count. The protocol carries a duplicate flag and no counter,
100/// so a registration's cap is counted on the framework's retry-count header instead, which the
101/// copies the runtime publishes carry.
102pub struct MqttMessage {
103    payload: Bytes,
104    headers: HeaderMap,
105    topic: String,
106    qos: Qos,
107    /// `None` when this delivery carries no acknowledgement: `QoS` 0, or a fanned-out copy on
108    /// an overlapping filter (the wire ack belongs to exactly one delivery).
109    acker: Option<(Link, Publish)>,
110    /// The test harness's count of this delivery, released when the delivery is dropped, settled
111    /// or not. Only a delivery of the in-process mode carries one, and only with the `testing`
112    /// feature is the field there at all.
113    #[cfg(feature = "testing")]
114    in_flight: Option<InFlight>,
115}
116
117impl std::fmt::Debug for MqttMessage {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("MqttMessage")
120            .field("topic", &self.topic)
121            .field("payload_len", &self.payload.len())
122            .finish_non_exhaustive()
123    }
124}
125
126impl MqttMessage {
127    pub(crate) fn new(topic: String, publish: &Publish, link: Option<Link>) -> Self {
128        let acker = match publish.qos {
129            QoS::AtMostOnce => None,
130            _ => link.map(|link| (link, publish.clone())),
131        };
132        Self {
133            payload: publish.payload.clone(),
134            headers: headers_of(publish),
135            topic,
136            qos: Qos::from_client(publish.qos),
137            acker,
138            #[cfg(feature = "testing")]
139            in_flight: None,
140        }
141    }
142
143    /// This delivery, counted by the test harness until it is dropped.
144    #[cfg(feature = "testing")]
145    pub(crate) fn counted(mut self, in_flight: Option<InFlight>) -> Self {
146        self.in_flight = in_flight;
147        self
148    }
149
150    /// This delivery, no longer counted by the test harness while it is held.
151    #[cfg(feature = "testing")]
152    pub(crate) fn uncounted(mut self) -> Self {
153        if let Some(in_flight) = &mut self.in_flight {
154            in_flight.suspend();
155        }
156        self
157    }
158
159    /// A held delivery a subscription takes, counted again until it is dropped.
160    #[cfg(feature = "testing")]
161    pub(crate) fn recounted(mut self) -> Self {
162        if let Some(in_flight) = &mut self.in_flight {
163            in_flight.resume();
164        }
165        self
166    }
167
168    /// The topic this message was published to (the real topic, never a `$share` filter).
169    #[must_use]
170    pub fn topic(&self) -> &str {
171        &self.topic
172    }
173
174    /// The quality of service this message was delivered at: the lesser of the one it was
175    /// published with and the one its subscription asked for.
176    ///
177    /// A delivery at [`Qos::AtMostOnce`] carries no acknowledgement, so `ack` reports
178    /// [`AckError::Unsupported`] for it.
179    #[must_use]
180    pub const fn qos(&self) -> Qos {
181        self.qos
182    }
183}
184
185impl IncomingMessage for MqttMessage {
186    fn payload(&self) -> &[u8] {
187        &self.payload
188    }
189
190    fn headers(&self) -> &HeaderMap {
191        &self.headers
192    }
193
194    /// The [`PARTITION_KEY_HEADER`] value the publisher set, which keyed worker lanes
195    /// (`workers(n, by_key)`) order by.
196    fn partition_key(&self) -> Option<&[u8]> {
197        self.headers.get(PARTITION_KEY_HEADER)
198    }
199
200    async fn ack(self) -> Result<(), AckError> {
201        let Some((link, publish)) = self.acker else {
202            return Err(AckError::Unsupported);
203        };
204        link.ack(&publish).await
205    }
206
207    async fn nack(self, requeue: bool) -> Result<(), AckError> {
208        if requeue {
209            // MQTT has no negative acknowledgement: an unacked message redelivers only when
210            // the session resumes. Reporting Unsupported is honest; pretending would ack.
211            Err(AckError::Unsupported)
212        } else {
213            self.ack().await
214        }
215    }
216}
217
218/// The key a delivery is ordered by. MQTT has no partitions, so the key is the
219/// [`PARTITION_KEY_HEADER`] the publisher set.
220impl Partitioned for MqttMessage {
221    fn partition_key(&self) -> Option<&[u8]> {
222        IncomingMessage::partition_key(self)
223    }
224}
225
226/// The headers a PUBLISH packet carries: its user properties, plus the well-known headers its
227/// first-class properties hold.
228pub(crate) fn headers_of(publish: &Publish) -> HeaderMap {
229    let mut headers = HeaderMap::new();
230    if let Some(properties) = &publish.properties {
231        for (name, value) in &properties.user_properties {
232            headers.insert(name.clone(), value.clone());
233        }
234        if let Some(content_type) = &properties.content_type {
235            headers.insert(CONTENT_TYPE, content_type.clone());
236        }
237        if let Some(response_topic) = &properties.response_topic {
238            headers.insert(REPLY_TO, response_topic.clone());
239        }
240        if let Some(correlation) = &properties.correlation_data {
241            headers.insert(CORRELATION_ID, correlation.clone());
242        }
243    }
244    headers
245}
246
247/// Whether a media type names text on the wire.
248///
249/// The MQTT 5 payload format indicator is a two-valued answer - unspecified bytes or UTF-8 - so
250/// the question is only whether the media type is a textual one. JSON is, whatever vendor prefix
251/// it carries, and so is every `text/` subtype; everything else is bytes as far as the protocol
252/// is concerned. Parameters after `;` (a charset) say nothing about that and are dropped.
253fn is_text_media_type(content_type: &str) -> bool {
254    let media = content_type
255        .split(';')
256        .next()
257        .unwrap_or_default()
258        .trim()
259        .to_ascii_lowercase();
260    media.starts_with("text/") || media == "application/json" || media.ends_with("+json")
261}
262
263/// Builds the wire properties of an outgoing message from its headers.
264///
265/// `None` when the message carries no headers at all, so a plain message stays property-free on
266/// the wire and takes the protocol's own defaults. The quality of service and the retain flag are
267/// not here: they are protocol fields of the PUBLISH packet, carried as
268/// [`MqttPublishOptions`](crate::MqttPublishOptions) and resolved before the packet is built.
269///
270/// The payload format indicator is decided here rather than declared anywhere, because it follows
271/// the media type this message carries in its `content-type` header, which only the sender puts
272/// there. A message whose media type is textual is published as UTF-8 (`1`), every other one as
273/// unspecified bytes (`0`), and the same header fills the MQTT 5 content type property, so a
274/// non-Rust peer reads both from the packet. A message that names no media type declares neither.
275///
276/// Every header but `correlation-id` becomes an MQTT string, so a header whose name or value is
277/// not [MQTT text](is_mqtt_text) refuses the publish: the packet would otherwise reach the server
278/// rewritten, or not at all.
279pub(crate) fn to_wire_properties<Payload>(
280    msg: &OutgoingMessage<'_, Payload>,
281) -> Result<Option<PublishProperties>, MqttError> {
282    let mut properties = PublishProperties::default();
283    let mut carries_properties = false;
284    for (name, value) in msg.headers().iter() {
285        carries_properties = true;
286        // Binary data on the wire, the one property that takes any bytes.
287        if name == "correlation-id" {
288            properties.correlation_data = Some(Bytes::copy_from_slice(value));
289            continue;
290        }
291        let text = match str::from_utf8(value) {
292            Ok(text) if is_mqtt_text(name) && is_mqtt_text(text) => text.to_owned(),
293            _ => {
294                return Err(MqttError::Publish {
295                    topic: msg.name().to_owned(),
296                    reason: format!(
297                        "header {name:?} is not text an MQTT property carries: its name and value \
298                         must be UTF-8 with no control character and no Unicode non-character"
299                    ),
300                });
301            }
302        };
303        match name {
304            "content-type" => {
305                properties.payload_format_indicator = Some(u8::from(is_text_media_type(&text)));
306                properties.content_type = Some(text);
307            }
308            "reply-to" => properties.response_topic = Some(text),
309            other => properties.user_properties.push((other.to_owned(), text)),
310        }
311    }
312    Ok(carries_properties.then_some(properties))
313}
314
315/// Whether `text` is a string an MQTT packet carries as it is.
316///
317/// MQTT 5 forbids U+0000 in a string and tells senders to keep out the other control characters
318/// and the Unicode non-characters (section 1.5.4). A receiver may close the connection on any of
319/// them, and Mosquitto does, taking every message in flight on that connection with it.
320pub(crate) fn is_mqtt_text(text: &str) -> bool {
321    // Printable ASCII, which almost every header and topic is, answers in one pass over the
322    // bytes; only text beyond it is decoded character by character.
323    text.bytes().all(|byte| matches!(byte, 0x20..=0x7e))
324        || text.chars().all(|c| !c.is_control() && !is_noncharacter(c))
325}
326
327/// The Unicode non-characters: U+FDD0 to U+FDEF, and the last two code points of every plane.
328const fn is_noncharacter(c: char) -> bool {
329    let code = c as u32;
330    (code >= 0xFDD0 && code <= 0xFDEF) || code & 0xFFFE == 0xFFFE
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn well_known_headers_ride_first_class_properties() {
339        let mut headers = HeaderMap::new();
340        headers.insert("content-type", "application/json");
341        headers.insert("reply-to", "replies/1");
342        headers.insert("correlation-id", "corr-1");
343        headers.insert("x-tenant", "acme");
344        let outgoing: OutgoingMessage<'_> =
345            OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
346
347        let properties = to_wire_properties(&outgoing)
348            .expect("the headers are MQTT text")
349            .expect("properties built");
350        assert_eq!(properties.content_type.as_deref(), Some("application/json"));
351        assert_eq!(properties.payload_format_indicator, Some(1));
352        assert_eq!(properties.response_topic.as_deref(), Some("replies/1"));
353        assert_eq!(
354            properties.correlation_data.as_deref(),
355            Some(b"corr-1".as_slice())
356        );
357        assert_eq!(
358            properties.user_properties,
359            vec![("x-tenant".to_owned(), "acme".to_owned())]
360        );
361    }
362
363    #[test]
364    fn plain_messages_stay_property_free() {
365        let outgoing: OutgoingMessage<'_> = OutgoingMessage::new("orders", b"{}".as_slice());
366        assert!(
367            to_wire_properties(&outgoing)
368                .expect("no headers to refuse")
369                .is_none()
370        );
371    }
372
373    /// The indicator follows the media type, and the protocol has only two answers: UTF-8 or
374    /// unspecified bytes. A vendor JSON type is still JSON; a binary codec's type is not.
375    #[test]
376    fn the_payload_format_follows_the_media_type() {
377        for (content_type, expected) in [
378            ("application/json", 1),
379            ("application/json; charset=utf-8", 1),
380            ("application/vnd.acme.order+json", 1),
381            ("text/plain", 1),
382            ("TEXT/CSV", 1),
383            ("application/cbor", 0),
384            ("application/msgpack", 0),
385            ("application/octet-stream", 0),
386        ] {
387            let mut headers = HeaderMap::new();
388            headers.insert("content-type", content_type);
389            let outgoing: OutgoingMessage<'_> =
390                OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
391            let properties = to_wire_properties(&outgoing)
392                .expect("the headers are MQTT text")
393                .expect("properties built");
394            assert_eq!(
395                properties.payload_format_indicator,
396                Some(expected),
397                "{content_type} is {}",
398                if expected == 1 { "text" } else { "bytes" }
399            );
400        }
401    }
402
403    /// A message with no media type says nothing about its format, which is the protocol's own
404    /// default of unspecified bytes.
405    #[test]
406    fn a_message_without_a_media_type_declares_no_payload_format() {
407        let mut headers = HeaderMap::new();
408        headers.insert("x-tenant", "acme");
409        let outgoing: OutgoingMessage<'_> =
410            OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
411
412        let properties = to_wire_properties(&outgoing)
413            .expect("the headers are MQTT text")
414            .expect("properties built");
415        assert_eq!(properties.payload_format_indicator, None);
416    }
417
418    /// Every header a message carries is a user property or a first-class one. The delivery
419    /// arguments are not among them: they are fields of the packet, and a header named after one
420    /// is a header like any other.
421    #[test]
422    fn nothing_is_read_off_the_headers_on_the_way_to_the_wire() {
423        let mut headers = HeaderMap::new();
424        headers.insert("mqtt-qos", "2");
425        let outgoing: OutgoingMessage<'_> =
426            OutgoingMessage::new("states", b"online".as_slice()).with_headers(headers);
427
428        let properties = to_wire_properties(&outgoing)
429            .expect("the headers are MQTT text")
430            .expect("properties built");
431        assert_eq!(
432            properties.user_properties,
433            vec![("mqtt-qos".to_owned(), "2".to_owned())]
434        );
435    }
436
437    /// A header an MQTT string cannot carry refuses the publish rather than reaching the server
438    /// rewritten, or reaching it at all: a server may close the connection on such a packet.
439    #[test]
440    fn a_header_that_is_not_mqtt_text_refuses_the_publish() {
441        for (name, value) in [
442            ("x-binary", &[0x00, 0x80, 0xfe, 0xff][..]),
443            ("x-line", b"one\r\ntwo".as_slice()),
444            ("x-tab", b"a\tb".as_slice()),
445            ("x-nul", b"a\0b".as_slice()),
446            ("x-noncharacter", "a\u{fffe}b".as_bytes()),
447            ("x-noncharacter", "a\u{fdd0}b".as_bytes()),
448            ("content-type", b"text/plain\n".as_slice()),
449            ("reply-to", &[0xc3][..]),
450            ("x-name\n", b"fine".as_slice()),
451        ] {
452            let mut headers = HeaderMap::new();
453            headers.insert(name.to_owned(), Bytes::copy_from_slice(value));
454            let outgoing: OutgoingMessage<'_> =
455                OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
456            let err = to_wire_properties(&outgoing)
457                .expect_err("a header MQTT cannot carry is refused, never rewritten");
458            assert!(
459                matches!(&err, MqttError::Publish { topic, .. } if topic == "orders"),
460                "{name:?}: {err}"
461            );
462        }
463    }
464
465    /// The correlation data is binary on the wire, so any bytes ride it unchanged; and text MQTT
466    /// carries, non-ASCII included, is accepted as it is.
467    #[test]
468    fn binary_correlation_data_and_unicode_text_are_carried() {
469        let mut headers = HeaderMap::new();
470        headers.insert("correlation-id", Bytes::from_static(&[0x00, 0x80, 0xff]));
471        headers.insert("x-city", "Zürich 東京");
472        let outgoing: OutgoingMessage<'_> =
473            OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
474
475        let properties = to_wire_properties(&outgoing)
476            .expect("the headers are MQTT text")
477            .expect("properties built");
478        assert_eq!(
479            properties.correlation_data.as_deref(),
480            Some([0x00, 0x80, 0xff].as_slice())
481        );
482        assert_eq!(
483            properties.user_properties,
484            vec![("x-city".to_owned(), "Zürich 東京".to_owned())]
485        );
486    }
487}