diff --git a/src/lib.rs b/src/lib.rs index a5477f7..2fe8cf4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -102,7 +102,7 @@ pub use crate::ssl_options::*; //{SslOptions, SslOptionsBuilder}; pub use crate::subscribe_options::*; //{SubscribeOptions}; pub use crate::token::*; //{Token} pub use crate::topic::*; //{Topic, TopicFilter, TopicMatcher}; -pub use crate::topic_matcher::TopicMatcher; +pub use crate::topic_matcher::TopicMatcherExt; pub use crate::types::*; //... pub use crate::will_options::*; //{WillOptions, WillOptionsBuilder}; //{Result, Error, ErrorKind}; diff --git a/src/topic_matcher.rs b/src/topic_matcher.rs index 1f5949b..e08ac2b 100644 --- a/src/topic_matcher.rs +++ b/src/topic_matcher.rs @@ -1,12 +1,12 @@ // topic_matcher.rs // -// A collection for matching a topic to a set of filters. +// Code to match MQTT topics to filters that may contain wildcards. // // This file is part of the Eclipse Paho MQTT Rust Client library. // /******************************************************************************* - * Copyright (c) 2021-2022 Frank Pagliughi + * Copyright (c) 2024 Altair Bueno * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 @@ -18,388 +18,210 @@ * http://www.eclipse.org/org/documents/edl-v10.php. * * Contributors: - * Frank Pagliughi - initial implementation and documentation + * Altair Bueno - initial implementation and documentation *******************************************************************************/ -// TODO: Remove for production -#![allow(dead_code)] - //! Code to match MQTT topics to filters that may contain wildcards. -//! - -use std::collections::HashMap; -/// A collection of topic filters to arbitrary objects. +/// Checks if a filter matches a given topic. /// -/// This can be used to get an iterator to all items that have a filter that -/// matches a topic. To test against a single filter, see -/// [`TopicFilter`](crate::TopicFilter). This collection is more commonly used -/// when there are a nuber of filters and each needs to be associated with a -/// particular action or piece of data. Note, though, that a single incoming -/// topic could match against several items in the collection. For example, -/// the topic: -/// data/temperature/engine +/// # Example /// -/// Could match against the filters: -/// data/temperature/engine -/// data/temperature/# -/// data/+/engine +/// ``` +/// use paho_mqtt::topic_matcher::matches; /// -/// Thus, the collection gives an iterator for the items matching a topic. -/// -/// A common use for this would be to store callbacks to proces incoming -/// messages based on topics. +/// assert!(matches("a/+/c", "a/b/c")); +/// assert!(matches("a/#", "a/b/d")); +/// ``` +pub fn matches(filter: &str, topic: &str) -> bool { + matches_iter(filter.split('/'), topic.split('/')) +} + +/// Checks if a (splitted) filter matches a given (splitted) topic. /// -/// This code was adapted from the Eclipse Python `MQTTMatcher` class: -/// +/// # Example /// -/// which use a prefix tree (trie) to store the values. +/// ``` +/// use paho_mqtt::topic_matcher::matches_iter; /// +/// assert!(matches_iter(["a", "+", "c"], ["a", "b", "c"])); +/// assert!(matches_iter(["a", "#"], ["a", "b", "d"])); +/// ``` +pub fn matches_iter<'a>( + filter: impl IntoIterator, + topic: impl IntoIterator, +) -> bool { + let mut filter = filter.into_iter().peekable(); + let mut topic = topic.into_iter().peekable(); + + // See https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901246 + if matches!(filter.peek(), Some(&"#" | &"+")) + && matches!(topic.peek(), Some(x) if x.starts_with('$')) + { + return false; + } -/// A collection of topic filters that can compare against a topic to -/// produce an iterator of all matched items. + loop { + let filter_level = filter.next(); + let topic_level = topic.next(); + match (filter_level, topic_level) { + // Exhausted both filter and topic + (None, None) => return true, + // Wildcard on filter + (Some("#"), _) => return true, + // Single level wildcard on filter + (Some("+"), Some(_)) => continue, + // Equal levels + (Some(filter), Some(topic)) if filter == topic => continue, + // Otherwise, no match + _ => return false, + } + } +} + +/// Extension trait for map types and tuple iterators that allows to filter +/// entries by matching a MQTT topic. /// -/// This is particularly useful at creating a lookup table of callbacks or -/// individual channels for subscriptions, especially when the subscriptions -/// contain wildcards. +/// # Example /// -/// Note, however that there might be an issue with overlapped subscriptions -/// where callbacks are invoked multiple times for a message that matches -/// more than one subscription. +/// ``` +/// use std::collections::HashMap; +/// use std::collections::HashSet; +/// use paho_mqtt::topic_matcher::TopicMatcher as _; /// -/// When using MQTT v5, subscription identifiers would be more efficient -/// and solve the problem of multiple overlapped callbacks. See: -/// +/// let mut matcher = HashMap::<&str, &str>::new(); +/// matcher.insert("00:00:00:00:00:00/+/+/rpc", "_/device_type/systemid/_"); +/// matcher.insert("00:00:00:00:00:00/+/+/+/rpc", "_/device_type/systemid/zoneid/_"); +/// matcher.insert("00:00:00:00:00:00/+/rpc", "_/device_type/_"); +/// matcher.insert("00:00:00:00:00:00/rpc", ""); /// -pub struct TopicMatcher { - root: Node, +/// let topic = "00:00:00:00:00:00/humidifier/1/rpc"; +/// let matches: HashSet<_> = matcher.matches(topic).collect(); +/// assert_eq!( +/// matches, +/// HashSet::from([("00:00:00:00:00:00/+/+/rpc", "_/device_type/systemid/_")]) +/// ); +/// ``` +pub trait TopicMatcherExt { + /// The key type returned by the iterator. + type Key; + /// The value type returned by the iterator. + type Value; + + /// Matches the given topic against the keys of the map and returns an + /// iterator over the matching entries. Keys of the map are expected to + /// be MQTT topic patterns and may contain wildcards. + fn matches<'topic>( + self, + topic: &'topic str, + ) -> impl Iterator + 'topic + where + Self: 'topic; } -impl TopicMatcher { - /// Creates a new, empty, topic matcher collection. - pub fn new() -> Self { - Self::default() - } - - /// Determines if the collection contains no values. - pub fn is_empty(&self) -> bool { - self.root.is_empty() - } - - /// Inserts a new topic filter into the collection. - pub fn insert(&mut self, key: S, val: T) +impl TopicMatcherExt for C +where + C: IntoIterator, + K: AsRef, +{ + type Key = K; + type Value = V; + + fn matches<'topic>( + self, + topic: &'topic str, + ) -> impl Iterator + 'topic where - S: Into, + Self: 'topic, { - let key = key.into(); - let mut node = &mut self.root; - - for sym in key.split('/') { - node = match sym { - "+" => node.plus_wild.get_or_insert(Box::>::default()), - "#" => node.pound_wild.get_or_insert(Box::>::default()), - sym => node.children.entry(sym.to_string()).or_default(), - } - } - // We've either found or created nodes down to here. - node.content = Some((key, val)); + self.into_iter() + .filter(move |(pattern, _)| matches(pattern.as_ref(), topic)) } +} - /// Removes the entry, returning the value for it, if found. - pub fn remove(&mut self, key: &str) -> Option { - // TODO: If the node is empty after removing the item, we should - // remove the node and all empty nodes above it. - let mut node = &mut self.root; - for sym in key.split('/') { - let node_opt = match sym { - "+" => node.plus_wild.as_deref_mut(), - "#" => node.pound_wild.as_deref_mut(), - sym => node.children.get_mut(sym), - }; - node = match node_opt { - Some(node) => node, - None => return None, - }; - } - node.content.take().map(|(_, v)| v) - } +#[cfg(test)] +mod test { + use super::*; - /// Gets a reference to a value from the collection using an exact - /// filter match. - pub fn get(&self, key: &str) -> Option<&T> { - let mut node = &self.root; - for sym in key.split('/') { - let node_opt = match sym { - "+" => node.plus_wild.as_deref(), - "#" => node.pound_wild.as_deref(), - sym => node.children.get(sym), - }; - node = match node_opt { - Some(node) => node, - None => return None, - }; - } - node.content.as_ref().map(|(_, v)| v) + #[test] + fn assert_that_no_wildcards_matches() { + assert!(matches("a/b/c", "a/b/c")); + assert!(matches("foo/bar", "foo/bar")); } - - /// Gets a mutable mutable reference to a value from the collection - /// using an exact filter match. - pub fn get_mut(&mut self, key: &str) -> Option<&mut T> { - let mut node = &mut self.root; - for sym in key.split('/') { - let node_opt = match sym { - "+" => node.plus_wild.as_deref_mut(), - "#" => node.pound_wild.as_deref_mut(), - sym => node.children.get_mut(sym), - }; - node = match node_opt { - Some(node) => node, - None => return None, - }; - } - node.content.as_mut().map(|(_, v)| v) + #[test] + fn assert_that_plus_wildcard_matches() { + assert!(matches("a/+/c", "a/b/c")); + assert!(matches("foo/+/baz", "foo/bar/baz")); } - - /// Gets an iterator for all the matches to the specified topic - pub fn matches<'a, 'b>(&'a self, topic: &'b str) -> MatchIter<'a, 'b, T> { - MatchIter::new(&self.root, topic) + #[test] + fn assert_that_leading_plus_wildcard_matches() { + assert!(matches("+/b/c", "a/b/c")); } - - /// Gets a mutable iterator for all the matches to the specified topic - pub fn matches_mut<'a, 'b>(&'a mut self, topic: &'b str) -> MatchIterMut<'a, 'b, T> { - MatchIterMut::new(&mut self.root, topic) + #[test] + fn assert_that_trailing_plus_wildcard_matches() { + assert!(matches("a/b/+", "a/b/c")); + assert!(matches("foo/+", "foo/bar")); } - - /// Determines if the topic matches any of the filters in the collection. - pub fn has_match(&self, topic: &str) -> bool { - self.matches(topic).next().is_some() + #[test] + fn assert_that_hash_wildcard_matches_none_level() { + assert!(matches("a/b/#", "a/b")); } -} - -// We manually implement Default, otherwise the derived one would -// require T: Default. - -impl Default for TopicMatcher { - /// Create an empty TopicMatcher collection. - fn default() -> Self { - TopicMatcher { - root: Node::default(), - } + #[test] + fn assert_that_hash_wildcard_matches_single_level() { + assert!(matches("a/b/#", "a/b/c")); } -} - -/// A single node in the topic matcher collection. -/// -/// A terminal (leaf) node has some `content`, whereas intermediate nodes -/// do not. We also cache the full topic at the leaf. This should allow for -/// more efficient searches through the collection, so that the iterators -/// don't have to keep the stack of keys that lead down to the final leaf. -/// -/// Note that although we could put the wildcard keys into the `children` -/// map, we specifically have separate fields for them. That allows us to -/// have separate mutable references for each, allowing for a mutable -/// iterator. -struct Node { - /// The value that matches the topic at this node, if any. - content: Option<(String, T)>, - /// The explicit, non-wildcardchild nodes mapped by the next field of - /// the topic. - children: HashMap>, - /// Matches against the '+' wildcard - plus_wild: Option>>, - /// Matches against the (terminating) '#' wildcard - /// TODO: This is a terminating leaf. We can insert just a value, - /// instad of a Node. - pound_wild: Option>>, -} - -impl Node { - /// Determines if the node does not contain a value. - /// - /// This is a relatively simplistic implementation indicating that the - /// node's content and children are empty. Technically, the node could - /// contain a collection of children that are empty, which might be - /// considered an "empty" state. But not here. - fn is_empty(&self) -> bool { - self.content.is_none() - && self.children.is_empty() - && self.plus_wild.is_none() - && self.pound_wild.is_none() + #[test] + fn assert_that_hash_wildcard_matches_multiple_levels() { + assert!(matches("a/b/#", "a/b/c/d")); } -} - -// We manually implement Default, otherwise the derived one would -// require T: Default. - -impl Default for Node { - /// Creates a default, empty node. - fn default() -> Self { - Node { - content: None, - children: HashMap::new(), - plus_wild: None, - pound_wild: None, - } + #[test] + fn assert_that_single_hash_matches_all() { + assert!(matches("#", "foo/bar/baz")); + assert!(matches("#", "/foo/bar")); + assert!(matches("/#", "/foo/bar")); } -} - -/// Iterator for the topic matcher collection. -/// This is created from a specific topic string and will find the contents -/// of all the matching filters in the collection. -/// Lifetimes: -/// 'a - The matcher collection -/// 'b - The original topic string -/// -/// We keep a stack of nodes that still need to be searched. For each node, -/// there is also a stack of keys for that node to search. The keys are kept -/// in reverse order, where the next ket to be searched can be popped off the -/// back of the vector. -pub struct MatchIter<'a, 'b, T> { - // The nodes still to be processed - nodes: Vec<(&'a Node, Vec<&'b str>)>, -} - -impl<'a, 'b, T> MatchIter<'a, 'b, T> { - fn new(node: &'a Node, topic: &'b str) -> Self { - let syms: Vec<_> = topic.rsplit('/').collect(); - Self { - nodes: vec![(node, syms)], - } + #[test] + fn assert_that_plus_and_hash_wildcards_matches() { + assert!(matches("foo/+/#", "foo/bar/baz")); + assert!(matches("A/B/+/#", "A/B/B/C")); } -} - -impl<'a, 'b, T> Iterator for MatchIter<'a, 'b, T> { - type Item = &'a (String, T); - - /// Gets the next value from a key filter that matches the iterator's topic. - fn next(&mut self) -> Option { - let (node, mut syms) = match self.nodes.pop() { - Some(val) => val, - None => return None, - }; - - let sym = match syms.pop() { - Some(sym) => sym, - None => return node.content.as_ref(), - }; - - if let Some(child) = node.children.get(sym) { - self.nodes.push((child, syms.clone())); - } - - if let Some(child) = node.plus_wild.as_ref() { - self.nodes.push((child, syms)) - } - - if let Some(child) = node.pound_wild.as_ref() { - // By protocol definition, a '#' must be a terminating leaf. - return child.content.as_ref(); - } - - self.next() + #[test] + fn assert_that_sys_topic_matches() { + assert!(matches("$SYS/bar", "$SYS/bar")); } -} - -/// Mutable iterator for the topic matcher collection. -/// This is created from a specific topic string and will find the contents -/// of all the matching filters in the collection. -/// Lifetimes: -/// 'a - The matcher collection -/// 'b - The original topic string -pub struct MatchIterMut<'a, 'b, T> { - // The nodes still to be processed - nodes: Vec<(&'a mut Node, Vec<&'b str>)>, -} - -impl<'a, 'b, T> MatchIterMut<'a, 'b, T> { - fn new(node: &'a mut Node, topic: &'b str) -> Self { - let syms: Vec<_> = topic.rsplit('/').collect(); - Self { - nodes: vec![(node, syms)], - } + #[test] + fn assert_that_non_first_levels_with_dollar_sign_matches_hash_wildcard() { + assert!(matches("foo/#", "foo/$bar")); } -} - -impl<'a, 'b, T> Iterator for MatchIterMut<'a, 'b, T> { - type Item = (&'a String, &'a mut T); - - /// Gets the next value from a key filter that matches the iterator's topic. - fn next(&mut self) -> Option { - let (node, mut syms) = match self.nodes.pop() { - Some(val) => val, - None => return None, - }; - - let sym = match syms.pop() { - Some(sym) => sym, - None => return node.content.as_mut().map(|(k, v)| (&*k, v)), - }; - - if let Some(child) = node.children.get_mut(sym) { - self.nodes.push((child, syms.clone())); - } - - if let Some(child) = node.plus_wild.as_mut() { - self.nodes.push((child, syms)) - } - - if let Some(child) = node.pound_wild.as_mut() { - // By protocol definition, a '#' must be a terminating leaf. - return child.content.as_mut().map(|(k, v)| (&*k, v)); - } - - self.next() + #[test] + fn assert_that_non_first_levels_with_dollar_sign_matches_plus_wildcard() { + assert!(matches("foo/+/baz", "foo/$bar/baz")); } -} - -///////////////////////////////////////////////////////////////////////////// -// Unit Tests -///////////////////////////////////////////////////////////////////////////// - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; - #[test] - fn test_topic_matcher() { - let mut matcher: TopicMatcher = TopicMatcher::new(); - matcher.insert("some/test/topic", 19); - - assert_eq!(matcher.get("some/test/topic"), Some(&19)); - assert_eq!(matcher.get("some/test/bubba"), None); - - matcher.insert("some/+/topic", 42); - matcher.insert("some/test/#", 99); - matcher.insert("some/prod/topic", 155); - - assert!(matcher.has_match("some/random/topic")); - assert!(!matcher.has_match("some/other/thing")); - - // Test the iterator - - let mut set = HashSet::new(); - set.insert(19); - set.insert(42); - set.insert(99); - - let mut match_set = HashSet::new(); - for (_k, v) in matcher.matches("some/test/topic") { - match_set.insert(*v); - } - - assert_eq!(set, match_set); + fn assert_that_different_levels_does_not_match() { + assert!(!matches("test/6/#", "test/3")); + assert!(!matches("foo/+/baz", "foo/bar/bar")); + assert!(!matches("foo/+/#", "fo2/bar/baz")); + assert!(!matches("$BOB/bar", "$SYS/bar")); } - #[test] - fn test_topic_matcher_callback() { - let mut matcher = TopicMatcher::new(); - - matcher.insert("some/+/topic", Box::new(|n: u32| n * 2)); - - for (_t, f) in matcher.matches("some/random/topic") { - let n = f(2); - assert_eq!(n, 4); - } + fn assert_that_longer_topics_does_not_match() { + assert!(!matches("foo/bar", "foo")); + } + #[test] + fn assert_that_plus_wildcard_does_not_match_multiple_levels() { + assert!(!matches("foo/+", "foo/bar/baz")); + } + #[test] + fn assert_that_leading_slash_with_hash_wildcard_does_not_match_normal_topic() { + assert!(!matches("/#", "foo/bar")); + } + #[test] + fn assert_that_hash_wildcard_does_not_match_an_internal_topic() { + assert!(!matches("#", "$SYS/bar")); + } + #[test] + fn assert_that_plus_wildcard_does_not_match_an_internal_topic() { + assert!(!matches("+/bar", "$SYS/bar")); } }