// Full Go-parity MQTT topic ↔ NATS subject translation. // Go reference: golang/nats-server/server/mqtt.go mqttToNATSSubjectConversion ~line 2200 // // Rules: // MQTT → NATS: // '/' → '.' (separator) // '+' → '*' (single-level wildcard) // '#' → '>' (multi-level wildcard) // '.' in MQTT topics must be escaped (replaced with a placeholder) // Empty levels (leading/trailing/consecutive slashes) produce empty tokens // '$' prefix topics are protected from wildcard matching per MQTT spec [MQTT-4.7.2-1] // // NATS → MQTT (reverse): // '.' → '/' // '*' → '+' // '>' → '#' using System.Text; namespace NATS.Server.Mqtt; /// /// Translates MQTT topics/filters to NATS subjects and vice versa with full Go parity. /// Go reference: mqtt.go mqttToNATSSubjectConversion, mqttNATSToMQTTSubjectConversion. /// public static class MqttTopicMapper { // Escape sequence for dots that appear in MQTT topic names. // Go uses _DOT_ internally to represent a literal dot in the NATS subject. private const string DotEscape = "_DOT_"; private const string DotEscapeReverse = "."; /// /// Translates an MQTT topic or filter to a NATS subject. /// Handles wildcards, dot escaping, empty levels, and '$' prefix protection. /// public static string MqttToNats(string mqttTopic) { if (mqttTopic.Length == 0) return string.Empty; var sb = new StringBuilder(mqttTopic.Length); for (var i = 0; i < mqttTopic.Length; i++) { switch (mqttTopic[i]) { case '/': sb.Append('.'); break; case '+': sb.Append('*'); break; case '#': sb.Append('>'); break; case '.': // Dots in MQTT topic names must be escaped for NATS sb.Append(DotEscape); break; default: sb.Append(mqttTopic[i]); break; } } return sb.ToString(); } /// /// Translates a NATS subject back to an MQTT topic. /// Reverses the mapping: '.' → '/', '*' → '+', '>' → '#', '_DOT_' → '.'. /// public static string NatsToMqtt(string natsSubject) { if (natsSubject.Length == 0) return string.Empty; // First, replace _DOT_ escape sequences back to dots var working = natsSubject.Replace(DotEscape, "\x00"); var sb = new StringBuilder(working.Length); for (var i = 0; i < working.Length; i++) { switch (working[i]) { case '.': sb.Append('/'); break; case '*': sb.Append('+'); break; case '>': sb.Append('#'); break; case '\x00': sb.Append('.'); break; default: sb.Append(working[i]); break; } } return sb.ToString(); } /// /// Returns true if an MQTT topic starts with '$', which means it should /// NOT be matched by wildcard subscriptions (MQTT spec [MQTT-4.7.2-1]). /// Topics starting with '$' are reserved for system/server use. /// public static bool IsDollarTopic(string mqttTopic) => mqttTopic.Length > 0 && mqttTopic[0] == '$'; /// /// Returns true if an MQTT topic filter starts with '$', indicating /// it explicitly targets system topics. /// public static bool IsDollarFilter(string mqttFilter) => mqttFilter.Length > 0 && mqttFilter[0] == '$'; /// /// Checks if a wildcard filter would match a '$' topic. /// Per MQTT spec, wildcard filters (starting with '#' or '+') must NOT /// match topics beginning with '$'. Only explicit '$' filters match '$' topics. /// public static bool WildcardMatchesDollarTopic(string mqttFilter, string mqttTopic) { if (!IsDollarTopic(mqttTopic)) return true; // non-$ topics are always matchable // $ topics only matched by filters that also start with $ return IsDollarFilter(mqttFilter); } }