Repository: smithy-lang/smithy
Pull request #2690 (2025-07-01 18:18:59)

MQTT Topic Filter Support
#### Background
Adds support (parsing, validation, conflict-checking) for traits to reference MQTT topic filters.  Topic filter support is required for the new request-response MQTT service clients being released by the IoT SDKs.

Fixes a bug where the topic parser incorrectly skips a trailing or preceding empty topic level.

#### Testing
* Tests have been added for topic filter parsing and conflict checking.
* Tests have been added to cover initial and trailing empty topic levels.

#### Links
* Further information about topics and topic filters can be found in MQTT specification docs: https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901241

---
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/.changes/next-release/feature-972f78d227a4fe30f8512c53c8e5d2ecb2349fc1.json b/.changes/next-release/feature-972f78d227a4fe30f8512c53c8e5d2ecb2349fc1.json
new file mode 100644
index 000000000..b92c37671
--- /dev/null
+++ b/.changes/next-release/feature-972f78d227a4fe30f8512c53c8e5d2ecb2349fc1.json
@@ -0,0 +1,5 @@
+{
+  "type": "feature",
+  "description": "Adds MQTT topic filter parsing and conflict support; fixes a parsing bug that treated trailing and preceding empty levels incorrectly",
+  "pull_requests": []
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index a77841d52..819a2c942 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -84,6 +84,8 @@ configure<JReleaserExtension> {
                     active = Active.ALWAYS
                     url = "https://central.sonatype.com/api/v1/publisher"
                     stagingRepository(stagingDir().get().asFile.path)
+                    maxRetries = 100
+                    retryDelay = 60
                 }
             }
         }
diff --git a/docs/source-2.0/spec/http-bindings.rst b/docs/source-2.0/spec/http-bindings.rst
index a31c63f23..ab1d80cf1 100644
--- a/docs/source-2.0/spec/http-bindings.rst
+++ b/docs/source-2.0/spec/http-bindings.rst
@@ -656,7 +656,53 @@ Serialization rules
   format by default, as defined in the ``IMF-fixdate`` production of
   :rfc:`9110#section-5.6.7`. The :ref:`timestampFormat-trait` MAY be used
   to use a custom serialization format.
+* If a member with the :ref:`httpHeader-trait` and a member with the
+  :ref:`httpPrefixHeaders-trait` set the same header, clients MUST use
+  the value set by the member with the :ref:`httpHeader-trait` and
+  disregard the value set by :ref:`httpPrefixHeaders-trait`. For
+  example, given the following model:
 
+.. code-block:: smithy
+
+    @readonly
+    @http(method: "GET", uri: "/myOperation")
+    operation MyOperation {
+        input: MyOperationInput
+    }
+
+    @input
+    structure MyOperationInput {
+        @httpPrefixHeaders("X-Foo-")
+        headers: MapOfStrings
+
+        @httpHeader("X-Foo-Value")
+        foo: String
+    }
+
+    map MapOfStrings {
+        key: String
+        value: String
+    }
+
+And given the following input to ``MyOperation``:
+
+.. code-block:: json
+
+    {
+        "headers": {
+            "Value": "not sent"
+        }
+        "foo": "resolved"
+    }
+
+An example HTTP request would be serialized as:
+
+::
+
+    GET /myOperation
+    Host: <server>
+    X-Foo-Value: resolved
+  
 .. note::
 
     While there is no limit placed on the length of an HTTP header field, many
@@ -722,6 +768,99 @@ Various HTTP headers are highly discouraged for the ``httpHeader`` and
         need to be modeled.
 
 
