diff --git a/.github/workflows/ReleaseNotes.md b/.github/workflows/ReleaseNotes.md
index 8bd59298..ca1d1be3 100644
--- a/.github/workflows/ReleaseNotes.md
+++ b/.github/workflows/ReleaseNotes.md
@@ -2,3 +2,4 @@
* [Core] The package inspector is now fully async (#1941).
* [Client] Added a dedicated exception when the client is not connected (#1954, thanks to @marcpiulachs).
* [ManagedClient] Added a new event (SubscriptionsChangedAsync) which is fired when a subscription or unsubscription was made (#1894, thanks to @pdufrene).
+* [TopicTemplate] Added new extension which provides a template engine for topics (#1932, thanks to @simonthum).
diff --git a/MQTTnet.sln b/MQTTnet.sln
index 34abf629..4350c299 100644
--- a/MQTTnet.sln
+++ b/MQTTnet.sln
@@ -7,9 +7,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet", "Source\MQTTnet\M
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B3F60ECB-45BA-4C66-8903-8BB89CA67998}"
ProjectSection(SolutionItems) = preProject
+ CODE-OF-CONDUCT.md = CODE-OF-CONDUCT.md
LICENSE = LICENSE
README.md = README.md
- CODE-OF-CONDUCT.md = CODE-OF-CONDUCT.md
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.AspNetCore", "Source\MQTTnet.AspnetCore\MQTTnet.AspNetCore.csproj", "{F10C4060-F7EE-4A83-919F-FF723E72F94A}"
@@ -20,17 +20,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.Extensions.ManagedC
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.Extensions.WebSocket4Net", "Source\MQTTnet.Extensions.WebSocket4Net\MQTTnet.Extensions.WebSocket4Net.csproj", "{2BD01D53-4CA5-4142-BE8D-313876395E3E}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MQTTnet.Samples", "Samples\MQTTnet.Samples.csproj", "{71CF35F5-3327-4A91-AAF4-5340F6701771}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.Samples", "Samples\MQTTnet.Samples.csproj", "{71CF35F5-3327-4A91-AAF4-5340F6701771}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MQTTnet.Tests", "Source\MQTTnet.Tests\MQTTnet.Tests.csproj", "{B270F32A-9F3E-42EE-A989-813E35E29ADB}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.Tests", "Source\MQTTnet.Tests\MQTTnet.Tests.csproj", "{B270F32A-9F3E-42EE-A989-813E35E29ADB}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MQTTnet.AspNetCore.Tests", "Source\MQTTnet.AspNetCore.Tests\MQTTnet.AspNetCore.Tests.csproj", "{A238BBBF-C75F-482D-9CC3-BB34ABA9B675}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.AspNetCore.Tests", "Source\MQTTnet.AspNetCore.Tests\MQTTnet.AspNetCore.Tests.csproj", "{A238BBBF-C75F-482D-9CC3-BB34ABA9B675}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MQTTnet.Benchmarks", "Source\MQTTnet.Benchmarks\MQTTnet.Benchmarks.csproj", "{2F516E76-AAC4-4219-B7D1-34CDD3CFF381}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.Benchmarks", "Source\MQTTnet.Benchmarks\MQTTnet.Benchmarks.csproj", "{2F516E76-AAC4-4219-B7D1-34CDD3CFF381}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MQTTnet.TestApp", "Source\MQTTnet.TestApp\MQTTnet.TestApp.csproj", "{175D5340-CC5B-4542-939D-4E7D15A0BC8D}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.TestApp", "Source\MQTTnet.TestApp\MQTTnet.TestApp.csproj", "{175D5340-CC5B-4542-939D-4E7D15A0BC8D}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MQTTnet.AspTestApp", "Source\MQTTnet.AspTestApp\MQTTnet.AspTestApp.csproj", "{72867E4C-4E15-4E8E-8FAB-AE9253286BBC}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.AspTestApp", "Source\MQTTnet.AspTestApp\MQTTnet.AspTestApp.csproj", "{72867E4C-4E15-4E8E-8FAB-AE9253286BBC}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MQTTnet.Extensions.TopicTemplate", "Source\MQTTnet.Extensions.TopicTemplate\MQTTnet.Extensions.TopicTemplate.csproj", "{374D5861-F7A6-42A2-8CBE-040846000EF9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -82,12 +84,14 @@ Global
{72867E4C-4E15-4E8E-8FAB-AE9253286BBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72867E4C-4E15-4E8E-8FAB-AE9253286BBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72867E4C-4E15-4E8E-8FAB-AE9253286BBC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {374D5861-F7A6-42A2-8CBE-040846000EF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {374D5861-F7A6-42A2-8CBE-040846000EF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {374D5861-F7A6-42A2-8CBE-040846000EF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {374D5861-F7A6-42A2-8CBE-040846000EF9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {07536672-5CBC-4BE3-ACE0-708A431A7894}
EndGlobalSection
diff --git a/Source/MQTTnet.Extensions.Rpc/IMqttRpcClient.cs b/Source/MQTTnet.Extensions.Rpc/IMqttRpcClient.cs
index b4345968..ccece444 100644
--- a/Source/MQTTnet.Extensions.Rpc/IMqttRpcClient.cs
+++ b/Source/MQTTnet.Extensions.Rpc/IMqttRpcClient.cs
@@ -1,3 +1,7 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
using System;
using System.Collections.Generic;
using System.Threading;
diff --git a/Source/MQTTnet.Extensions.TopicTemplate/MQTTnet.Extensions.TopicTemplate.csproj b/Source/MQTTnet.Extensions.TopicTemplate/MQTTnet.Extensions.TopicTemplate.csproj
new file mode 100644
index 00000000..d1c50d7d
--- /dev/null
+++ b/Source/MQTTnet.Extensions.TopicTemplate/MQTTnet.Extensions.TopicTemplate.csproj
@@ -0,0 +1,69 @@
+
+
+
+ netstandard1.3;netstandard2.0;netstandard2.1;netcoreapp3.1;net5.0;net6.0;net7.0
+ $(TargetFrameworks);net452;net461;net48
+ $(TargetFrameworks);uap10.0
+
+ MQTTnet.Extensions.TopicTemplate
+ MQTTnet.Extensions.TopicTemplate
+ True
+ 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.
+
+ The contributors of MQTTnet
+ MQTTnet.Extensions.TopicTemplate
+ false
+ false
+ true
+ true
+ snupkg
+ Copyright (c) .NET Foundation and Contributors
+ https://github.com/dotnet/MQTTnet
+ https://github.com/dotnet/MQTTnet.git
+ git
+ MQTT Message Queue Telemetry MQTTClient Messaging Routing AsyncAPI Template
+ en-US
+ nuget.png
+ true
+ true
+ MIT
+ For release notes please go to MQTTnet release notes (https://www.nuget.org/packages/MQTTnet/).
+ true
+ 1591;NETSDK1138
+
+
+
+ false
+ UAP,Version=v10.0
+ UAP
+ 10.0.18362.0
+ 10.0.10240.0
+ .NETCore
+ v5.0
+ $(DefineConstants);WINDOWS_UWP
+ en
+ $(MSBuildExtensionsPath)\Microsoft\WindowsXaml\v$(VisualStudioVersion)\Microsoft.Windows.UI.Xaml.CSharp.targets
+
+
+
+
+ True
+ \
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs b/Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs
new file mode 100644
index 00000000..3ff8a071
--- /dev/null
+++ b/Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs
@@ -0,0 +1,334 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using MQTTnet.Protocol;
+
+namespace MQTTnet.Extensions.TopicTemplate
+{
+ ///
+ /// A topic template is an MQTT topic filter string that may contain
+ /// 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.
+ ///
+ ///
+ /// topic/subtopic/{parameter}/{otherParameter}
+ ///
+ public sealed class MqttTopicTemplate : IEquatable
+ {
+ static readonly Regex MoustacheRegex = new Regex("{([^/]+?)}", RegexOptions.Compiled);
+
+ readonly string[] _parameterSegments;
+
+ string _topicFilter;
+
+ ///
+ /// Create a topic template from an mqtt topic filter with moustache placeholders.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public MqttTopicTemplate(string topicTemplate)
+ {
+ if (topicTemplate == null)
+ {
+ throw new ArgumentNullException(nameof(topicTemplate));
+ }
+
+ MqttTopicValidator.ThrowIfInvalidSubscribe(topicTemplate);
+
+ Template = topicTemplate;
+ _parameterSegments = topicTemplate.Split(MqttTopicFilterComparer.LevelSeparator)
+ .Select(segment => MoustacheRegex.Match(segment).Groups[1].Value)
+ .Select(s => s.Length > 0 ? s : null)
+ .ToArray();
+ }
+
+ ///
+ /// Yield the template parameter names.
+ ///
+ public IEnumerable Parameters => _parameterSegments.Where(s => s != null);
+
+ ///
+ /// The topic template string representation, e.g. A/B/{foo}/D.
+ ///
+ public string Template { get; }
+
+ ///
+ /// The topic template as an MQTT topic filter (+ substituted for all parameters). If the template
+ /// ends with a multi-level wildcard (hash), this will be reflected here.
+ ///
+ public string TopicFilter
+ {
+ get
+ {
+ LazyInitializer.EnsureInitialized(ref _topicFilter, () => MoustacheRegex.Replace(Template, MqttTopicFilterComparer.SingleLevelWildcard.ToString()));
+ return _topicFilter;
+ }
+ }
+
+ ///
+ /// Return the topic filter of this template, ending with a multi-level wildcard (hash).
+ ///
+ public string TopicTreeRootFilter
+ {
+ get
+ {
+ var filter = TopicFilter;
+ // append slash if neccessary
+ if (filter.Length > 0 && !filter.EndsWith(MqttTopicFilterComparer.LevelSeparator.ToString()) &&
+ !filter.EndsWith(MqttTopicFilterComparer.MultiLevelWildcard.ToString()))
+ {
+ filter += MqttTopicFilterComparer.LevelSeparator;
+ }
+
+ // append hash if neccessary
+ if (!filter.EndsWith(MqttTopicFilterComparer.MultiLevelWildcard.ToString()))
+ {
+ filter += MqttTopicFilterComparer.MultiLevelWildcard;
+ }
+
+ return filter;
+ }
+ }
+
+ public bool Equals(MqttTopicTemplate other)
+ {
+ return other != null && Template == other.Template;
+ }
+
+ public override bool Equals(object obj)
+ {
+ if (ReferenceEquals(null, obj))
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (obj.GetType() != GetType())
+ {
+ return false;
+ }
+
+ return Equals((MqttTopicTemplate)obj);
+ }
+
+ ///
+ /// Determine the shortest common prefix of the given templates. Partial segments
+ /// are not returned.
+ ///
+ ///
+ /// topic templates
+ ///
+ ///
+ public static MqttTopicTemplate FindCanonicalPrefix(IEnumerable templates)
+ {
+ string root = null;
+
+ string CommonPrefix(string a, string b)
+ {
+ var maxIndex = Math.Min(a.Length, b.Length) - 1;
+ for (var i = 0; i <= maxIndex; i++)
+ {
+ if (a[i] != b[i])
+ {
+ return a.Substring(0, i);
+ }
+ }
+
+ return a.Substring(0, maxIndex);
+ }
+
+ foreach (var topic in from template in templates select template.Template)
+ {
+ root = root == null ? topic : CommonPrefix(root, topic);
+ }
+
+ root = root ?? "";
+
+ return new MqttTopicTemplate(root.Substring(0, root.LastIndexOf('/')));
+ }
+
+ public override int GetHashCode()
+ {
+ return Template.GetHashCode();
+ }
+
+ ///
+ /// Test if this topic template matches a given topic.
+ ///
+ ///
+ /// a fully specified topic
+ ///
+ ///
+ /// true to match including the subtree (multi-level wildcard)
+ ///
+ /// true iff the topic matches the template's filter
+ ///
+ ///
+ ///
+ /// if the topic is invalid
+ ///
+ public bool MatchesTopic(string topic, bool subtree = false)
+ {
+ var comparison = MqttTopicFilterComparer.Compare(topic, subtree ? TopicTreeRootFilter : TopicFilter);
+ if (comparison == MqttTopicFilterCompareResult.FilterInvalid)
+ {
+ throw new InvalidOperationException("Invalid filter");
+ }
+
+ if (comparison == MqttTopicFilterCompareResult.TopicInvalid)
+ {
+ throw new ArgumentException("Invalid topic", nameof(topic));
+ }
+
+ return comparison == MqttTopicFilterCompareResult.IsMatch;
+ }
+
+ ///
+ /// Extract the parameter values from a topic corresponding to the template
+ /// parameters. The topic has to match this template.
+ ///
+ ///
+ /// the topic
+ ///
+ /// an enumeration of (parameter, index, value)
+ public IEnumerable<(string parameter, int index, string value)> ParseParameterValues(string topic)
+ {
+ if (!MatchesTopic(topic))
+ {
+ throw new ArgumentException("the topic has to match this template", nameof(topic));
+ }
+
+ return parseParameterValuesInternal(topic);
+ }
+
+ ///
+ /// Extract the parameter values from the message topic corresponding to the template
+ /// parameters. The message topic has to match this topic template.
+ ///
+ ///
+ /// the message
+ ///
+ /// an enumeration of (parameter, index, value)
+ public IEnumerable<(string parameter, int index, string value)> ParseParameterValues(MqttApplicationMessage message)
+ {
+ return ParseParameterValues(message.Topic);
+ }
+
+ ///
+ /// Try to set a parameter to a given value. If the parameter is not present,
+ /// this is returned. The value must not contain slashes.
+ ///
+ ///
+ /// a template parameter
+ ///
+ ///
+ /// a string
+ ///
+ ///
+ public MqttTopicTemplate TrySetParameter(string parameter, string value)
+ {
+ if (parameter != null && _parameterSegments.Contains(parameter))
+ {
+ return WithParameter(parameter, value);
+ }
+
+ return this;
+ }
+
+ ///
+ /// Replace the given parameter with a single-level wildcard (plus sign).
+ ///
+ ///
+ /// parameter name
+ ///
+ /// the topic template (without the parameter)
+ public MqttTopicTemplate WithoutParameter(string parameter)
+ {
+ return WithParameter(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.
+ ///
+ ///
+ /// a template parameter
+ ///
+ ///
+ /// a string
+ ///
+ ///
+ /// when the parameter is not present
+ ///
+ /// 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) ||
+ value.Contains(MqttTopicFilterComparer.MultiLevelWildcard))
+ {
+ throw new ArgumentException("parameter must exist and value must not contain slashes.");
+ }
+
+ var moustache = "{" + parameter + "}";
+ return new MqttTopicTemplate(Template.Replace(moustache, value));
+ }
+
+ ///
+ /// Reuse parameters as they are extracted using another topic template on this template
+ /// when the parameter name matches. Useful
+ /// for compatibility routing.
+ ///
+ ///
+ ///
+ ///
+ public MqttTopicTemplate WithParameterValuesFrom(IEnumerable<(string parameter, int index, string value)> parameters)
+ {
+ return parameters.Aggregate(this, (t, p) => t.TrySetParameter(p.parameter, p.value));
+ }
+
+ IEnumerable<(string parameter, int index, string value)> parseParameterValuesInternal(string topic)
+ {
+ // because we have a match, we know the segment array is at least the template's length
+ var segments = topic.Split(MqttTopicFilterComparer.LevelSeparator);
+ for (var i = 0; i < _parameterSegments.Length; i++)
+ {
+ var name = _parameterSegments[i];
+ if (name != null)
+ {
+ yield return (name, i, segments[i]);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs b/Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs
new file mode 100644
index 00000000..59c6acdb
--- /dev/null
+++ b/Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs
@@ -0,0 +1,151 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Linq;
+using MQTTnet.Protocol;
+
+namespace MQTTnet.Extensions.TopicTemplate
+{
+ public static class TopicTemplateExtensions
+ {
+ ///
+ /// Modify this message builder to respond to a given message. The
+ /// message's response topic and correlation data are included
+ /// in the message builder.
+ ///
+ ///
+ /// a message builder
+ ///
+ ///
+ /// a message with a response topic
+ ///
+ /// a message builder
+ ///
+ public static MqttApplicationMessageBuilder AsResponseTo(this MqttApplicationMessageBuilder builder, MqttApplicationMessage message)
+ {
+ if (!string.IsNullOrEmpty(message.ResponseTopic))
+ {
+ throw new ArgumentException("message does not have a response topic");
+ }
+
+ return builder.WithTopic(message.ResponseTopic).WithCorrelationData(message.CorrelationData);
+ }
+
+ ///
+ /// Set the filter topic according to the template, with
+ /// remaining template parameters substituted by single-level
+ /// wildcard.
+ ///
+ ///
+ /// a filter builder
+ ///
+ ///
+ /// a topic template
+ ///
+ ///
+ /// whether to subscribe to the whole topic tree
+ ///
+ /// the modified topic filter
+ public static MqttTopicFilterBuilder BuildFilter(this MqttTopicTemplate topicTemplate, bool subscribeTreeRoot = false)
+ {
+ return new MqttTopicFilterBuilder().WithTopicTemplate(topicTemplate, subscribeTreeRoot);
+ }
+
+ ///
+ /// Return a message builder to respond to this message. The
+ /// message's response topic and correlation data are included
+ /// in the response message builder.
+ ///
+ ///
+ /// a message with a response topic
+ ///
+ /// a message builder
+ ///
+ public static MqttApplicationMessageBuilder BuildResponse(this MqttApplicationMessage message)
+ {
+ return new MqttApplicationMessageBuilder().AsResponseTo(message);
+ }
+
+ ///
+ /// Return whether the message matches the given topic template.
+ ///
+ ///
+ /// a message
+ ///
+ ///
+ /// a topic template
+ ///
+ ///
+ /// whether to include the topic subtree
+ ///
+ ///
+ public static bool MatchesTopicTemplate(this MqttApplicationMessage message, MqttTopicTemplate topicTemplate, bool subtree = false)
+ {
+ return topicTemplate.MatchesTopic(message.Topic, subtree);
+ }
+
+ ///
+ /// Set the filter topic according to the template, with
+ /// template parameters substituted by a single-level
+ /// wildcard.
+ ///
+ ///
+ /// a filter builder
+ ///
+ ///
+ /// a topic template
+ ///
+ ///
+ /// whether to subscribe to the whole topic tree
+ ///
+ /// the modified topic filter
+ public static MqttTopicFilterBuilder WithTopicTemplate(this MqttTopicFilterBuilder builder, MqttTopicTemplate topicTemplate, bool subscribeTreeRoot = false)
+ {
+ return builder.WithTopic(subscribeTreeRoot ? topicTemplate.TopicTreeRootFilter : topicTemplate.TopicFilter);
+ }
+
+ ///
+ /// Set the publication topic according to the topic template. The template
+ /// must not have remaining (unset) parameters or contain wildcards.
+ ///
+ ///
+ /// a message builder
+ ///
+ ///
+ /// a parameterless topic template
+ ///
+ /// the modified message builder
+ ///
+ /// if the topic template has parameters
+ ///
+ public static MqttApplicationMessageBuilder WithTopicTemplate(this MqttApplicationMessageBuilder builder, MqttTopicTemplate topicTemplate)
+ {
+ if (topicTemplate.Parameters.Any())
+ {
+ throw new ArgumentException("topic templates must be parameter-less when sending " + topicTemplate.Template);
+ }
+
+ MqttTopicValidator.ThrowIfInvalid(topicTemplate.Template);
+ return builder.WithTopic(topicTemplate.Template);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs b/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs
new file mode 100644
index 00000000..11739f5e
--- /dev/null
+++ b/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs
@@ -0,0 +1,101 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using MQTTnet.Extensions.TopicTemplate;
+
+namespace MQTTnet.Tests.Extensions
+{
+ [TestClass]
+ public sealed class MqttTopicTemplate_Tests
+ {
+ [TestMethod]
+ public void BasicOps()
+ {
+ var template = new MqttTopicTemplate("A/B/{param}/D");
+ Assert.AreEqual("A/B/C/D", template.WithParameter("param", "D"));
+ Assert.AreEqual("A/B/+/D", template.WithoutParameter("param"));
+ Assert.AreEqual("A/B/+/D", template.TopicFilter);
+ Assert.AreEqual("A/B/+/D/#", template.TopicTreeRootFilter);
+
+ // corner cases
+ Assert.AreEqual("", new MqttTopicTemplate("").TopicFilter);
+ Assert.AreEqual("#", new MqttTopicTemplate("#").TopicFilter);
+ Assert.AreEqual("+", new MqttTopicTemplate("+").TopicFilter);
+ Assert.AreEqual("/", new MqttTopicTemplate("/").TopicFilter);
+ }
+
+ [TestMethod]
+ public void BasicOpsWithHash()
+ {
+ var template = new MqttTopicTemplate("A/B/{param}/D/#");
+ Assert.AreEqual("A/B/C/D/#", template.WithParameter("param", "D"));
+ Assert.AreEqual("A/B/+/D/#", template.WithoutParameter("param"));
+ Assert.AreEqual("A/B/+/D/#", template.TopicFilter);
+ Assert.AreEqual("A/B/+/D/#", template.TopicTreeRootFilter);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentException))]
+ 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", "e/f");
+ }
+
+ [TestMethod]
+ public void DynamicRoutingSupport()
+ {
+ var v1template = new MqttTopicTemplate("A/v1/{id}/F");
+ var v2template = new MqttTopicTemplate("A/v2/foo/{id}/{bar}");
+ var incoming_v1_topic = "A/v1/32/F";
+
+ var compat_v2_topic = v2template
+ .WithParameterValuesFrom(
+ v1template.ParseParameterValues(incoming_v1_topic))
+ .WithParameter("bar", "unknown");
+
+ // id: 32 was relocated and bar: unknown added
+ Assert.AreEqual("A/v2/foo/32/unknown", compat_v2_topic.Template);
+ }
+
+ [TestMethod]
+ public void SubscriptionSupport()
+ {
+ var template = new MqttTopicTemplate("A/v1/{param}/F");
+ var filter = template.BuildFilter()
+ .WithAtLeastOnceQoS()
+ .WithNoLocal()
+ .Build();
+ Assert.AreEqual("A/v1/+/F", filter.Topic);
+ }
+
+ [TestMethod]
+ public void CanonicalPrefixFilter()
+ {
+ var template1 = new MqttTopicTemplate("A/v1/{param}/F");
+ var template2 = new MqttTopicTemplate("A/v1/E/F");
+ var template3 = new MqttTopicTemplate("A/v1/E/F/G/H");
+ var canonicalFilter = MqttTopicTemplate.FindCanonicalPrefix(new[] { template1, template2, template3 });
+ Assert.AreEqual("A/v1/+/+", canonicalFilter.TopicFilter);
+ Assert.AreEqual("A/v1/+/+/#", canonicalFilter.TopicTreeRootFilter);
+
+ var template4 = new MqttTopicTemplate("A/v2/{prefix}");
+ var canonicalFilter2 = MqttTopicTemplate.FindCanonicalPrefix(new[] { template1, template2, template3, template4 });
+ Assert.AreEqual("A/+/+", canonicalFilter2.TopicFilter);
+ Assert.AreEqual("A/+/#", canonicalFilter2.TopicTreeRootFilter);
+ }
+
+
+ }
+}
\ No newline at end of file
diff --git a/Source/MQTTnet.Tests/MQTTnet.Tests.csproj b/Source/MQTTnet.Tests/MQTTnet.Tests.csproj
index c19b803f..adf62ce8 100644
--- a/Source/MQTTnet.Tests/MQTTnet.Tests.csproj
+++ b/Source/MQTTnet.Tests/MQTTnet.Tests.csproj
@@ -21,6 +21,7 @@
+