Skip to main content

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}