+.. smithy-trait:: smithy.api#httpPrefixHeaders
+.. _httpPrefixHeaders-trait:
+
+``httpPrefixHeaders`` trait
+===========================
+
+Summary
+    Binds a map of key-value pairs to prefixed HTTP headers.
+Trait selector
+    .. code-block:: none
+
+        structure > member
+        :test(> map :not([trait|sparse]) > member[id|member=value] > string)
+
+    The ``httpPrefixHeaders`` trait can be applied to ``structure`` members
+    that target a ``map`` of ``string``. The targeted map MUST NOT be marked
+    with the :ref:`sparse-trait`.
+Value type
+    ``string`` value that defines the prefix to prepend to each header field
+    name stored in the targeted map member. For example, given a prefix value
+    of "X-Amz-Meta-" and a map key entry of "Baz", the resulting header field
+    name serialized in the message is "X-Amz-Meta-Baz".
+Conflicts with
+   :ref:`httpLabel-trait`, :ref:`httpQuery-trait`, :ref:`httpQueryParams-trait`,
+   :ref:`httpHeader-trait`, :ref:`httpPayload-trait`,
+   :ref:`httpResponseCode-trait`
+Structurally exclusive
+    Only a single structure member can be bound to ``httpPrefixHeaders``.
+
+Given the following Smithy model:
+
+.. code-block:: smithy
+
+    @readonly
+    @http(method: "GET", uri: "/myOperation")
+    operation MyOperation {
+        input: MyOperationInput
+    }
+
+    @input
+    structure MyOperationInput {
+        @httpPrefixHeaders("X-Foo-")
+        headers: StringMap
+    }
+
+    map StringMap {
+        key: String
+        value: String
+    }
+
+And given the following input to ``MyOperation``:
+
+.. code-block:: json
+
+    {
+        "headers": {
+            "first": "hi",
+            "second": "there"
+        }
+    }
+
+An example HTTP request would be serialized as:
+
+::
+
+    GET /myOperation
+    Host: <server>
+    X-Foo-first: hi
+    X-Foo-second: there
+
+Disambiguation of ``httpPrefixHeaders``
+---------------------------------------
+
+In order to differentiate ``httpPrefixHeaders`` from other headers, when
+``httpPrefixHeaders`` are used with a non-empty string, no other
+:ref:`httpHeader-trait` bindings can start with the same prefix provided in
+``httpPrefixHeaders`` trait.
+
+If ``httpPrefixHeaders`` is set to an empty string, then other members
+can be bound to ``headers`` by using :ref:`httpHeader-trait`. This can
+lead to ambiguity on the source of provided header values, in this
+case the value using :ref:`httpHeader-trait` MUST be used instead of
+any value given by ``httpPrefixHeaders``.
+
+
+
+.. note::
+
+    ``httpPrefixHeaders`` is only considered when applied to top-level members
+    of an operation's input, output, or error structures. This trait has no
+    meaning in any other context and is simply ignored.
+
+
 .. smithy-trait:: smithy.api#httpLabel
 .. _httpLabel-trait:
 
@@ -904,139 +1043,6 @@ Serialization rules
     in any other context and is simply ignored.
 
 
