diff --git a/Samples/Client/Client_Subscribe_Samples.cs b/Samples/Client/Client_Subscribe_Samples.cs index 0aa304668..60b4ac2ad 100644 --- a/Samples/Client/Client_Subscribe_Samples.cs +++ b/Samples/Client/Client_Subscribe_Samples.cs @@ -8,6 +8,7 @@ // ReSharper disable UnusedMember.Local using MQTTnet.Client; +using MQTTnet.Extensions.TopicTemplate; using MQTTnet.Packets; using MQTTnet.Protocol; using MQTTnet.Samples.Helpers; @@ -16,6 +17,8 @@ namespace MQTTnet.Samples.Client; public static class Client_Subscribe_Samples { + static MqttTopicTemplate sampleTemplate = new MqttTopicTemplate("mqttnet/samples/topic/{id}"); + public static async Task Handle_Received_Application_Message() { /* @@ -42,11 +45,7 @@ public static async Task Handle_Received_Application_Message() await mqttClient.ConnectAsync(mqttClientOptions, CancellationToken.None); var mqttSubscribeOptions = mqttFactory.CreateSubscribeOptionsBuilder() - .WithTopicFilter( - f => - { - f.WithTopic("mqttnet/samples/topic/2"); - }) + .WithTopicTemplate(sampleTemplate.WithParameter("id", "2")) .Build(); await mqttClient.SubscribeAsync(mqttSubscribeOptions, CancellationToken.None); @@ -88,11 +87,8 @@ public static async Task Send_Responses() await mqttClient.ConnectAsync(mqttClientOptions, CancellationToken.None); var mqttSubscribeOptions = mqttFactory.CreateSubscribeOptionsBuilder() - .WithTopicFilter( - f => - { - f.WithTopic("mqttnet/samples/topic/1"); - }) + .WithTopicTemplate( + sampleTemplate.WithParameter("id", "1")) .Build(); var response = await mqttClient.SubscribeAsync(mqttSubscribeOptions, CancellationToken.None); @@ -121,21 +117,12 @@ public static async Task Subscribe_Multiple_Topics() // Create the subscribe options including several topics with different options. // It is also possible to all of these topics using a dedicated call of _SubscribeAsync_ per topic. var mqttSubscribeOptions = mqttFactory.CreateSubscribeOptionsBuilder() - .WithTopicFilter( - f => - { - f.WithTopic("mqttnet/samples/topic/1"); - }) - .WithTopicFilter( - f => - { - f.WithTopic("mqttnet/samples/topic/2").WithNoLocal(); - }) - .WithTopicFilter( - f => - { - f.WithTopic("mqttnet/samples/topic/3").WithRetainHandling(MqttRetainHandling.SendAtSubscribe); - }) + .WithTopicTemplate( + sampleTemplate.WithParameter("id", "1")) + .WithTopicTemplate( + sampleTemplate.WithParameter("id", "2"), noLocal: true) + .WithTopicTemplate( + sampleTemplate.WithParameter("id", "3"), retainHandling: MqttRetainHandling.SendAtSubscribe) .Build(); var response = await mqttClient.SubscribeAsync(mqttSubscribeOptions, CancellationToken.None); @@ -162,11 +149,7 @@ public static async Task Subscribe_Topic() await mqttClient.ConnectAsync(mqttClientOptions, CancellationToken.None); var mqttSubscribeOptions = mqttFactory.CreateSubscribeOptionsBuilder() - .WithTopicFilter( - f => - { - f.WithTopic("mqttnet/samples/topic/1"); - }) + .WithTopicTemplate(sampleTemplate.WithParameter("id", "1")) .Build(); var response = await mqttClient.SubscribeAsync(mqttSubscribeOptions, CancellationToken.None); diff --git a/Samples/MQTTnet.Samples.csproj b/Samples/MQTTnet.Samples.csproj index b45acc682..158023262 100644 --- a/Samples/MQTTnet.Samples.csproj +++ b/Samples/MQTTnet.Samples.csproj @@ -17,6 +17,7 @@ + diff --git a/Source/MQTTnet.Extensions.TopicTemplate/MQTTnet.Extensions.TopicTemplate.csproj b/Source/MQTTnet.Extensions.TopicTemplate/MQTTnet.Extensions.TopicTemplate.csproj index d1c50d7d0..14d653c04 100644 --- a/Source/MQTTnet.Extensions.TopicTemplate/MQTTnet.Extensions.TopicTemplate.csproj +++ b/Source/MQTTnet.Extensions.TopicTemplate/MQTTnet.Extensions.TopicTemplate.csproj @@ -11,10 +11,10 @@ The contributors of MQTTnet MQTTnet - This is an extension library which provides mqtt topic templating logic to support dispatch, - routing and similar functionality based on the well known moustache syntax, aka - AsyncAPI dynamic channel address. + Provides mqtt topic templating logic to support dispatch, + routing and similar functionality based on the well known moustache syntax (AsyncAPI compatible). + README.md The contributors of MQTTnet MQTTnet.Extensions.TopicTemplate false @@ -51,6 +51,10 @@ + + True + \ + True \ diff --git a/Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs b/Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs index b193e22c8..f5dc48f11 100644 --- a/Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs +++ b/Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs @@ -16,8 +16,8 @@ namespace MQTTnet.Extensions.TopicTemplate /// segments in curly braces called parameters. This well-known /// 'moustache' syntax also matches AsyncAPI Channel Address Expressions. /// The topic template is designed to support dynamic subscription/publication, - /// message-topic matching and routing. It is intended to be more convenient - /// than String.Format() for aforementioned purposes. + /// message-topic matching and routing. It is intended to be more safe and + /// convenient than String.Format() for aforementioned purposes. /// /// /// topic/subtopic/{parameter}/{otherParameter} @@ -282,12 +282,17 @@ public MqttTopicTemplate TrySetParameter(string parameter, string value) /// the topic template (without the parameter) public MqttTopicTemplate WithoutParameter(string parameter) { - return WithParameter(parameter, MqttTopicFilterComparer.SingleLevelWildcard.ToString()); + if (string.IsNullOrEmpty(parameter) || !_parameterSegments.Contains(parameter)) + { + throw new ArgumentException("topic template parameter must exist."); + } + + return ReplaceInternal(parameter, MqttTopicFilterComparer.SingleLevelWildcard.ToString()); } /// /// Substitute a parameter with a given value, thus removing the parameter. If the parameter is not present, - /// the method trows. The value must not contain slashes. + /// the method trows. The value must not contain slashes or wildcards. /// /// @@ -304,12 +309,19 @@ public MqttTopicTemplate WithoutParameter(string parameter) /// the topic template (without the parameter) public MqttTopicTemplate WithParameter(string parameter, string value) { - if (value == null || string.IsNullOrEmpty(parameter) || !_parameterSegments.Contains(parameter) || value.Contains(MqttTopicFilterComparer.LevelSeparator) || + if (value == null || string.IsNullOrEmpty(parameter) || !_parameterSegments.Contains(parameter) || + value.Contains(MqttTopicFilterComparer.LevelSeparator) || + value.Contains(MqttTopicFilterComparer.SingleLevelWildcard) || value.Contains(MqttTopicFilterComparer.MultiLevelWildcard)) { - throw new ArgumentException("parameter must exist and value must not contain slashes."); + throw new ArgumentException("parameter must exist and value must not contain slashes or wildcard."); } + return ReplaceInternal(parameter, value); + } + + private MqttTopicTemplate ReplaceInternal(string parameter, string value) + { var moustache = "{" + parameter + "}"; return new MqttTopicTemplate(Template.Replace(moustache, value)); } diff --git a/Source/MQTTnet.Extensions.TopicTemplate/README.md b/Source/MQTTnet.Extensions.TopicTemplate/README.md new file mode 100644 index 000000000..35935b3be --- /dev/null +++ b/Source/MQTTnet.Extensions.TopicTemplate/README.md @@ -0,0 +1,79 @@ +# MQTTNet.Extensions.TopicTemplate + +Provides mqtt topic templating logic to support dispatch, routing and +similar functionality based on the well known moustache syntax, aka +AsyncAPI dynamic channel address. + +Generating and parsing MQTT topics and topic filters is often done in +an ad-hoc fashion, leading to error-prone code and subtly introduced +showstoppers like forgetting to strip a slash from a parameter. To +remedy, this extension aids you write safe, maintainable logic +for topic generation, routing and dispatch. + + +## How it works + +The package lets you write MQTTNet code like: +```csharp +var template = new MqttTopicTemplate("App/v1/{sender}/message"); + +var filter = new MqttTopicFilterBuilder() + .WithTopicTemplate(template) // (1) + .WithAtLeastOnceQoS() + .WithNoLocal() + .Build(); + +// filter.Topic == "App/v1/+/message" + +var myTopic = template + .WithParameter("sender", "me, myself & i"); // (2) + +var message = new MqttApplicationMessageBuilder() + .WithTopicTemplate( // (3) + myTopic) + .WithPayload("Hello!") + .Build(); + +// message.Topic == "App/v1/me, myself & i/message" + +Assert.IsTrue(message.MatchesTopicTemplate(template)); // (4) +``` + +At (1), the parameter "sender" was automatically replaced by a plus sign +(single level wildcard) because the caller is creating a topic filter. + +Using the same template (they are immutable), you can send +a message. At (2), we replace the parameter with a known value. +If that value contained a level separator or topic filter only +characters, we'd get an ArgumentException. If we forgot to replace +the parameter "sender" (e.g. when placing template not myTopic), +we would be prevented from sending at (3). + +On the receiving side, a message can be matched to a template +using code like at (4). + +These daily tasks become much safer and easier to code than +string.Format() and Split() allow for. More elaborate computation is supported +too. You could extract the sender using the template or route +messages based on topic content. + +## Features + +- Safe handling of topic parameters in moustache syntax +- Simplifies topic generation and parsing logic +- Match topics and extract the values of their parameters +- Translate between topic templates to ease dynamic routing +- Derive a canonical topic filter from a bunch of templates +- Integrates with MQTTNet message, subscription and filter builders +- Extension method friendly (sealed class with one public ctor) + +## Additional documentation + +- Some of the [unit tests](https://github.com/dotnet/MQTTnet/blob/master/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs) are instructive +- Some examples feature the extension, e.g. TopicGenerator, Client_Subscribe_Samples + +## Feedback + +Report any issues with the main project on https://github.com/dotnet/MQTTnet/issues + + diff --git a/Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs b/Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs index 5d2374aac..a4d49e99a 100644 --- a/Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs +++ b/Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs @@ -4,6 +4,8 @@ using System; using System.Linq; +using MQTTnet.Client; +using MQTTnet.Packets; using MQTTnet.Protocol; namespace MQTTnet.Extensions.TopicTemplate @@ -55,6 +57,24 @@ public static MqttTopicFilterBuilder BuildFilter(this MqttTopicTemplate topicTem return new MqttTopicFilterBuilder().WithTopicTemplate(topicTemplate, subscribeTreeRoot); } + /// + /// Create a message builder from this template. The template must not have + /// remaining parameters. + /// + /// + /// a parameterless topic template + /// + /// a new message builder + /// + /// if the topic template has parameters + /// + public static MqttApplicationMessageBuilder BuildMessage(this MqttTopicTemplate topicTemplate) + { + return new MqttApplicationMessageBuilder().WithTopicTemplate(topicTemplate); + } + /// /// Return a message builder to respond to this message. The /// message's response topic and correlation data are included @@ -116,6 +136,29 @@ public static MqttTopicFilterBuilder WithTopicTemplate(this MqttTopicFilterBuild return builder.WithTopic(subscribeTreeRoot ? topicTemplate.TopicTreeRootFilter : topicTemplate.TopicFilter); } + /// + /// Set the subscription to the template's topic filter. + /// + /// the builder + public static MqttClientSubscribeOptionsBuilder WithTopicTemplate( + this MqttClientSubscribeOptionsBuilder builder, + MqttTopicTemplate topicTemplate, + MqttQualityOfServiceLevel qualityOfServiceLevel = MqttQualityOfServiceLevel.AtMostOnce, + bool noLocal = false, + bool retainAsPublished = false, + MqttRetainHandling retainHandling = MqttRetainHandling.SendAtSubscribe) + { + return builder.WithTopicFilter( + new MqttTopicFilter + { + Topic = topicTemplate.TopicFilter, + QualityOfServiceLevel = qualityOfServiceLevel, + NoLocal = noLocal, + RetainAsPublished = retainAsPublished, + RetainHandling = retainHandling + }); + } + /// /// Set the publication topic according to the topic template. The template /// must not have remaining (unset) parameters or contain wildcards. diff --git a/Source/MQTTnet.TestApp/MQTTnet.TestApp.csproj b/Source/MQTTnet.TestApp/MQTTnet.TestApp.csproj index 93973e703..6833e9ffa 100644 --- a/Source/MQTTnet.TestApp/MQTTnet.TestApp.csproj +++ b/Source/MQTTnet.TestApp/MQTTnet.TestApp.csproj @@ -21,6 +21,7 @@ + diff --git a/Source/MQTTnet.TestApp/TopicGenerator.cs b/Source/MQTTnet.TestApp/TopicGenerator.cs index 495c18e27..ad7014a08 100644 --- a/Source/MQTTnet.TestApp/TopicGenerator.cs +++ b/Source/MQTTnet.TestApp/TopicGenerator.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Text; +using MQTTnet.Extensions.TopicTemplate; namespace MQTTnet.TestApp { @@ -17,6 +17,8 @@ out Dictionary> multiWildcardTopicsByPublisher singleWildcardTopicsByPublisher = new Dictionary>(); multiWildcardTopicsByPublisher = new Dictionary>(); + MqttTopicTemplate baseTemplate = new MqttTopicTemplate("{publisher}/{building}/{level}/{sensor}"); + // Find some reasonable distribution across three topic levels var topicsPerLevel = (int)Math.Pow(numTopicsPerPublisher, (1.0 / 3.0)); @@ -40,25 +42,37 @@ out Dictionary> multiWildcardTopicsByPublisher int publisherTopicCount = 0; var publisherName = "pub" + p; + var publisherTemplate = baseTemplate + .WithParameter("publisher", publisherName); for (var l1 = 0; l1 < numLevel1Topics; ++l1) { + var l1Template = publisherTemplate + .WithParameter("building", "building" + (l1 + 1)); for (var l2 = 0; l2 < numLevel2Topics; ++l2) { + var l2Template = l1Template + .WithParameter("level", "level" + (l2 + 1)); for (var l3 = 0; l3 < maxNumLevel3Topics; ++l3) { if (publisherTopicCount >= numTopicsPerPublisher) break; - var topic = string.Format("{0}/building{1}/level{2}/sensor{3}", publisherName, l1 + 1, l2 + 1, l3 + 1); + var topic = l2Template + .WithParameter("sensor", "sensor" + (l3 + 1)) + .TopicFilter; AddPublisherTopic(publisherName, topic, topicsByPublisher); if (l2 == 0) { - var singleWildcardTopic = string.Format("{0}/building{1}/+/sensor{2}", publisherName, l1 + 1, l3 + 1); + var singleWildcardTopic = l1Template + .WithParameter("sensor", "sensor" + (l3 + 1)) + .TopicFilter; AddPublisherTopic(publisherName, singleWildcardTopic, singleWildcardTopicsByPublisher); if (l1 == 0) { - var multiWildcardTopic = string.Format("{0}/+/level{1}/+", publisherName, l3 + 1); + var multiWildcardTopic = publisherTemplate + .WithParameter("sensor", "sensor" + (l3 + 1)) + .TopicFilter; AddPublisherTopic(publisherName, multiWildcardTopic, multiWildcardTopicsByPublisher); } } diff --git a/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs b/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs index f17a867b6..3a95ebc81 100644 --- a/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs +++ b/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs @@ -45,6 +45,29 @@ public void RejectsReservedChars1() var template = new MqttTopicTemplate("A/B/{foo}/D"); template.WithParameter("foo", "a#"); } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void RejectsReservedChars2() + { + var template = new MqttTopicTemplate("A/B/{foo}/D"); + template.WithParameter("foo", "a+b"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void RejectsReservedChars3() + { + var template = new MqttTopicTemplate("A/B/{foo}/D"); + template.WithParameter("foo", "a/b"); + } + + [TestMethod] + public void AcceptsEmptyValue() + { + var template = new MqttTopicTemplate("A/B/{foo}/D"); + template.WithParameter("foo", ""); + } [TestMethod] [ExpectedException(typeof(MqttProtocolViolationException))] @@ -60,13 +83,6 @@ public void RejectsNullTemplate() var _ = new MqttTopicTemplate(null); } - [TestMethod] - [ExpectedException(typeof(ArgumentException))] - public void RejectsReservedChars2() - { - var template = new MqttTopicTemplate("A/B/{foo}/D"); - template.WithParameter("foo", "e/f"); - } [TestMethod] public void IgnoresEmptyParameters() @@ -114,6 +130,50 @@ public void SubscriptionSupport() Assert.AreEqual("A/v1/+/F", filter.Topic); } + [TestMethod] + public void SubscriptionSupport2() + { + var template = new MqttTopicTemplate("A/v1/{param}/F"); + + var subscribeOptions = new MqttFactory().CreateSubscribeOptionsBuilder() + .WithTopicTemplate(template) + .WithSubscriptionIdentifier(5) + .Build(); + Assert.AreEqual("A/v1/+/F", subscribeOptions.TopicFilters[0].Topic); + } + + [TestMethod] + public void SendAndSubscribeSupport() + { + var template = new MqttTopicTemplate("App/v1/{sender}/message"); + + var filter = new MqttTopicFilterBuilder() + .WithTopicTemplate(template) + .WithAtLeastOnceQoS() + .WithNoLocal() + .Build(); + + Assert.AreEqual("App/v1/+/message", filter.Topic); + + var myTopic = template.WithParameter("sender", "me, myself & i"); + + var message = new MqttApplicationMessageBuilder() + .WithTopicTemplate( + myTopic) + .WithPayload("Hello!") + .Build(); + + Assert.IsTrue(message.MatchesTopicTemplate(template)); + } + + [TestMethod] + public void SendAndSubscribeSupport2() + { + var template = new MqttTopicTemplate("App/v1/{sender}/message"); + Assert.ThrowsException(() => + template.BuildMessage()); + } + [TestMethod] public void CanonicalPrefixFilter() { diff --git a/Source/MQTTnet.Tests/TopicGenerator.cs b/Source/MQTTnet.Tests/TopicGenerator.cs index ddfbe7096..d623d107f 100644 --- a/Source/MQTTnet.Tests/TopicGenerator.cs +++ b/Source/MQTTnet.Tests/TopicGenerator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using MQTTnet.Extensions.TopicTemplate; namespace MQTTnet.Tests { @@ -31,31 +32,43 @@ out Dictionary> multiWildcardTopicsByPublisher { maxNumLevel3Topics = 1; } + + MqttTopicTemplate baseTemplate = new MqttTopicTemplate("{publisher}/{building}/{level}/{sensor}"); for (var p = 0; p < numPublishers; ++p) { int publisherTopicCount = 0; var publisherName = "pub" + p; + var publisherTemplate = baseTemplate + .WithParameter("publisher", publisherName); + for (var l1 = 0; l1 < numLevel1Topics; ++l1) { + var l1Template = publisherTemplate.WithParameter("building", "building" + (l1 + 1)); for (var l2 = 0; l2 < numLevel2Topics; ++l2) { + var l2Template = l1Template.WithParameter("level", "level" + (l2 + 1)); for (var l3 = 0; l3 < maxNumLevel3Topics; ++l3) { if (publisherTopicCount >= numTopicsPerPublisher) break; - var topic = string.Format("{0}/building{1}/level{2}/sensor{3}", publisherName, l1 + 1, l2 + 1, l3 + 1); - AddPublisherTopic(publisherName, topic, topicsByPublisher); + var l3Template = l2Template + .WithParameter("sensor", "sensor" + (l3 + 1)); + AddPublisherTopic(publisherName, l3Template.TopicFilter, topicsByPublisher); if (l2 == 0) { - var singleWildcardTopic = string.Format("{0}/building{1}/+/sensor{2}", publisherName, l1 + 1, l3 + 1); + var singleWildcardTopic = l1Template + .WithParameter("sensor", "sensor" + (l3 + 1)) + .TopicFilter; AddPublisherTopic(publisherName, singleWildcardTopic, singleWildcardTopicsByPublisher); } if ((l1 == 0) && (l3 == 0)) { - var multiWildcardTopic = string.Format("{0}/+/level{1}/+", publisherName, l2 + 1); + var multiWildcardTopic = publisherTemplate + .WithParameter("level", "level" + (l2 + 1)) + .TopicFilter; AddPublisherTopic(publisherName, multiWildcardTopic, multiWildcardTopicsByPublisher); }