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}