-.. smithy-trait:: smithy.api#httpPrefixHeaders
-.. _httpPrefixHeaders-trait:
-
-``httpPrefixHeaders`` trait
-===========================
-
-Summary
-    Binds a map of key-value pairs to prefixed HTTP headers.
-Trait selector
-    .. code-block:: none
-
-        structure > member
-        :test(> map :not([trait|sparse]) > member[id|member=value] > string)
-
-    The ``httpPrefixHeaders`` trait can be applied to ``structure`` members
-    that target a ``map`` of ``string``. The targeted map MUST NOT be marked
-    with the :ref:`sparse-trait`.
-Value type
-    ``string`` value that defines the prefix to prepend to each header field
-    name stored in the targeted map member. For example, given a prefix value
-    of "X-Amz-Meta-" and a map key entry of "Baz", the resulting header field
-    name serialized in the message is "X-Amz-Meta-Baz".
-Conflicts with
-   :ref:`httpLabel-trait`, :ref:`httpQuery-trait`, :ref:`httpQueryParams-trait`,
-   :ref:`httpHeader-trait`, :ref:`httpPayload-trait`,
-   :ref:`httpResponseCode-trait`
-Structurally exclusive
-    Only a single structure member can be bound to ``httpPrefixHeaders``.
-
-Given the following Smithy model:
-
-.. code-block:: smithy
-
-    @readonly
-    @http(method: "GET", uri: "/myOperation")
-    operation MyOperation {
-        input: MyOperationInput
-    }
-
-    @input
-    structure MyOperationInput {
-        @httpPrefixHeaders("X-Foo-")
-        headers: StringMap
-    }
-
-    map StringMap {
-        key: String
-        value: String
-    }
-
-And given the following input to ``MyOperation``:
-
-.. code-block:: json
-
-    {
-        "headers": {
-            "first": "hi",
-            "second": "there"
-        }
-    }
-
-An example HTTP request would be serialized as:
-
-::
-
-    GET /myOperation
-    Host: <server>
-    X-Foo-first: hi
-    X-Foo-second: there
-
-Given the following Smithy model that also uses the ``httpHeader`` trait:
-
-.. code-block:: smithy
-
-    @readonly
-    @http(method: "GET", uri: "/myOperation")
-    operation MyOperation {
-        input: MyOperationInput
-    }
-
-    @input
-    structure MyOperationInput {
-        @httpPrefixHeaders("X-Foo-")
-        headers: MapOfStrings
-
-        @httpHeader("X-Foo-Value")
-        foo: String
-    }
-
-    map MapOfStrings {
-        key: String
-        value: String
-    }
-
-And given the following input to ``MyOperation``:
-
-.. code-block:: json
-
-    {
-        "headers": {
-            "Value": "not sent"
-        }
-        "foo": "resolved"
-    }
-
-An example HTTP request would be serialized as:
-
-::
-
-    GET /myOperation
-    Host: <server>
-    X-Foo-Value: resolved
-
-
-Disambiguation of ``httpPrefixHeaders``
----------------------------------------
-
-In order to differentiate ``httpPrefixHeaders`` from other headers, when
-``httpPrefixHeaders`` are used with a non-empty string, no other
-:ref:`httpHeader-trait` bindings can start with the same prefix provided in
-``httpPrefixHeaders`` trait.
-
-If ``httpPrefixHeaders`` is set to an empty string, then other members can be
-bound to ``headers``. However, this can lead to ambiguity on the source of
-provided header values.
-
-.. note::
-
-    ``httpPrefixHeaders`` is only considered when applied to top-level members
-    of an operation's input, output, or error structures. This trait has no
-    meaning in any other context and is simply ignored.
-
-
 .. smithy-trait:: smithy.api#httpQuery
 .. _httpQuery-trait:
 
diff --git a/docs/source-2.0/spec/model.rst b/docs/source-2.0/spec/model.rst
index d07630146..9917dd986 100644
--- a/docs/source-2.0/spec/model.rst
+++ b/docs/source-2.0/spec/model.rst
@@ -908,9 +908,9 @@ shape. Members and references within a model MUST NOT target shapes.
 Summary
     Marks a shape as a :ref:`trait <traits>`.
 Trait selector
-    ``:is(simpleType, list, map, set, structure, union)``
+    ``:is(simpleType, list, map, structure, union)``
 
-    This trait can only be applied to simple types, ``list``, ``map``, ``set``,
+    This trait can only be applied to simple types, ``list``, ``map``,
     ``structure``, and ``union`` shapes.
 Value type
     ``structure``
diff --git a/smithy-aws-endpoints/src/main/resources/software/amazon/smithy/rulesengine/aws/language/functions/partition/partitions.json b/smithy-aws-endpoints/src/main/resources/software/amazon/smithy/rulesengine/aws/language/functions/partition/partitions.json
index 1910fe30e..e8ee5f299 100644
--- a/smithy-aws-endpoints/src/main/resources/software/amazon/smithy/rulesengine/aws/language/functions/partition/partitions.json
+++ b/smithy-aws-endpoints/src/main/resources/software/amazon/smithy/rulesengine/aws/language/functions/partition/partitions.json
@@ -85,7 +85,7 @@
         "implicitGlobalRegion": "us-iso-east-1"
       },
       "regions": {
-        "us-iso-east-1":  {},
+        "us-iso-east-1": {},
         "us-iso-west-1": {},
         "aws-iso-global": {}
       }
