From 2410d7b4c100d844ff231a57ffbac478b5c5c6a8 Mon Sep 17 00:00:00 2001 From: simonthum Date: Tue, 23 Apr 2024 20:09:56 +0200 Subject: [PATCH] Utility class supporting dynamic topics using topic templates (#1932) * Add MqttTopicTemplate The topic template class is a way to deal with dynamic topics that is less cumbersome and error-prone than String.Format or other string fu one might use to handle dynamic topics. Example: MyCity/{street}/{crossing}/lights 'street' and 'crossing' would be parameters. The class and its extension methods are intended to support dynamic subscription/publication, message-topic matching and routing. The template syntax is modelled after the well-known moustache syntax, which conincidentally is also published as the 'AsyncAPI channel address expressions'. * Add MQTTnet.Extensions.TopicTemplate to solution * Apply code style from team shared resharper settings. Add missing file headers. * Update release notes --------- Co-authored-by: christian <6939810+chkr1011@users.noreply.github.com> --- .github/workflows/ReleaseNotes.md | 1 + MQTTnet.sln | 22 +- .../MQTTnet.Extensions.Rpc/IMqttRpcClient.cs | 4 + .../MQTTnet.Extensions.TopicTemplate.csproj | 69 ++++ .../MqttTopicTemplate.cs | 334 ++++++++++++++++++ .../TopicTemplateExtensions.cs | 151 ++++++++ .../Extensions/MqttTopicTemplate_Tests.cs | 101 ++++++ Source/MQTTnet.Tests/MQTTnet.Tests.csproj | 1 + 8 files changed, 674 insertions(+), 9 deletions(-) create mode 100644 Source/MQTTnet.Extensions.TopicTemplate/MQTTnet.Extensions.TopicTemplate.csproj create mode 100644 Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs create mode 100644 Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs create mode 100644 Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs 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 @@ +