ruststream_rumqttc/filter.rs
1//! [`MqttTopic`] and [`MqttFilter`]: the two subscription descriptors.
2//!
3//! MQTT names two things. A publisher names a *topic*, which carries no wildcards. A subscriber
4//! names a *topic filter*, where `+` matches one level and `#` matches the rest. The crate keeps
5//! them apart because a retry copy is a publish: a subscription on a topic is reached again by
6//! publishing to that topic, and a subscription on a wildcard filter is reached by publishing to
7//! none of the topics it matches. [`shared`](MqttTopic::shared) wraps either into an MQTT 5
8//! shared subscription (`$share/<group>/<filter>`), which is how competing consumers are
9//! expressed at all.
10
11use std::borrow::Cow;
12use std::fmt;
13use std::future::{Future, ready};
14
15use rumqttc::v5::mqttbytes::{QoS, valid_filter, valid_topic};
16#[cfg(feature = "asyncapi")]
17use ruststream::asyncapi::Bindings;
18use ruststream::{
19 AddressedCopies, FromName, NamedCopies, RedeliveryAddress, RedeliveryAddressed,
20 SubscriptionSource,
21};
22
23#[cfg(feature = "asyncapi")]
24use crate::asyncapi;
25use crate::broker::ConnectedMqttBroker;
26use crate::error::MqttError;
27use crate::message::is_mqtt_text;
28use crate::subscriber::{MqttSubscriber, OnDrop};
29
30/// Delivery quality of service for a subscription or a publish policy.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum Qos {
33 /// Fire and forget; deliveries carry no acknowledgement
34 /// ([`AckError::Unsupported`](ruststream::AckError::Unsupported)).
35 AtMostOnce,
36 /// Acknowledged delivery. The default.
37 #[default]
38 AtLeastOnce,
39 /// Exactly-once handshake (the client completes the second leg automatically).
40 ExactlyOnce,
41}
42
43impl Qos {
44 pub(crate) const fn from_client(qos: QoS) -> Self {
45 match qos {
46 QoS::AtMostOnce => Self::AtMostOnce,
47 QoS::AtLeastOnce => Self::AtLeastOnce,
48 QoS::ExactlyOnce => Self::ExactlyOnce,
49 }
50 }
51
52 pub(crate) fn to_client(self) -> QoS {
53 match self {
54 Self::AtMostOnce => QoS::AtMostOnce,
55 Self::AtLeastOnce => QoS::AtLeastOnce,
56 Self::ExactlyOnce => QoS::ExactlyOnce,
57 }
58 }
59
60 /// The number the protocol and the `mqtt` binding both use for this guarantee.
61 #[cfg(feature = "asyncapi")]
62 pub(crate) const fn level(self) -> u8 {
63 match self {
64 Self::AtMostOnce => 0,
65 Self::AtLeastOnce => 1,
66 Self::ExactlyOnce => 2,
67 }
68 }
69}
70
71/// Rejects a share group no broker would accept, before any I/O.
72fn validate_share_group(group: Option<&String>) -> Result<(), MqttError> {
73 if let Some(group) = group
74 && (group.is_empty() || group.contains(['/', '+', '#']))
75 {
76 return Err(MqttError::Invalid(format!(
77 "'{group}' is not a valid share group name"
78 )));
79 }
80 Ok(())
81}
82
83/// A subscription descriptor for one MQTT topic filter, wildcards included.
84///
85/// One such subscription reads every topic its filter matches, so it addresses none of them: the
86/// mount site names where a deferred retry copy goes. [`MqttTopic`] is the descriptor for a
87/// subscription that names a single topic and answers for its own copies.
88///
89/// ```
90/// # mod demo {
91/// use ruststream_rumqttc::prelude::*;
92/// use serde::Deserialize;
93///
94/// #[derive(Deserialize)]
95/// struct Telemetry {
96/// temperature: f64,
97/// }
98///
99/// #[subscriber(MqttFilter::new("devices/+/telemetry").qos(Qos::AtLeastOnce).shared("workers"))]
100/// async fn record(telemetry: &Telemetry, Ctx(topic): Ctx<DeliveryTopic>) -> HandlerOutcome {
101/// tracing::info!(%topic, telemetry.temperature, "reading recorded");
102/// HandlerOutcome::ack()
103/// }
104///
105/// #[ruststream::app]
106/// fn app() -> impl App {
107/// RustStream::new(AppInfo::new("fleet", "0.1.0")).with_broker(
108/// MqttBroker::new("mqtt://localhost:1883", "fleet-svc"),
109/// |b| {
110/// // The filter names no single topic, so the mount site says where a retry copy goes.
111/// b.include(record)
112/// .out_retry(Publish::default())
113/// .to("devices/retry/telemetry");
114/// },
115/// )
116/// }
117/// # }
118/// # fn main() {}
119/// ```
120///
121/// A publish transform names the destination per delivery instead of once. Naming a topic with
122/// `.to(..)` and composing a naming transform are mutually exclusive: writing both does not
123/// compile.
124///
125/// ```
126/// # mod demo {
127/// use ruststream::runtime::{Names, Outgoing, PublishContext};
128/// use ruststream_rumqttc::prelude::*;
129/// use serde::Deserialize;
130///
131/// #[derive(Deserialize)]
132/// struct Command {
133/// action: String,
134/// }
135///
136/// #[subscriber(MqttFilter::new("devices/+/commands").qos(Qos::AtLeastOnce))]
137/// async fn execute(command: &Command, Ctx(topic): Ctx<DeliveryTopic>) -> HandlerOutcome {
138/// tracing::info!(command.action, %topic, "running");
139/// HandlerOutcome::ack()
140/// }
141///
142/// /// Sends every copy back to the topic its own delivery arrived on, which is the one topic of
143/// /// the filter's many that this message belongs to.
144/// struct ToDeliveryTopic;
145///
146/// impl<Options> PublishTransform<ForReply<MqttContext>, Options> for ToDeliveryTopic {
147/// type Destination = Names;
148///
149/// fn apply(
150/// &self,
151/// out: &mut Outgoing<'_>,
152/// _options: &mut Option<Options>,
153/// cx: &PublishContext<'_, MqttContext>,
154/// ) {
155/// out.set_name(cx.context(DeliveryTopic).to_owned());
156/// }
157/// }
158///
159/// #[ruststream::app]
160/// fn app() -> impl App {
161/// RustStream::new(AppInfo::new("commands", "0.1.0")).with_broker(
162/// MqttBroker::new("mqtt://localhost:1883", "commands-svc"),
163/// |b| {
164/// // A copy returns to the device it came from rather than to one topic for the fleet.
165/// b.include(execute)
166/// .max_attempts(nonzero!(5u32))
167/// .dead_letter("dead/commands")
168/// .out_retry(Publish::default())
169/// .transform(ToDeliveryTopic);
170/// },
171/// )
172/// }
173/// # }
174/// # fn main() {}
175/// ```
176#[derive(Debug, Clone, PartialEq, Eq)]
177#[must_use]
178pub struct MqttFilter {
179 filter: String,
180 qos: Qos,
181 shared: Option<String>,
182}
183
184impl MqttFilter {
185 /// Names the topic filter, with wildcards as the protocol defines them.
186 pub fn new(filter: impl Into<String>) -> Self {
187 Self {
188 filter: filter.into(),
189 qos: Qos::default(),
190 shared: None,
191 }
192 }
193
194 /// Sets the delivery quality of service. Defaults to [`Qos::AtLeastOnce`].
195 pub fn qos(mut self, qos: Qos) -> Self {
196 self.qos = qos;
197 self
198 }
199
200 /// Makes this an MQTT 5 shared subscription in `group`: the broker distributes matching
201 /// messages across the group's consumers instead of fanning out to each.
202 pub fn shared(mut self, group: impl Into<String>) -> Self {
203 self.shared = Some(group.into());
204 self
205 }
206
207 /// The plain topic filter (without any share group).
208 #[must_use]
209 pub fn filter(&self) -> &str {
210 &self.filter
211 }
212
213 pub(crate) const fn qos_value(&self) -> Qos {
214 self.qos
215 }
216
217 /// The filter as subscribed on the wire (`$share/<group>/<filter>` when shared).
218 pub(crate) fn wire_filter(&self) -> String {
219 self.shared.as_ref().map_or_else(
220 || self.filter.clone(),
221 |group| format!("$share/{group}/{}", self.filter),
222 )
223 }
224
225 /// Rejects descriptors that cannot form a subscription, before any I/O. The client's own
226 /// send-path error cannot say why a request failed, so validation happens here.
227 pub(crate) fn validate(&self) -> Result<(), MqttError> {
228 if !valid_filter(&self.filter) || !is_mqtt_text(&self.filter) {
229 return Err(MqttError::Invalid(format!(
230 "'{}' is not a valid MQTT topic filter",
231 self.filter
232 )));
233 }
234 validate_share_group(self.shared.as_ref())
235 }
236}
237
238/// A topic filter is all this descriptor needs to exist, so the mount site may supply it: a
239/// definition written `#[subscriber(MqttFilter)]` takes its filter from `name(..)` there, and the
240/// quality of service and share group stay the defaults.
241impl FromName for MqttFilter {
242 fn from_name(name: impl Into<Cow<'static, str>>) -> Self {
243 Self::new(name.into().into_owned())
244 }
245}
246
247/// A subscription descriptor for one MQTT topic.
248///
249/// A topic carries no wildcard, so it is a name a publisher can use: the subscription is reached
250/// again by publishing to it, share group or not, because the group takes that copy between its
251/// members. That is what lets this descriptor answer for its own retry copies, and what a
252/// subscription written with `+` or `#` cannot do - [`MqttFilter`] is the descriptor for those.
253///
254/// ```
255/// # mod demo {
256/// use std::time::Duration;
257///
258/// use ruststream_rumqttc::prelude::*;
259/// use serde::Deserialize;
260///
261/// #[derive(Deserialize)]
262/// struct Reading {
263/// device: String,
264/// temperature: f64,
265/// }
266///
267/// #[subscriber(MqttTopic::new("devices/dev42/telemetry").shared("workers"))]
268/// async fn store(reading: &Reading) -> HandlerOutcome {
269/// if reading.temperature.is_nan() {
270/// // A sensor glitch: ask for the reading again in five seconds.
271/// return HandlerOutcome::retry_after(Duration::from_secs(5));
272/// }
273/// tracing::info!(reading.device, reading.temperature, "reading stored");
274/// HandlerOutcome::ack()
275/// }
276///
277/// #[ruststream::app]
278/// fn app() -> impl App {
279/// RustStream::new(AppInfo::new("telemetry", "0.1.0")).with_broker(
280/// MqttBroker::new("mqtt://localhost:1883", "telemetry-svc"),
281/// |b| {
282/// // A topic addresses its own retry copies, so the mount site names no destination.
283/// b.include(store)
284/// .max_attempts(nonzero!(5u32))
285/// .dead_letter("dead/telemetry");
286/// },
287/// )
288/// }
289/// # }
290/// # fn main() {}
291/// ```
292#[derive(Clone, PartialEq, Eq)]
293#[must_use]
294pub struct MqttTopic(MqttFilter);
295
296impl fmt::Debug for MqttTopic {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 f.debug_struct("MqttTopic")
299 .field("topic", &self.0.filter)
300 .field("qos", &self.0.qos)
301 .field("shared", &self.0.shared)
302 .finish()
303 }
304}
305
306impl MqttTopic {
307 /// Names the topic. A value carrying a wildcard is refused when the subscription opens,
308 /// naming [`MqttFilter`] as the descriptor that takes one.
309 pub fn new(topic: impl Into<String>) -> Self {
310 Self(MqttFilter::new(topic))
311 }
312
313 /// Sets the delivery quality of service. Defaults to [`Qos::AtLeastOnce`].
314 pub fn qos(mut self, qos: Qos) -> Self {
315 self.0 = self.0.qos(qos);
316 self
317 }
318
319 /// Makes this an MQTT 5 shared subscription in `group`: the broker distributes matching
320 /// messages across the group's consumers instead of fanning out to each.
321 pub fn shared(mut self, group: impl Into<String>) -> Self {
322 self.0 = self.0.shared(group);
323 self
324 }
325
326 /// The topic this subscribes to (without any share group).
327 #[must_use]
328 pub fn topic(&self) -> &str {
329 self.0.filter()
330 }
331
332 #[cfg(feature = "asyncapi")]
333 pub(crate) const fn qos_value(&self) -> Qos {
334 self.0.qos_value()
335 }
336
337 /// The descriptor as a filter, which is the form a subscription opens with on the wire.
338 pub(crate) fn into_filter(self) -> MqttFilter {
339 self.0
340 }
341
342 /// Rejects a value no publisher could name, before any I/O. A topic is a string the service
343 /// may read from its configuration, so which of the two descriptors fits is the author's
344 /// declaration and this is what holds them to it.
345 pub(crate) fn validate(&self) -> Result<(), MqttError> {
346 // The client reads an empty string as a valid topic; a broker does not.
347 if self.0.filter.is_empty() {
348 return Err(MqttError::Invalid(
349 "an MQTT topic must not be empty".to_owned(),
350 ));
351 }
352 if !valid_topic(&self.0.filter) {
353 return Err(MqttError::Invalid(format!(
354 "'{}' is not an MQTT topic: wildcards are subscribe-only, so a filter carrying \
355 '+' or '#' subscribes with MqttFilter instead",
356 self.0.filter
357 )));
358 }
359 if !is_mqtt_text(&self.0.filter) {
360 return Err(MqttError::Invalid(format!(
361 "{:?} is not an MQTT topic: it carries a control character or a Unicode \
362 non-character",
363 self.0.filter
364 )));
365 }
366 validate_share_group(self.0.shared.as_ref())
367 }
368
369 /// The topic a publisher reaches this subscription on.
370 fn address(&self) -> Result<RedeliveryAddress, MqttError> {
371 // The copy path promises that the reported address reaches this subscription, so a value
372 // that is not a topic is refused here rather than published to.
373 self.validate()?;
374 Ok(RedeliveryAddress::new(self.0.filter.clone()))
375 }
376}
377
378/// A topic is all this descriptor needs to exist, so the mount site may supply it: a definition
379/// written `#[subscriber(MqttTopic)]` takes its topic from `name(..)` there, and the quality of
380/// service and share group stay the defaults.
381impl FromName for MqttTopic {
382 fn from_name(name: impl Into<Cow<'static, str>>) -> Self {
383 Self::new(name.into().into_owned())
384 }
385}
386
387impl SubscriptionSource<ConnectedMqttBroker> for MqttTopic {
388 type Subscriber = MqttSubscriber;
389 /// One topic is one destination, so the descriptor says where a copy of a delivery reaches
390 /// this subscription again and the mount site names nothing.
391 type Copies = AddressedCopies;
392
393 fn name(&self) -> &str {
394 self.topic()
395 }
396
397 /// The app's subscription stays in the session when the service stops: its drop leaves the
398 /// filter on the server.
399 async fn subscribe(self, connected: &ConnectedMqttBroker) -> Result<MqttSubscriber, MqttError> {
400 connected.open_topic(self, OnDrop::Keep).await
401 }
402
403 /// The quality of service this subscription receives at, which is the consumer's own setting
404 /// and belongs to the operation rather than the channel.
405 #[cfg(feature = "asyncapi")]
406 fn operation_bindings(&self) -> Bindings {
407 asyncapi::receive_operation(self.qos_value())
408 }
409
410 /// The MQTT 5 properties every delivery on this subscription is mapped through.
411 #[cfg(feature = "asyncapi")]
412 fn message_bindings(&self) -> Bindings {
413 asyncapi::message()
414 }
415}
416
417/// The plain topic, never the share group's wire form: `$share/<group>/<topic>` is a
418/// subscribe-side name, and a publish to it would reach a topic of that literal spelling.
419impl RedeliveryAddressed<ConnectedMqttBroker> for MqttTopic {
420 fn redelivery_address(
421 &self,
422 _connected: &ConnectedMqttBroker,
423 ) -> impl Future<Output = Result<RedeliveryAddress, MqttError>> + Send {
424 ready(self.address())
425 }
426}
427
428impl SubscriptionSource<ConnectedMqttBroker> for MqttFilter {
429 type Subscriber = MqttSubscriber;
430 /// A filter matches many topics and is a topic itself only by accident, so the descriptor
431 /// addresses no copy and the mount site names where one goes.
432 type Copies = NamedCopies;
433
434 fn name(&self) -> &str {
435 self.filter()
436 }
437
438 /// The app's subscription stays in the session when the service stops: its drop leaves the
439 /// filter on the server.
440 async fn subscribe(self, connected: &ConnectedMqttBroker) -> Result<MqttSubscriber, MqttError> {
441 connected.open_filter(self, OnDrop::Keep).await
442 }
443
444 /// The quality of service this subscription receives at, which is the consumer's own setting
445 /// and belongs to the operation rather than the channel.
446 #[cfg(feature = "asyncapi")]
447 fn operation_bindings(&self) -> Bindings {
448 asyncapi::receive_operation(self.qos_value())
449 }
450
451 /// The MQTT 5 properties every delivery on this subscription is mapped through.
452 #[cfg(feature = "asyncapi")]
453 fn message_bindings(&self) -> Bindings {
454 asyncapi::message()
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn invalid_filters_are_rejected_before_io() {
464 assert!(MqttFilter::new("a/#/b").validate().is_err());
465 assert!(MqttFilter::new("").validate().is_err());
466 }
467
468 /// A server may close the connection on a string with a control character in it, so neither
469 /// descriptor sends one.
470 #[test]
471 fn a_control_character_is_rejected_before_io() {
472 assert!(MqttFilter::new("devices/\n/+").validate().is_err());
473 assert!(MqttTopic::new("devices/a\tb").validate().is_err());
474 assert!(MqttTopic::new("devices/\u{fffe}").validate().is_err());
475 assert!(MqttTopic::new("devices/zürich").validate().is_ok());
476 }
477
478 #[test]
479 fn invalid_share_groups_are_rejected_before_io() {
480 assert!(MqttFilter::new("a").shared("g/1").validate().is_err());
481 assert!(MqttFilter::new("a").shared("").validate().is_err());
482 assert!(MqttTopic::new("a").shared("g/1").validate().is_err());
483 }
484
485 #[test]
486 fn shared_filters_wrap_on_the_wire_only() {
487 let filter = MqttFilter::new("orders/+").shared("workers");
488 assert_eq!(filter.filter(), "orders/+");
489 assert_eq!(filter.wire_filter(), "$share/workers/orders/+");
490 }
491
492 /// A deferred retry is published under the reported address, so the address has to be a topic
493 /// a publisher can name. The descriptor that answers one takes only topics, and says which
494 /// descriptor takes a filter when it meets one.
495 #[test]
496 fn a_topic_is_the_address_a_retry_is_published_to() {
497 assert_eq!(
498 MqttTopic::new("devices/dev42/telemetry")
499 .address()
500 .expect("a topic is its own address"),
501 RedeliveryAddress::new("devices/dev42/telemetry")
502 );
503 for wildcard in ["devices/+/telemetry", "devices/#"] {
504 let refused = MqttTopic::new(wildcard)
505 .address()
506 .expect_err("a filter is not an address");
507 assert!(
508 refused.to_string().contains("MqttFilter"),
509 "the refusal names the descriptor that takes a filter: {refused}"
510 );
511 }
512 assert!(MqttTopic::new("").address().is_err());
513 }
514
515 /// A share group is subscribe-side spelling. The address is the plain topic, where a publish
516 /// reaches the group and one member takes it.
517 #[test]
518 fn a_shared_subscription_is_reached_through_its_plain_topic() {
519 assert_eq!(
520 MqttTopic::new("jobs")
521 .shared("workers")
522 .address()
523 .expect("a topic is its own address"),
524 RedeliveryAddress::new("jobs")
525 );
526 }
527
528 /// A name the mount site supplies builds the same descriptor a service writes inline, so
529 /// `#[subscriber(MqttFilter)]` and `MqttFilter::new(..)` reach one subscription.
530 #[test]
531 fn a_descriptor_built_from_a_name_alone_is_the_plain_one() {
532 assert_eq!(
533 MqttFilter::from_name("devices/+/telemetry"),
534 MqttFilter::new("devices/+/telemetry")
535 );
536 assert_eq!(
537 MqttTopic::from_name("devices/dev42/telemetry"),
538 MqttTopic::new("devices/dev42/telemetry")
539 );
540 }
541}