@@ -107,30 +107,47 @@
       }
     },
     {
-      "id" : "aws-iso-e",
-      "outputs" : {
-        "dnsSuffix" : "cloud.adc-e.uk",
-        "dualStackDnsSuffix" : "cloud.adc-e.uk",
-        "implicitGlobalRegion" : "eu-isoe-west-1",
-        "name" : "aws-iso-e",
-        "supportsDualStack" : false,
-        "supportsFIPS" : true
+      "id": "aws-iso-e",
+      "outputs": {
+        "dnsSuffix": "cloud.adc-e.uk",
+        "dualStackDnsSuffix": "cloud.adc-e.uk",
+        "implicitGlobalRegion": "eu-isoe-west-1",
+        "name": "aws-iso-e",
+        "supportsDualStack": false,
+        "supportsFIPS": true
       },
-      "regionRegex" : "^eu\\-isoe\\-\\w+\\-\\d+$",
-      "regions" : { }
+      "regionRegex": "^eu\\-isoe\\-\\w+\\-\\d+$",
+      "regions": {}
     },
     {
-      "id" : "aws-iso-f",
-      "outputs" : {
-        "dnsSuffix" : "csp.hci.ic.gov",
-        "dualStackDnsSuffix" : "csp.hci.ic.gov",
-        "implicitGlobalRegion" : "us-isof-south-1",
-        "name" : "aws-iso-f",
-        "supportsDualStack" : false,
-        "supportsFIPS" : true
+      "id": "aws-iso-f",
+      "outputs": {
+        "dnsSuffix": "csp.hci.ic.gov",
+        "dualStackDnsSuffix": "csp.hci.ic.gov",
+        "implicitGlobalRegion": "us-isof-south-1",
+        "name": "aws-iso-f",
+        "supportsDualStack": false,
+        "supportsFIPS": true
       },
-      "regionRegex" : "^us\\-isof\\-\\w+\\-\\d+$",
-      "regions" : { }
+      "regionRegex": "^us\\-isof\\-\\w+\\-\\d+$",
+      "regions": {}
+    },
+    {
+      "id": "aws-eusc",
+      "outputs": {
+        "dnsSuffix": "amazonaws.eu",
+        "dualStackDnsSuffix": "amazonaws.eu",
+        "implicitGlobalRegion": "eusc-de-east-1",
+        "name": "aws-eusc",
+        "supportsDualStack": false,
+        "supportsFIPS": true
+      },
+      "regionRegex": "^eusc\\-(de)\\-\\w+\\-\\d+$",
+      "regions": {
+        "eusc-de-east-1": {
+          "description": "EU (Germany)"
+        }
+      }
     }
   ]
-}
+}
\ No newline at end of file
diff --git a/smithy-mqtt-traits/src/main/java/software/amazon/smithy/mqtt/traits/Topic.java b/smithy-mqtt-traits/src/main/java/software/amazon/smithy/mqtt/traits/Topic.java
index 9c515ff48..21ddae568 100644
--- a/smithy-mqtt-traits/src/main/java/software/amazon/smithy/mqtt/traits/Topic.java
+++ b/smithy-mqtt-traits/src/main/java/software/amazon/smithy/mqtt/traits/Topic.java
@@ -30,23 +30,62 @@ public final class Topic {
     }
 
     /**
-     * Parses an MQTT topic and labels.
+     * Parses a string into an MQTT topic, including substitution labels.
      *
-     * @param topic Topic to parse.
+     * @param topic string to parse into a modeled MQTT topic
      * @return Returns the parsed topic.
      * @throws TopicSyntaxException if the topic is malformed.
      */
     public static Topic parse(String topic) {
+        return parse(TopicRule.TOPIC, topic);
+    }
+
+    /**
+     * Parses a string into an MQTT topic, including substitution labels.
+     *
+     * @param rule MQTT-specific rule to apply to the parsing
+     * @param topic string to parse into a modeled MQTT topic
+     * @return Returns the parsed topic.
+     * @throws TopicSyntaxException if the topic is malformed.
+     */
+    public static Topic parse(TopicRule rule, String topic) {
         List<Level> levels = new ArrayList<>();
         Set<String> labels = new HashSet<>();
 
-        for (String level : topic.split("/")) {
-            if (level.contains("#") || level.contains("+")) {
+        if (topic.isEmpty()) {
+            throw new TopicSyntaxException("Topics and topic filters may not be empty");
+        }
+
+        boolean hasFullWildcard = false;
+
+        // use negative limit to allow zero-length captures which matches MQTT specification behavior
+        for (String level : topic.split("/", -1)) {
+            if (hasFullWildcard) {
                 throw new TopicSyntaxException(format(
-                        "Wildcard levels are not allowed in MQTT topics. Found `%s` in `%s`",
+                        "A full wildcard must be the last segment in a topic filter. Found `%s` in `%s`",
                         level,
                         topic));
-            } else if (level.startsWith("{") && level.endsWith("}")) {
+            }
+
+            if (level.contains("#") || level.contains("+")) {
+                if (rule == TopicRule.TOPIC) {
+                    throw new TopicSyntaxException(format(
+                            "Wildcard levels are not allowed in MQTT topics. Found `%s` in `%s`",
+                            level,
+                            topic));
+                } else if (level.length() > 1) {
+                    throw new TopicSyntaxException(format(
+                            "A wildcard must be the entire topic segment. Found `%s` in `%s`",
+                            level,
+                            topic));
+                }
+
+                if (level.equals("#")) {
+                    hasFullWildcard = true;
+                }
+            }
+
+            if (level.startsWith("{") && level.endsWith("}")) {
                 String label = level.substring(1, level.length() - 1);
                 if (!LABEL_PATTERN.matcher(label).matches()) {
                     throw new TopicSyntaxException(format(
@@ -119,11 +158,27 @@ public final class Topic {
         for (int i = 0; i < minSize; i++) {
             Level thisLevel = levels.get(i);
             Level otherLevel = other.levels.get(i);
+
+            String thisValue = thisLevel.getContent();
+            String otherValue = otherLevel.getContent();
+
+            // multi-level wildcard will conflict regardless of what the other level is
+            if (thisValue.equals("#") || otherValue.equals("#")) {
+                return true;
+            }
+
+            // single-level wildcard is a level match regardless of the other level
+            if (thisValue.equals("+") || otherValue.equals("+")) {
+                continue;
+            }
+
             // Both are static levels with different values.
             if (!thisLevel.isLabel() && !otherLevel.isLabel()
-                    && !thisLevel.getContent().equals(otherLevel.getContent())) {
+                    && !thisValue.equals(otherValue)) {
                 return false;
-            } else if (thisLevel.isLabel() != otherLevel.isLabel()) {
+            }
+
+            if (thisLevel.isLabel() != otherLevel.isLabel()) {
                 // One is static and the other is not, so there is not a
                 // conflict. One is more specific than the other.
                 return false;
@@ -222,4 +277,21 @@ public final class Topic {
             return Objects.hash(isLabel, value);
         }
     }
+
+    /**
+     * Controls the rules for how a value is parsed into a topic.
+     */
+    public enum TopicRule {
+
+        /**
+         * Treat the value as a basic topic.  Wildcards are not allowed.
+         */
+        TOPIC,
+
+        /**
+         * Treat the value as a topic filter.  Single and multi-level wildcards are allowed if they follow the
+         * MQTT specification properly.
+         */
+        FILTER
+    }
 }
diff --git a/smithy-trait-codegen/src/main/java/software/amazon/smithy/traitcodegen/TraitCodegenSettings.java b/smithy-trait-codegen/src/main/java/software/amazon/smithy/traitcodegen/TraitCodegenSettings.java
index 188b59666..a72b5f5bd 100644
--- a/smithy-trait-codegen/src/main/java/software/amazon/smithy/traitcodegen/TraitCodegenSettings.java
+++ b/smithy-trait-codegen/src/main/java/software/amazon/smithy/traitcodegen/TraitCodegenSettings.java
@@ -55,8 +55,8 @@ public final class TraitCodegenSettings {
     ) {
         this.packageName = Objects.requireNonNull(packageName);
         this.smithyNamespace = Objects.requireNonNull(smithyNamespace);
-        if (smithyNamespace.startsWith(SMITHY_API_NAMESPACE)) {
-            throw new IllegalArgumentException("The `smithy` namespace is reserved.");
+        if (smithyNamespace.equals(SMITHY_API_NAMESPACE) || smithyNamespace.startsWith(SMITHY_API_NAMESPACE + ".")) {
+            throw new IllegalArgumentException("The `smithy` namespace and its sub-namespaces are reserved.");
         }
         this.headerLines = Objects.requireNonNull(headerLines);
         this.excludeTags = Objects.requireNonNull(excludeTags);


Test patch:
diff --git a/smithy-mqtt-traits/src/test/java/software/amazon/smithy/mqtt/traits/TopicConflictTest.java b/smithy-mqtt-traits/src/test/java/software/amazon/smithy/mqtt/traits/TopicConflictTest.java
index 1b13add31..4fb82e00f 100644
--- a/smithy-mqtt-traits/src/test/java/software/amazon/smithy/mqtt/traits/TopicConflictTest.java
+++ b/smithy-mqtt-traits/src/test/java/software/amazon/smithy/mqtt/traits/TopicConflictTest.java
@@ -6,16 +6,18 @@ package software.amazon.smithy.mqtt.traits;
 
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
 public class TopicConflictTest {
     @ParameterizedTest
-    @MethodSource("data")
-    public void patternConflicts(String topicA, String topicB, boolean isConflicting) {
-        Topic a = Topic.parse(topicA);
-        Topic b = Topic.parse(topicB);
+    @MethodSource("topicCases")
+    public void topicPatternConflicts(String topicA, String topicB, boolean isConflicting) {
+        Topic a = Topic.parse(Topic.TopicRule.TOPIC, topicA);
+        Topic b = Topic.parse(Topic.TopicRule.TOPIC, topicB);
 
         if (a.conflictsWith(b) != isConflicting) {
             if (isConflicting) {
@@ -26,7 +28,7 @@ public class TopicConflictTest {
         }
     }
 
-    public static Collection<Object[]> data() {
+    public static Collection<Object[]> topicCases() {
         return Arrays.asList(new Object[][] {
                 // No conflict because a is more specific.
                 {"a", "{x}", false},
@@ -53,7 +55,81 @@ public class TopicConflictTest {
                 // No conflict
                 {"a/b/c/d", "a/{b}/c/{d}", false},
                 // No conflict.
-                {"$aws/things/{thingName}/jobs/get", "$aws/things/{thingName}/jobs/start-next", false}
+                {"$aws/things/{thingName}/jobs/get", "$aws/things/{thingName}/jobs/start-next", false},
+                // No conflict, empty second level creates mismatch with single-level topic
+                {"a/", "a", false}
+        });
+    }
+
+    @ParameterizedTest
+    @MethodSource("topicFilterCases")
+    public void topicFilterPatternConflicts(String topicA, String topicB, boolean isConflicting) {
+        Topic a = Topic.parse(Topic.TopicRule.FILTER, topicA);
+        Topic b = Topic.parse(Topic.TopicRule.FILTER, topicB);
+
+        if (a.conflictsWith(b) != isConflicting) {
+            if (isConflicting) {
+                List<String> aLevels = a.getLevels().stream().map(Topic.Level::toString).collect(Collectors.toList());
+                String aMarkedTopic = String.join("@", aLevels);
+
+                List<String> bLevels = b.getLevels().stream().map(Topic.Level::toString).collect(Collectors.toList());
+                String bMarkedTopic = String.join("@", bLevels);
+
+                Assertions.fail("Expected conflict between `" + aMarkedTopic + "` and `" + bMarkedTopic + "`");
+            } else {
+                Assertions.fail("Unexpected conflict between `" + a + "` and `" + b + "`");
+            }
+        }
+    }
+
+    public static Collection<Object[]> topicFilterCases() {
+        return Arrays.asList(new Object[][] {
+                // No conflict because a is more specific.
+                {"a", "{x}", false},
+                // No conflict because "a" is more specific than "{y}".
+                {"a/{x}", "{y}/a", false},
+                // No conflict because "a" is more specific than "{x}".
+                {"{x}/a", "a/{y}", false},
+                // Conflicts because the topics are equivalent and the same length.
+                {"a/{x}", "a/{y}", true},
+                // Does not conflict because "{x}" and "{y}" are under different level prefixes.
+                {"a/{x}", "b/{y}", false},
+                // Conflicts because they have the same levels and the same length.
+                {"a/{x}/b", "a/{y}/b", true},
+                // Does not conflict because one is longer than the other.
+                {"a/{x}/b", "a/{y}/b/{z}", false},
+                // Does not conflict because one is longer than the other.
+                {"a/{x}/b", "a/{y}/b/{z}/c", false},
+                // Do not conflict because "b" is more specific than "{b}"
+                {"a/b/c", "a/{b}/c", false},
+                // Conflicts because they are all labels at the same level.
+                {"{a}/{b}/{c}", "{x}/{y}/{z}", true},
+                // No conflicts because one is longer than the other.
+                {"{a}/{b}/{c}", "{x}/{y}/{z}/{a}", false},
+                // No conflict
+                {"a/b/c/d", "a/{b}/c/{d}", false},
+                // No conflict.
+                {"$aws/things/{thingName}/jobs/get", "$aws/things/{thingName}/jobs/start-next", false},
+                // Conflicts because multi-level wild card matches rest of path
+                {"a/#", "a/b/c/d", true},
+                // Conflicts becase single-level wild card matches segment
+                {"a/+/c", "a/b/c", true},
+                // Conflicts becase single-level wild card matches label segment
+                {"a/{b}/c", "a/+/c", true},
+                // No conflict because single-level wildcard doesn't match multi-segment "b/c"
+                {"a/+/c", "a/b/c/d", false},
+                // Conflicts because '#' matches everything
+                {"#", "/", true},
+                // Conflicts because '#' matches everything
+                {"+/a", "#", true},
+                // Conflicts because 'a/a' matches both
+                {"+/a", "a/+", true},
+                // Conflicts because single-level wildcard matches empty segments
+                {"+/+", "/", true},
+                // Conflict because wildcard matches empty level
+                {"/", "+/", true},
+                // Conflict because wildcard matches empty level
+                {"/+", "/", true},
         });
     }
 }
diff --git a/smithy-mqtt-traits/src/test/java/software/amazon/smithy/mqtt/traits/TopicTest.java b/smithy-mqtt-traits/src/test/java/software/amazon/smithy/mqtt/traits/TopicTest.java
index 90da8daf0..7f54bf7cc 100644
--- a/smithy-mqtt-traits/src/test/java/software/amazon/smithy/mqtt/traits/TopicTest.java
+++ b/smithy-mqtt-traits/src/test/java/software/amazon/smithy/mqtt/traits/TopicTest.java
@@ -19,42 +19,73 @@ import org.junit.jupiter.api.Test;
 public class TopicTest {
     @Test
     public void requiresThatLabelsSpanWholeLevel() {
-        assertThrows(TopicSyntaxException.class, () -> Topic.parse("foo/bar/{baz}bam"));
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "foo/bar/{baz}bam"));
     }
 
     @Test
     public void requiresThatLabelsContainOneCharacter() {
-        assertThrows(TopicSyntaxException.class, () -> Topic.parse("foo/bar/{}"));
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "foo/bar/{}"));
     }
 
     @Test
     public void requiresThatLabelsContainValidCharacters() {
-        assertThrows(TopicSyntaxException.class, () -> Topic.parse("foo/bar/{nope nope}"));
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "foo/bar/{nope nope}"));
     }
 
     @Test
     public void doesNotAllowDuplicateLabels() {
-        assertThrows(TopicSyntaxException.class, () -> Topic.parse("foo/bar/{nope}/{nope}"));
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "foo/bar/{nope}/{nope}"));
     }
 
     @Test
-    public void doesNotSupportSingleLevelWildCards() {
-        assertThrows(TopicSyntaxException.class, () -> Topic.parse("foo/bar/+/nope"));
+    public void doesNotSupportSingleLevelWildCardsOnTopics() {
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "foo/bar/+/nope"));
     }
 
     @Test
-    public void doesNotSupportMultiLevelWildCards() {
-        assertThrows(TopicSyntaxException.class, () -> Topic.parse("foo/bar/nope/#"));
+    public void doesNotSupportMultiLevelWildCardsOnTopics() {
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "foo/bar/nope/#"));
     }
 
     @Test
     public void detectsLabelSyntaxError() {
-        assertThrows(TopicSyntaxException.class, () -> Topic.parse("foo/bar/nope/}"));
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "foo/bar/nope/}"));
+    }
+
+    @Test
+    public void doesNotAllowEmpty() {
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, ""));
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.FILTER, ""));
+    }
+
+    @Test
+    public void doesNotAllowSingleLevelWildcardInTopic() {
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "test/+/bar"));
+    }
+
+    @Test
+    public void doesNotAllowMultiLevelWildcardInTopic() {
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.TOPIC, "test/#"));
+    }
+
+    @Test
+    public void doesNotAllowMixedSingleLevelWildcard() {
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.FILTER, "test/+d/bar"));
+    }
+
+    @Test
+    public void doesNotAllowMixedMultiLevelWildcard() {
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.FILTER, "test/uff#dah/bar"));
+    }
+
+    @Test
+    public void doesNotAllowSegmentsAfterMultiLevelWildcardTopicFilter() {
+        assertThrows(TopicSyntaxException.class, () -> Topic.parse(Topic.TopicRule.FILTER, "test/#/bar"));
     }
 
     @Test
     public void parsesTopicWithNoLabels() {
-        Topic topic = Topic.parse("foo/bar/baz");
+        Topic topic = Topic.parse(Topic.TopicRule.TOPIC, "foo/bar/baz");
 
         assertThat(topic.toString(), equalTo("foo/bar/baz"));
         assertThat(topic.getLevels(),
@@ -68,9 +99,56 @@ public class TopicTest {
         assertThat(topic, equalTo(topic));
     }
 
+    @Test
+    public void parsesTopicFilterWithNoLabels() {
+        Topic topic = Topic.parse(Topic.TopicRule.FILTER, "foo/+/baz/#");
+
+        assertThat(topic.toString(), equalTo("foo/+/baz/#"));
+        assertThat(topic.getLevels(),
+                contains(
+                        new Topic.Level("foo"),
+                        new Topic.Level("+"),
+                        new Topic.Level("baz"),
+                        new Topic.Level("#")));
+        assertThat(topic.conflictsWith(topic), is(true));
+        assertThat(topic.getLabels(), empty());
+        assertFalse(topic.hasLabel("foo"));
+        assertThat(topic, equalTo(topic));
+    }
+
+    @Test
+    public void parsesSingleSlashTopic() {
+        Topic topic = Topic.parse(Topic.TopicRule.FILTER, "/");
+
+        assertThat(topic.toString(), equalTo("/"));
+        assertThat(topic.getLevels().size(), is(2));
+        assertThat(topic.getLevels().get(0).getContent(), equalTo(""));
+        assertThat(topic.getLevels().get(1).getContent(), equalTo(""));
+    }
+
+    @Test
+    public void parsesTopicWithTrailingSlash() {
+        Topic topic = Topic.parse(Topic.TopicRule.FILTER, "hello/");
+
+        assertThat(topic.toString(), equalTo("hello/"));
+        assertThat(topic.getLevels().size(), is(2));
+        assertThat(topic.getLevels().get(0).getContent(), equalTo("hello"));
+        assertThat(topic.getLevels().get(1).getContent(), equalTo(""));
+    }
+
+    @Test
+    public void parsesTopicWithEmptyTopLevel() {
+        Topic topic = Topic.parse(Topic.TopicRule.FILTER, "/world");
+
+        assertThat(topic.toString(), equalTo("/world"));
+        assertThat(topic.getLevels().size(), is(2));
+        assertThat(topic.getLevels().get(0).getContent(), equalTo(""));
+        assertThat(topic.getLevels().get(1).getContent(), equalTo("world"));
+    }
+
     @Test
     public void parsesTopicWithLabels() {
-        Topic topic = Topic.parse("foo/{foo}/bar/{baz}");
+        Topic topic = Topic.parse(Topic.TopicRule.TOPIC, "foo/{foo}/bar/{baz}");
 
         assertThat(topic, equalTo(topic));
         assertThat(topic.toString(), equalTo("foo/{foo}/bar/{baz}"));
@@ -93,8 +171,8 @@ public class TopicTest {
 
     @Test
     public void topicEquality() {
-        Topic topic1 = Topic.parse("foo/bar");
-        Topic topic2 = Topic.parse("foo/{bar}");
+        Topic topic1 = Topic.parse(Topic.TopicRule.TOPIC, "foo/bar");
+        Topic topic2 = Topic.parse(Topic.TopicRule.TOPIC, "foo/{bar}");
 
         assertThat(topic1, equalTo(topic1));
         assertThat(topic1, not(equalTo(topic2)));