// 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+1); } foreach (string topic in from template in templates select template.Template) { root = root == null ? topic : CommonPrefix(root, topic); } if (string.IsNullOrEmpty(root)) return new MqttTopicTemplate(MqttTopicFilterComparer.MultiLevelWildcard.ToString()); if (root.Contains(MqttTopicFilterComparer.LevelSeparator) && !root.EndsWith(MqttTopicFilterComparer.LevelSeparator.ToString()) && !root.EndsWith("}")) { root = root.Substring(0, root.LastIndexOf(MqttTopicFilterComparer.LevelSeparator)+1); } if (root.EndsWith(MqttTopicFilterComparer.LevelSeparator.ToString())) root += MqttTopicFilterComparer.SingleLevelWildcard; return new MqttTopicTemplate(root); } 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]); } } } } }