Skip to content

Commit 597d1a8

Browse files
authored
Better Topic Matching (#128)
1 parent 1d27b33 commit 597d1a8

File tree

4 files changed

+295
-1
lines changed

4 files changed

+295
-1
lines changed

Source/HiveMQtt/Client/HiveMQClientEvents.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ protected virtual void OnMessageReceivedEventLauncher(PublishPacket packet)
141141
// Per Subscription Event Handler
142142
foreach (var subscription in this.Subscriptions)
143143
{
144-
if (subscription.TopicFilter.Topic == packet.Message.Topic)
144+
if (packet.Message.Topic != null && MatchTopic(subscription.TopicFilter.Topic, packet.Message.Topic))
145145
{
146146
subscription.MessageReceivedHandler?.Invoke(this, eventArgs);
147147
}

Source/HiveMQtt/Client/HiveMQClientUtil.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
namespace HiveMQtt.Client;
1717

18+
using System;
19+
using System.Text.RegularExpressions;
1820
using HiveMQtt.MQTT5.Types;
1921

2022
/// <inheritdoc />
@@ -64,6 +66,71 @@ internal bool SubscriptionExists(Subscription subscription)
6466
return null;
6567
}
6668

69+
/// <summary>
70+
/// This method is used to determine if a topic filter matches a topic.
71+
///
72+
/// It implements the MQTT 5.0 specification definitions for single-level
73+
/// and multi-level wildcard characters (and related rules).
74+
///
75+
/// </summary>
76+
/// <param name="pattern">The topic filter.</param>
77+
/// <param name="candidate">The topic to match.</param>
78+
/// <returns>A boolean indicating whether the topic filter matches the topic.</returns>
79+
/// <exception cref="ArgumentException">Thrown when the topic filter is invalid.</exception>
80+
public static bool MatchTopic(string pattern, string candidate)
81+
{
82+
if (pattern == candidate)
83+
{
84+
return true;
85+
}
86+
87+
if (pattern == "#")
88+
{
89+
// A subscription to “#” will not receive any messages published to a topic beginning with a $
90+
if (candidate.StartsWith("$", System.StringComparison.CurrentCulture))
91+
{
92+
return false;
93+
}
94+
else
95+
{
96+
return true;
97+
}
98+
}
99+
100+
if (pattern == "+")
101+
{
102+
// A subscription to “+” will not receive any messages published to a topic containing a $
103+
if (candidate.StartsWith("$", System.StringComparison.CurrentCulture) ||
104+
candidate.StartsWith("/", System.StringComparison.CurrentCulture))
105+
{
106+
return false;
107+
}
108+
else
109+
{
110+
return true;
111+
}
112+
}
113+
114+
// If pattern contains a multi-level wildcard character, it must be the last character in the pattern
115+
// and it must be preceded by a topic level separator.
116+
var mlwcValidityRegex = new Regex(@"(?<!/)#");
117+
118+
if (pattern.Contains("/#/") | mlwcValidityRegex.IsMatch(pattern))
119+
{
120+
throw new ArgumentException(
121+
"The multi-level wildcard character must be specified either on its own or following a topic level separator. " +
122+
"In either case it must be the last character specified in the Topic Filter.");
123+
}
124+
125+
// ^sport\/tennis\/player1(\/?|.+)$
126+
var regexp = "\\A" + Regex.Escape(pattern).Replace(@"\+", @"?[/][^/]*") + "\\z";
127+
128+
regexp = regexp.Replace(@"/\#", @"(/?|.+)");
129+
regexp = regexp.EndsWith("\\z", System.StringComparison.CurrentCulture) ? regexp : regexp + "\\z";
130+
131+
return Regex.IsMatch(candidate, regexp);
132+
}
133+
67134
/// <summary>
68135
/// https://learn.microsoft.com/en-us/dotnet/api/system.idisposable?view=net-6.0.
69136
/// </summary>

Tests/HiveMQtt.Test/HiveMQClient/SubscribeBuilderTest.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
namespace HiveMQtt.Test.HiveMQClient;
22

3+
using System.Text.RegularExpressions;
34
using System.Threading.Tasks;
45
using HiveMQtt.Client;
56
using HiveMQtt.Client.Events;
@@ -154,4 +155,108 @@ void GlobalMessageHandler(object? sender, OnMessageReceivedEventArgs eventArgs)
154155
var disconnectResult = await subClient.DisconnectAsync().ConfigureAwait(false);
155156
Assert.True(disconnectResult);
156157
}
158+
159+
[Fact]
160+
public async Task PerSubHandlerWithSingleLevelWildcardAsync()
161+
{
162+
// Create a subscribeClient that subscribes to a topic with a single-level wildcard
163+
var subscribeClient = new HiveMQClient();
164+
var connectResult = await subscribeClient.ConnectAsync().ConfigureAwait(false);
165+
Assert.True(connectResult.ReasonCode == ConnAckReasonCode.Success);
166+
167+
var tcs = new TaskCompletionSource<bool>();
168+
var messageCount = 0;
169+
170+
var subscribeOptions = new SubscribeOptionsBuilder()
171+
.WithSubscription("tests/PerSubHandlerWithSingleLevelWildcard/+/msg", MQTT5.Types.QualityOfService.AtLeastOnceDelivery, messageReceivedHandler: (sender, args) =>
172+
{
173+
messageCount++;
174+
var pattern = @"^tests/PerSubHandlerWithSingleLevelWildcard/[0-2]/msg$";
175+
var regex = new Regex(pattern);
176+
Assert.Matches(regex, args.PublishMessage.Topic);
177+
178+
Assert.Equal("test", args.PublishMessage.PayloadAsString);
179+
180+
if (messageCount == 3)
181+
{
182+
tcs.SetResult(true);
183+
}
184+
})
185+
.Build();
186+
187+
var subResult = await subscribeClient.SubscribeAsync(subscribeOptions).ConfigureAwait(false);
188+
189+
Assert.NotEmpty(subResult.Subscriptions);
190+
Assert.Equal(SubAckReasonCode.GrantedQoS1, subResult.Subscriptions[0].SubscribeReasonCode);
191+
192+
var pubClient = new HiveMQClient();
193+
var pubConnectResult = await pubClient.ConnectAsync().ConfigureAwait(false);
194+
Assert.True(pubConnectResult.ReasonCode == ConnAckReasonCode.Success);
195+
196+
// Publish 3 messages that will match the single-level wildcard
197+
for (var i = 0; i < 3; i++)
198+
{
199+
await pubClient.PublishAsync($"tests/PerSubHandlerWithSingleLevelWildcard/{i}/msg", "test").ConfigureAwait(false);
200+
}
201+
202+
// Wait for the 3 messages to be received by the per-subscription handler
203+
var handlerResult = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
204+
Assert.True(handlerResult);
205+
206+
var disconnectResult = await subscribeClient.DisconnectAsync().ConfigureAwait(false);
207+
Assert.True(disconnectResult);
208+
}
209+
210+
[Fact]
211+
public async Task PerSubHandlerWithMultiLevelWildcardAsync()
212+
{
213+
// Create a subscribeClient that subscribes to a topic with a single-level wildcard
214+
var subscribeClient = new HiveMQClient();
215+
var connectResult = await subscribeClient.ConnectAsync().ConfigureAwait(false);
216+
Assert.True(connectResult.ReasonCode == ConnAckReasonCode.Success);
217+
218+
var tcs = new TaskCompletionSource<bool>();
219+
var messageCount = 0;
220+
221+
var subscribeOptions = new SubscribeOptionsBuilder()
222+
.WithSubscription(
223+
"tests/PerSubHandlerWithMultiLevelWildcard/#",
224+
MQTT5.Types.QualityOfService.AtLeastOnceDelivery,
225+
messageReceivedHandler: (sender, args) =>
226+
{
227+
messageCount++;
228+
var pattern = @"\Atests/PerSubHandlerWithMultiLevelWildcard/(/?|.+)\z";
229+
var regex = new Regex(pattern);
230+
Assert.Matches(regex, args.PublishMessage.Topic);
231+
232+
Assert.Equal("test", args.PublishMessage.PayloadAsString);
233+
234+
if (messageCount == 3)
235+
{
236+
tcs.SetResult(true);
237+
}
238+
})
239+
.Build();
240+
241+
var subResult = await subscribeClient.SubscribeAsync(subscribeOptions).ConfigureAwait(false);
242+
243+
Assert.NotEmpty(subResult.Subscriptions);
244+
Assert.Equal(SubAckReasonCode.GrantedQoS1, subResult.Subscriptions[0].SubscribeReasonCode);
245+
246+
var pubClient = new HiveMQClient();
247+
var pubConnectResult = await pubClient.ConnectAsync().ConfigureAwait(false);
248+
Assert.True(pubConnectResult.ReasonCode == ConnAckReasonCode.Success);
249+
250+
// Publish 3 messages that will match the multi-level wildcard
251+
await pubClient.PublishAsync($"tests/PerSubHandlerWithMultiLevelWildcard/1", "test").ConfigureAwait(false);
252+
await pubClient.PublishAsync($"tests/PerSubHandlerWithMultiLevelWildcard/1/2", "test").ConfigureAwait(false);
253+
await pubClient.PublishAsync($"tests/PerSubHandlerWithMultiLevelWildcard/1/2/3/4/5", "test").ConfigureAwait(false);
254+
255+
// Wait for the 3 messages to be received by the per-subscription handler
256+
var handlerResult = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
257+
Assert.True(handlerResult);
258+
259+
var disconnectResult = await subscribeClient.DisconnectAsync().ConfigureAwait(false);
260+
Assert.True(disconnectResult);
261+
}
157262
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
namespace HiveMQtt.Test.HiveMQClient;
2+
3+
using Xunit;
4+
using HiveMQtt.Client;
5+
6+
public class UtilTest
7+
{
8+
[Fact]
9+
public void SingleLevelWildcardMatch()
10+
{
11+
// The plus sign (‘+’ U+002B) is a wildcard character that matches only one topic level.
12+
//
13+
// The single-level wildcard can be used at any level in the Topic Filter, including first and last levels. Where it is used, it MUST occupy an entire level of the filter [MQTT-4.7.1-2]. It can be used at more than one level in the Topic Filter and can be used in conjunction with the multi-level wildcard.
14+
//
15+
// For example, “sport/tennis/+” matches “sport/tennis/player1” and “sport/tennis/player2”, but not “sport/tennis/player1/ranking”. Also, because the single-level wildcard matches only a single level, “sport/+” does not match “sport” but it does match “sport/”.
16+
//
17+
// · “+” is valid
18+
// · “+/tennis/#” is valid
19+
// · “sport+” is not valid
20+
// · “sport/+/player1” is valid
21+
// · “/finance” matches “+/+” and “/+”, but not “+”
22+
bool result;
23+
24+
// “sport/tennis/+” matches “sport/tennis/player1”
25+
result = HiveMQClient.MatchTopic("sport/tennis/+", "sport/tennis/player1");
26+
Assert.True(result);
27+
28+
// “sport/tennis/+” doesn't match sport/tennis/player1/ranking”
29+
result = HiveMQClient.MatchTopic("sport/tennis/+", "sport/tennis/player1/ranking");
30+
Assert.False(result);
31+
32+
// “sport/+” does not match “sport”
33+
result = HiveMQClient.MatchTopic("sport/+", "sport");
34+
Assert.False(result);
35+
36+
// “sport/+” does match “sport/”
37+
result = HiveMQClient.MatchTopic("sport/+", "sport/");
38+
Assert.True(result);
39+
40+
// "sport/+/player1" matches "sport/tennis/player1"
41+
result = HiveMQClient.MatchTopic("sport/+/player1", "sport/tennis/player1");
42+
Assert.True(result);
43+
44+
// "/finance" matches “/+”
45+
result = HiveMQClient.MatchTopic("/+", "/finance");
46+
Assert.True(result);
47+
48+
// "/finance" doesn't match “+”
49+
result = HiveMQClient.MatchTopic("+", "/finance");
50+
Assert.False(result);
51+
52+
// A subscription to “+/monitor/Clients” will not receive any messages published to “$SYS/monitor/Clients”
53+
result = HiveMQClient.MatchTopic("+/monitor/Clients", "$SYS/monitor/Clients");
54+
Assert.False(result);
55+
56+
// A subscription to “$SYS/monitor/+” will receive messages published to “$SYS/monitor/Clients”
57+
result = HiveMQClient.MatchTopic("$SYS/monitor/+", "$SYS/monitor/Clients");
58+
Assert.True(result);
59+
60+
// https://github.com/hivemq/hivemq-mqtt-client-dotnet/issues/126
61+
result = HiveMQClient.MatchTopic("hivemqtt/sendmessageonloop/+/test", "hivemqtt/sendmessageonloop/2938472/test");
62+
Assert.True(result);
63+
}
64+
65+
[Fact]
66+
public void MultiLevelWildcardMatch()
67+
{
68+
// From: https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901244
69+
// # The number sign (‘#’ U+0023) is a wildcard character that matches any number of levels within a topic. The multi-level wildcard represents the parent and any number of child levels. The multi-level wildcard character MUST be specified either on its own or following a topic level separator. In either case it MUST be the last character specified in the Topic Filter [MQTT-4.7.1-1].
70+
//
71+
// For example, if a Client subscribes to “sport/tennis/player1/#”, it would receive messages published using these Topic Names:
72+
// · “sport/tennis/player1”
73+
// · “sport/tennis/player1/ranking
74+
// · “sport/tennis/player1/score/wimbledon”
75+
//
76+
// · “sport/#” also matches the singular “sport”, since # includes the parent level.
77+
// · “#” is valid and will receive every Application Message
78+
// · “sport/tennis/#” is valid
79+
// · “sport/tennis#” is not valid
80+
// · “sport/tennis/#/ranking” is not valid
81+
bool result;
82+
83+
// “sport/tennis/#” matches “sport/tennis/player1”
84+
result = HiveMQClient.MatchTopic("sport/tennis/player1/#", "sport/tennis/player1");
85+
Assert.True(result);
86+
87+
// “sport/tennis/#” matches “sport/tennis/player1/ranking”
88+
result = HiveMQClient.MatchTopic("sport/tennis/player1/#", "sport/tennis/player1/ranking");
89+
Assert.True(result);
90+
91+
// “sport/tennis/+” matches “sport/tennis/player1/ranking”
92+
result = HiveMQClient.MatchTopic("sport/tennis/player1/#", "sport/tennis/player1/score/wimbledon");
93+
Assert.True(result);
94+
95+
// “sport/#” also matches the singular “sport”, since # includes the parent level.
96+
result = HiveMQClient.MatchTopic("sport/#", "sport");
97+
Assert.True(result);
98+
99+
// “#” is valid and will receive every Application Message
100+
result = HiveMQClient.MatchTopic("#", "any/and/all/topics");
101+
Assert.True(result);
102+
103+
// Invalid multi-level wildcards
104+
Assert.Throws<ArgumentException>(() => HiveMQClient.MatchTopic("invalid/mlwc#", "sport/tennis/player1/ranking"));
105+
106+
// “sport/tennis/#/ranking” is not valid
107+
Assert.Throws<ArgumentException>(() => HiveMQClient.MatchTopic("sport/tennis/#/ranking", "sport/tennis/player1/ranking"));
108+
Assert.Throws<ArgumentException>(() => HiveMQClient.MatchTopic("/#/", "sport/tennis/player1/ranking"));
109+
110+
// “sport/tennis#” is not valid
111+
Assert.Throws<ArgumentException>(() => HiveMQClient.MatchTopic("sports/tennis#", "sport/tennis/player1/ranking"));
112+
113+
// A subscription to “#” will not receive any messages published to a topic beginning with a $
114+
result = HiveMQClient.MatchTopic("#", "$SYS/broker/clients/total");
115+
Assert.False(result);
116+
117+
// A subscription to “$SYS/#” will receive messages published to topics beginning with “$SYS/”
118+
result = HiveMQClient.MatchTopic("$SYS/#", "$SYS/broker/clients/total");
119+
Assert.True(result);
120+
121+
}
122+
}

0 commit comments

Comments
 (0)