From 9b0d29eaaeb1e961043834d0989fda33d55db95a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:27 +0530 Subject: [PATCH 01/17] feat(php): drop topic wildcards from the Topic helper Remove Topic::any()/all() and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- src/SDK/Language/PHP.php | 5 --- templates/php/src/ResolvedTopic.php.twig | 43 -------------------- templates/php/src/Topic.php.twig | 50 +++++------------------- templates/php/tests/TopicTest.php.twig | 20 +--------- tests/e2e/languages/php/test.php | 5 --- 5 files changed, 10 insertions(+), 113 deletions(-) delete mode 100644 templates/php/src/ResolvedTopic.php.twig diff --git a/src/SDK/Language/PHP.php b/src/SDK/Language/PHP.php index 816fa0c315..a29b327047 100644 --- a/src/SDK/Language/PHP.php +++ b/src/SDK/Language/PHP.php @@ -258,11 +258,6 @@ public function getFiles(): array 'destination' => 'src/{{ namespace | caseNamespacePath }}/Topic.php', 'template' => 'php/src/Topic.php.twig', ], - [ - 'scope' => 'default', - 'destination' => 'src/{{ namespace | caseNamespacePath }}/ResolvedTopic.php', - 'template' => 'php/src/ResolvedTopic.php.twig', - ], [ 'scope' => 'default', 'destination' => 'tests/{{ namespace | caseNamespacePath }}/TopicTest.php', diff --git a/templates/php/src/ResolvedTopic.php.twig b/templates/php/src/ResolvedTopic.php.twig deleted file mode 100644 index f6009b9b03..0000000000 --- a/templates/php/src/ResolvedTopic.php.twig +++ /dev/null @@ -1,43 +0,0 @@ - $levels - */ - public function __construct(private array $levels) - { - } - - /** - * @param array $levels - */ - public function path(array $levels): never - { - throw new \BadMethodCallException('nothing can come after all()'); - } - - public function any(): never - { - throw new \BadMethodCallException('nothing can come after all()'); - } - - public function all(): never - { - throw new \BadMethodCallException('nothing can come after all()'); - } - - public function __toString(): string - { - return \implode('/', $this->levels); - } -} diff --git a/templates/php/src/Topic.php.twig b/templates/php/src/Topic.php.twig index 230a2d3c2e..da3178b7b2 100644 --- a/templates/php/src/Topic.php.twig +++ b/templates/php/src/Topic.php.twig @@ -8,18 +8,12 @@ namespace {{ namespace | caseNamespace }}; * Helper to build MQTT topics for `Push`, level by level. * * Topic::path(['user', $userId, 'notification']); // "user/123/notification" - * Topic::path(['user'])->any()->path(['notification']); // "user/+/notification" - * Topic::path(['org'])->any()->path(['logs'])->all(); // "org/+/logs/#" + * Topic::path(['org', $orgId])->path(['logs']); // "org/42/logs" * - * `any()` matches exactly one level and `all()` everything below, at any depth (including the - * parent itself). `all()` ends the topic, so it returns a ResolvedTopic. - * - * The same names start a topic (`Topic::path()`) and extend one (`$topic->path()`); both are - * routed through `__callStatic()` and `__call()` to the protected implementations below. + * The same name starts a topic (`Topic::path()`) and extends one (`$topic->path()`); both are + * routed through `__callStatic()` and `__call()` to the protected implementation below. * * @method static Topic path(array $levels) Start a topic with the given levels, or append them. - * @method static Topic any() Start a topic with a single-level wildcard (`+`), or append one. - * @method static ResolvedTopic all() The multi-level wildcard (`#`) alone, or appended to end the topic. */ final readonly class Topic implements \Stringable { @@ -33,7 +27,7 @@ final readonly class Topic implements \Stringable /** * @param array $arguments */ - public static function __callStatic(string $name, array $arguments): self|ResolvedTopic + public static function __callStatic(string $name, array $arguments): self { return new self([])->dispatch($name, $arguments); } @@ -41,7 +35,7 @@ final readonly class Topic implements \Stringable /** * @param array $arguments */ - public function __call(string $name, array $arguments): self|ResolvedTopic + public function __call(string $name, array $arguments): self { return $this->dispatch($name, $arguments); } @@ -54,22 +48,6 @@ final readonly class Topic implements \Stringable return new self([...$this->levels, ...$this->levelsOf($levels)]); } - /** - * Append a single-level wildcard (`+`): matches exactly one level. - */ - protected function appendAny(): self - { - return new self([...$this->levels, '+']); - } - - /** - * Append the multi-level wildcard (`#`) and end the topic. - */ - protected function appendAll(): ResolvedTopic - { - return new ResolvedTopic([...$this->levels, '#']); - } - public function __toString(): string { return \implode('/', $this->levels); @@ -78,19 +56,17 @@ final readonly class Topic implements \Stringable /** * @param array $arguments */ - private function dispatch(string $name, array $arguments): self|ResolvedTopic + private function dispatch(string $name, array $arguments): self { return match ($name) { 'path' => $this->appendPath($arguments[0] ?? null), - 'any' => $this->appendAny(), - 'all' => $this->appendAll(), default => throw new \BadMethodCallException('Call to undefined method ' . self::class . '::' . $name . '()'), }; } /** * Validate the levels passed to `path()`: a non-empty array of non-empty strings, none holding - * the level separator `/` or the wildcards `+` and `#` (use `any()` and `all()` for those). + * the level separator `/` or the MQTT wildcards `+` and `#`. * * @return list */ @@ -120,20 +96,12 @@ final readonly class Topic implements \Stringable throw new \InvalidArgumentException(\sprintf('"%s" contains "/", split it: path([%s])', $level, $parts)); } - if ($level === '+') { - throw new \InvalidArgumentException('"+" is reserved, use any()'); - } - if (\str_contains($level, '+')) { - throw new \InvalidArgumentException(\sprintf('"%s" contains "+", use any() for wildcards', $level)); - } - - if ($level === '#') { - throw new \InvalidArgumentException('"#" is reserved, use all()'); + throw new \InvalidArgumentException(\sprintf('"%s" contains "+", not allowed in a topic', $level)); } if (\str_contains($level, '#')) { - throw new \InvalidArgumentException(\sprintf('"%s" contains "#", use all() for wildcards', $level)); + throw new \InvalidArgumentException(\sprintf('"%s" contains "#", not allowed in a topic', $level)); } $result[] = $level; diff --git a/templates/php/tests/TopicTest.php.twig b/templates/php/tests/TopicTest.php.twig index 333cb0324f..ab69c4d387 100644 --- a/templates/php/tests/TopicTest.php.twig +++ b/templates/php/tests/TopicTest.php.twig @@ -14,25 +14,7 @@ final class TopicTest extends TestCase $this->assertSame('user', (string) Topic::path(['user'])); $this->assertSame('user/notification', (string) Topic::path(['user', 'notification'])); $this->assertSame('user/notification', (string) Topic::path(['user'])->path(['notification'])); - } - - public function testAny(): void - { - $this->assertSame('user/+/notification', (string) Topic::path(['user'])->any()->path(['notification'])); - $this->assertSame('chat/+/+/message', (string) Topic::path(['chat'])->any()->any()->path(['message'])); - $this->assertSame('+/notification', (string) Topic::any()->path(['notification'])); - } - - public function testAll(): void - { - $this->assertSame('org/+/logs/#', (string) Topic::path(['org'])->any()->path(['logs'])->all()); - $this->assertSame('#', (string) Topic::all()); - } - - public function testNothingAfterAll(): void - { - $this->expectException(\BadMethodCallException::class); - Topic::all()->any(); + $this->assertSame('org/42/logs', (string) Topic::path(['org', '42'])->path(['logs'])); } /** diff --git a/tests/e2e/languages/php/test.php b/tests/e2e/languages/php/test.php index 986054082d..879be9d25e 100644 --- a/tests/e2e/languages/php/test.php +++ b/tests/e2e/languages/php/test.php @@ -413,11 +413,6 @@ public function toArray(): array // Topic helper tests echo Topic::path(['user', '123', 'notification']) . "\n"; echo Topic::path(['org', '42', 'user', '123'])->path(['notification']) . "\n"; -echo Topic::path(['user'])->any()->path(['notification']) . "\n"; -echo Topic::path(['chat'])->any()->any()->path(['message']) . "\n"; -echo Topic::path(['org'])->any()->path(['logs'])->all() . "\n"; -echo Topic::any()->path(['notification']) . "\n"; -echo Topic::all() . "\n"; foreach ([ ['empty path', []], ['empty level', ['user', '']], From 8b3c9d2d32d10470d971a791ef3f824dfaf6b322 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:34 +0530 Subject: [PATCH 02/17] feat(web): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/web/src/index.ts.twig | 2 +- templates/web/src/services/push.ts.twig | 21 +++-------- templates/web/src/topic.ts.twig | 48 +++---------------------- tests/e2e/languages/web/index.html | 5 --- tests/e2e/languages/web/node.js | 5 --- 5 files changed, 10 insertions(+), 71 deletions(-) diff --git a/templates/web/src/index.ts.twig b/templates/web/src/index.ts.twig index 212ef88fee..c2a915b5d2 100644 --- a/templates/web/src/index.ts.twig +++ b/templates/web/src/index.ts.twig @@ -42,7 +42,7 @@ export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; -export { Topic, ResolvedTopic } from './topic'; +export { Topic } from './topic'; export { Channel } from './channel'; export { Operator, Condition } from './operator'; {% for enum in allEnums %} diff --git a/templates/web/src/services/push.ts.twig b/templates/web/src/services/push.ts.twig index beff5de44f..5ac6083b68 100644 --- a/templates/web/src/services/push.ts.twig +++ b/templates/web/src/services/push.ts.twig @@ -6,7 +6,7 @@ import type { } from 'mqtt'; import { Client } from '../client'; -import type { ResolvedTopic, Topic } from '../topic'; +import type { Topic } from '../topic'; /** A message delivered on a subscribed topic (the MQTT analog of a Realtime event). */ export interface PushMessage { @@ -253,7 +253,7 @@ export class Push { * Subscribe to one or more topics. Resolves to a {@link PushSubscription} handle. * * Each topic is a string or a `Topic` builder, e.g. - * `Topic.path(['user', userId]).all()`. + * `Topic.path(['user', userId])`. * * Called with only a callback, `subscribe((message) => {...}, options)` subscribes to the * signed-in user's own topic, `users/`, like FCM/APNs delivery to a device. The @@ -275,18 +275,12 @@ export class Push { options?: SubscribeOptions, ): Promise; subscribe( - topics: - string | Topic | ResolvedTopic | (string | Topic | ResolvedTopic)[], + topics: string | Topic | (string | Topic)[], callback: MessageCallback, options?: SubscribeOptions, ): Promise; async subscribe( - topicsOrCallback: - | string - | Topic - | ResolvedTopic - | (string | Topic | ResolvedTopic)[] - | MessageCallback, + topicsOrCallback: string | Topic | (string | Topic)[] | MessageCallback, callbackOrOptions?: MessageCallback | SubscribeOptions, topicOptions: SubscribeOptions = {}, ): Promise { @@ -303,12 +297,7 @@ export class Push { } private async subscribeWith( - topicsOrCallback: - | string - | Topic - | ResolvedTopic - | (string | Topic | ResolvedTopic)[] - | MessageCallback, + topicsOrCallback: string | Topic | (string | Topic)[] | MessageCallback, callbackOrOptions: MessageCallback | SubscribeOptions | undefined, topicOptions: SubscribeOptions, ): Promise { diff --git a/templates/web/src/topic.ts.twig b/templates/web/src/topic.ts.twig index cd6cea01f7..1258f705e8 100644 --- a/templates/web/src/topic.ts.twig +++ b/templates/web/src/topic.ts.twig @@ -1,6 +1,6 @@ /** * Validate the levels passed to `path()`: a non-empty array of non-empty strings, none holding - * the level separator `/` or the wildcards `+` and `#` (use `any()` and `all()` for those). + * the level separator `/` or the MQTT wildcards `+` and `#`. */ function levelsOf(levels: unknown): string[] { if (!Array.isArray(levels)) { @@ -28,17 +28,11 @@ function levelsOf(levels: unknown): string[] { `"${level}" contains "/", split it: path([${parts}])`, ); } - if (level === '+') { - throw new Error('"+" is reserved, use any()'); - } if (level.includes('+')) { - throw new Error(`"${level}" contains "+", use any() for wildcards`); - } - if (level === '#') { - throw new Error('"#" is reserved, use all()'); + throw new Error(`"${level}" contains "+", not allowed in a topic`); } if (level.includes('#')) { - throw new Error(`"${level}" contains "#", use all() for wildcards`); + throw new Error(`"${level}" contains "#", not allowed in a topic`); } return level; }); @@ -48,11 +42,7 @@ function levelsOf(levels: unknown): string[] { * Helper to build MQTT topics for `Push`, level by level. * * Topic.path(['user', userId, 'notification']); // "user/123/notification" - * Topic.path(['user']).any().path(['notification']); // "user/+/notification" - * Topic.path(['org']).any().path(['logs']).all(); // "org/+/logs/#" - * - * `any()` matches exactly one level and `all()` everything below, at any depth (including the - * parent itself). `all()` ends the topic, so it returns a {@link ResolvedTopic}. + * Topic.path(['org', orgId]).path(['logs']); // "org/42/logs" */ export class Topic { private constructor(private readonly levels: readonly string[]) {} @@ -62,41 +52,11 @@ export class Topic { return new Topic(levelsOf(levels)); } - /** Start a topic with a single-level wildcard (`+`). */ - static any(): Topic { - return new Topic(['+']); - } - - /** The multi-level wildcard alone (`#`): matches every topic. */ - static all(): ResolvedTopic { - return new ResolvedTopic(['#']); - } - /** Append the given levels. */ path(levels: string[]): Topic { return new Topic([...this.levels, ...levelsOf(levels)]); } - /** Append a single-level wildcard (`+`): matches exactly one level. */ - any(): Topic { - return new Topic([...this.levels, '+']); - } - - /** Append the multi-level wildcard (`#`) and end the topic. */ - all(): ResolvedTopic { - return new ResolvedTopic([...this.levels, '#']); - } - - toString(): string { - return this.levels.join('/'); - } -} - -/** A topic ended by `all()`: nothing can be appended after the `#` wildcard. */ -export class ResolvedTopic { - /** @internal Created by {@link Topic.all}. */ - constructor(private readonly levels: readonly string[]) {} - toString(): string { return this.levels.join('/'); } diff --git a/tests/e2e/languages/web/index.html b/tests/e2e/languages/web/index.html index 86cec02417..50efc3327d 100644 --- a/tests/e2e/languages/web/index.html +++ b/tests/e2e/languages/web/index.html @@ -530,11 +530,6 @@ // Topic helper tests console.log(Topic.path(['user', '123', 'notification']).toString()); console.log(Topic.path(['org', '42', 'user', '123']).path(['notification']).toString()); - console.log(Topic.path(['user']).any().path(['notification']).toString()); - console.log(Topic.path(['chat']).any().any().path(['message']).toString()); - console.log(Topic.path(['org']).any().path(['logs']).all().toString()); - console.log(Topic.any().path(['notification']).toString()); - console.log(Topic.all().toString()); for (const [name, levels] of [ ['empty path', []], ['empty level', ['user', '']], diff --git a/tests/e2e/languages/web/node.js b/tests/e2e/languages/web/node.js index 4518b2ab05..06636a4133 100644 --- a/tests/e2e/languages/web/node.js +++ b/tests/e2e/languages/web/node.js @@ -337,11 +337,6 @@ async function start() { // Topic helper tests console.log(Topic.path(['user', '123', 'notification']).toString()); console.log(Topic.path(['org', '42', 'user', '123']).path(['notification']).toString()); - console.log(Topic.path(['user']).any().path(['notification']).toString()); - console.log(Topic.path(['chat']).any().any().path(['message']).toString()); - console.log(Topic.path(['org']).any().path(['logs']).all().toString()); - console.log(Topic.any().path(['notification']).toString()); - console.log(Topic.all().toString()); for (const [name, levels] of [ ['empty path', []], ['empty level', ['user', '']], From 2d80be08a3f91cbbd4d160095cd751491308a884 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:34 +0530 Subject: [PATCH 03/17] feat(node): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/node/src/index.ts.twig | 2 +- templates/node/test/topic.test.js.twig | 20 +++----------------- tests/e2e/languages/node/test.js | 5 ----- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/templates/node/src/index.ts.twig b/templates/node/src/index.ts.twig index 63df5f3652..3909e067be 100644 --- a/templates/node/src/index.ts.twig +++ b/templates/node/src/index.ts.twig @@ -7,7 +7,7 @@ export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; -export { Topic, ResolvedTopic } from './topic'; +export { Topic } from './topic'; export { Operator, Condition } from './operator'; {% for enum in allEnums %} export { {{ enum.title | caseUcfirst }} } from './enums/{{enum.title | caseKebab}}'; diff --git a/templates/node/test/topic.test.js.twig b/templates/node/test/topic.test.js.twig index 44ba8cdabc..71912d2760 100644 --- a/templates/node/test/topic.test.js.twig +++ b/templates/node/test/topic.test.js.twig @@ -9,24 +9,10 @@ describe('Topic', () => { expect(Topic.path(['user']).path(['notification']).toString()).toEqual( 'user/notification', ); - }); - test('any', () => { - expect( - Topic.path(['user']).any().path(['notification']).toString(), - ).toEqual('user/+/notification'); - expect( - Topic.path(['chat']).any().any().path(['message']).toString(), - ).toEqual('chat/+/+/message'); - expect(Topic.any().path(['notification']).toString()).toEqual( - '+/notification', + expect(Topic.path(['org', '42']).path(['logs']).toString()).toEqual( + 'org/42/logs', ); }); - test('all', () => { - expect( - Topic.path(['org']).any().path(['logs']).all().toString(), - ).toEqual('org/+/logs/#'); - expect(Topic.all().toString()).toEqual('#'); - }); test.each([ ['an empty path', []], ['an empty level', ['user', '']], @@ -39,6 +25,6 @@ describe('Topic', () => { ['a non-array', 'user'], ])('rejects %s', (_, levels) => { expect(() => Topic.path(levels)).toThrow(Error); - expect(() => Topic.any().path(levels)).toThrow(Error); + expect(() => Topic.path(['user']).path(levels)).toThrow(Error); }); }); diff --git a/tests/e2e/languages/node/test.js b/tests/e2e/languages/node/test.js index 568b9ddf6d..e114fb37d6 100644 --- a/tests/e2e/languages/node/test.js +++ b/tests/e2e/languages/node/test.js @@ -433,11 +433,6 @@ async function start() { // Topic helper tests console.log(Topic.path(['user', '123', 'notification']).toString()); console.log(Topic.path(['org', '42', 'user', '123']).path(['notification']).toString()); - console.log(Topic.path(['user']).any().path(['notification']).toString()); - console.log(Topic.path(['chat']).any().any().path(['message']).toString()); - console.log(Topic.path(['org']).any().path(['logs']).all().toString()); - console.log(Topic.any().path(['notification']).toString()); - console.log(Topic.all().toString()); for (const [name, levels] of [ ['empty path', []], ['empty level', ['user', '']], From 2e363f5f6b3ac3964e21ea17b5bf74cd6931b6ef Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:35 +0530 Subject: [PATCH 04/17] feat(react-native): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/react-native/src/index.ts.twig | 2 +- .../react-native/src/services/push.ts.twig | 21 +++++-------------- tests/e2e/languages/react-native/browser.js | 5 ----- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/templates/react-native/src/index.ts.twig b/templates/react-native/src/index.ts.twig index 4cde5f0c10..f68bef9404 100644 --- a/templates/react-native/src/index.ts.twig +++ b/templates/react-native/src/index.ts.twig @@ -24,7 +24,7 @@ export { Query } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; -export { Topic, ResolvedTopic } from './topic'; +export { Topic } from './topic'; export { Channel } from './channel'; export { Operator, Condition } from './operator'; {% for enum in allEnums %} diff --git a/templates/react-native/src/services/push.ts.twig b/templates/react-native/src/services/push.ts.twig index c3cbf27400..eb69fd8f91 100644 --- a/templates/react-native/src/services/push.ts.twig +++ b/templates/react-native/src/services/push.ts.twig @@ -22,7 +22,7 @@ import { import { Client } from '../client'; import { createTcpStream } from '../lib/tcp-stream'; import { Service } from '../service'; -import type { ResolvedTopic, Topic } from '../topic'; +import type { Topic } from '../topic'; /** A message delivered on a subscribed topic (the MQTT analog of a Realtime event). */ export interface PushMessage { @@ -483,7 +483,7 @@ export class Push extends Service { * Subscribe to one or more topics. Resolves to a {@link PushSubscription} handle. * * Each topic is a string or a `Topic` builder, e.g. - * `Topic.path(['user', userId]).all()`. + * `Topic.path(['user', userId])`. * * Called with only a callback, `subscribe((message) => {...}, options)` subscribes to the * signed-in user's own topic, `users/`, like FCM/APNs delivery to a device. The @@ -508,18 +508,12 @@ export class Push extends Service { options?: SubscribeOptions, ): Promise; subscribe( - topics: - string | Topic | ResolvedTopic | (string | Topic | ResolvedTopic)[], + topics: string | Topic | (string | Topic)[], callback: MessageCallback, options?: SubscribeOptions, ): Promise; async subscribe( - topicsOrCallback: - | string - | Topic - | ResolvedTopic - | (string | Topic | ResolvedTopic)[] - | MessageCallback, + topicsOrCallback: string | Topic | (string | Topic)[] | MessageCallback, callbackOrOptions?: MessageCallback | SubscribeOptions, topicOptions: SubscribeOptions = {}, ): Promise { @@ -536,12 +530,7 @@ export class Push extends Service { } private async subscribeWith( - topicsOrCallback: - | string - | Topic - | ResolvedTopic - | (string | Topic | ResolvedTopic)[] - | MessageCallback, + topicsOrCallback: string | Topic | (string | Topic)[] | MessageCallback, callbackOrOptions: MessageCallback | SubscribeOptions | undefined, topicOptions: SubscribeOptions, ): Promise { diff --git a/tests/e2e/languages/react-native/browser.js b/tests/e2e/languages/react-native/browser.js index b1d96c2a96..7396275fb1 100644 --- a/tests/e2e/languages/react-native/browser.js +++ b/tests/e2e/languages/react-native/browser.js @@ -411,11 +411,6 @@ import { // Topic helper tests console.log(Topic.path(['user', '123', 'notification']).toString()); console.log(Topic.path(['org', '42', 'user', '123']).path(['notification']).toString()); - console.log(Topic.path(['user']).any().path(['notification']).toString()); - console.log(Topic.path(['chat']).any().any().path(['message']).toString()); - console.log(Topic.path(['org']).any().path(['logs']).all().toString()); - console.log(Topic.any().path(['notification']).toString()); - console.log(Topic.all().toString()); for (const [name, levels] of [ ['empty path', []], ['empty level', ['user', '']], From d0cb9a7a041f929e3729ee6045cde4696c3ba86d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:48 +0530 Subject: [PATCH 05/17] feat(go): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/go/topic.go.twig | 51 +++--------------------------- templates/go/topic_test.go.twig | 16 +++------- tests/e2e/languages/go-v2/tests.go | 5 --- tests/e2e/languages/go/tests.go | 5 --- 4 files changed, 9 insertions(+), 68 deletions(-) diff --git a/templates/go/topic.go.twig b/templates/go/topic.go.twig index 842ee8032d..49f3600ce1 100644 --- a/templates/go/topic.go.twig +++ b/templates/go/topic.go.twig @@ -1,12 +1,7 @@ // Package topic builds MQTT topics for Push, level by level. // // topic.Path([]string{"user", userID, "notification"}).String() // "user/123/notification" -// topic.Path([]string{"user"}).Any().Path([]string{"notification"}).String() // "user/+/notification" -// topic.Path([]string{"org"}).Any().Path([]string{"logs"}).All().String() // "org/+/logs/#" -// -// Any matches exactly one level and All everything below, at any depth (including the -// parent itself). All ends the topic, so it returns a ResolvedTopic, which has no methods -// to append further levels. +// topic.Path([]string{"org", orgID}).Path([]string{"logs"}).String() // "org/42/logs" // // Path panics when given invalid levels (no levels, an empty level, or a level holding // "/", "+" or "#"). Invalid levels are a programmer error, like a bad pattern passed to @@ -18,57 +13,27 @@ import ( "strings" ) -// Topic is a topic being built. Create one with Path or Any. +// Topic is a topic being built. Create one with Path. type Topic struct { levels []string } -// ResolvedTopic is a topic ended by All: nothing can be appended after the "#" wildcard. -type ResolvedTopic struct { - levels []string -} - // Path starts a topic with the given levels, e.g. topic.Path([]string{"user", userID}). // It panics if the levels are invalid. func Path(levels []string) Topic { return Topic{levels: levelsOf(levels)} } -// Any starts a topic with a single-level wildcard ("+"). -func Any() Topic { - return Topic{levels: []string{"+"}} -} - -// All returns the multi-level wildcard alone ("#"): it matches every topic. -func All() ResolvedTopic { - return ResolvedTopic{levels: []string{"#"}} -} - // Path appends the given levels. It panics if the levels are invalid. func (t Topic) Path(levels []string) Topic { return Topic{levels: appendLevels(t.levels, levelsOf(levels)...)} } -// Any appends a single-level wildcard ("+"): it matches exactly one level. -func (t Topic) Any() Topic { - return Topic{levels: appendLevels(t.levels, "+")} -} - -// All appends the multi-level wildcard ("#") and ends the topic. -func (t Topic) All() ResolvedTopic { - return ResolvedTopic{levels: appendLevels(t.levels, "#")} -} - // String joins the levels with "/". func (t Topic) String() string { return strings.Join(t.levels, "/") } -// String joins the levels with "/". -func (t ResolvedTopic) String() string { - return strings.Join(t.levels, "/") -} - // appendLevels copies the levels so topics built from a shared prefix never alias. func appendLevels(levels []string, more ...string) []string { result := make([]string, 0, len(levels)+len(more)) @@ -77,7 +42,7 @@ func appendLevels(levels []string, more ...string) []string { } // levelsOf validates the levels passed to Path: a non-empty list of non-empty strings, none -// holding the level separator "/" or the wildcards "+" and "#" (use Any and All for those). +// holding the level separator "/" or the MQTT wildcards "+" and "#". func levelsOf(levels []string) []string { if len(levels) == 0 { panic("path() needs at least one level") @@ -93,17 +58,11 @@ func levelsOf(levels []string) []string { } panic(fmt.Sprintf("\"%s\" contains \"/\", split it: path([%s])", level, strings.Join(parts, ", "))) } - if level == "+" { - panic("\"+\" is reserved, use any()") - } if strings.Contains(level, "+") { - panic(fmt.Sprintf("\"%s\" contains \"+\", use any() for wildcards", level)) - } - if level == "#" { - panic("\"#\" is reserved, use all()") + panic(fmt.Sprintf("\"%s\" contains \"+\", not allowed in a topic", level)) } if strings.Contains(level, "#") { - panic(fmt.Sprintf("\"%s\" contains \"#\", use all() for wildcards", level)) + panic(fmt.Sprintf("\"%s\" contains \"#\", not allowed in a topic", level)) } } return append([]string(nil), levels...) diff --git a/templates/go/topic_test.go.twig b/templates/go/topic_test.go.twig index be50c4668c..c304905103 100644 --- a/templates/go/topic_test.go.twig +++ b/templates/go/topic_test.go.twig @@ -12,12 +12,7 @@ func TestPaths(t *testing.T) { }{ {Path([]string{"user", "123", "notification"}).String(), "user/123/notification"}, {Path([]string{"org", "42", "user", "123"}).Path([]string{"notification"}).String(), "org/42/user/123/notification"}, - {Path([]string{"user"}).Any().Path([]string{"notification"}).String(), "user/+/notification"}, - {Path([]string{"chat"}).Any().Any().Path([]string{"message"}).String(), "chat/+/+/message"}, - {Path([]string{"org"}).Any().Path([]string{"logs"}).All().String(), "org/+/logs/#"}, - {Any().Path([]string{"notification"}).String(), "+/notification"}, - {Any().String(), "+"}, - {All().String(), "#"}, + {Path([]string{"user"}).Path([]string{"notification"}).String(), "user/notification"}, } for _, c := range cases { if c.got != c.expected { @@ -27,11 +22,11 @@ func TestPaths(t *testing.T) { } func TestSharedPrefixDoesNotAlias(t *testing.T) { - base := Path([]string{"a", "b", "c"}).Any() + base := Path([]string{"a", "b", "c"}) first := base.Path([]string{"x"}) second := base.Path([]string{"y"}) - if first.String() != "a/b/c/+/x" || second.String() != "a/b/c/+/y" { - t.Errorf("Expected a/b/c/+/x and a/b/c/+/y, got %s and %s", first, second) + if first.String() != "a/b/c/x" || second.String() != "a/b/c/y" { + t.Errorf("Expected a/b/c/x and a/b/c/y, got %s and %s", first, second) } } @@ -50,9 +45,6 @@ func TestInvalidLevels(t *testing.T) { if !panics(func() { Path(levels) }) { t.Errorf("Expected Path(%q) to panic", levels) } - if !panics(func() { Any().Path(levels) }) { - t.Errorf("Expected Any().Path(%q) to panic", levels) - } } } diff --git a/tests/e2e/languages/go-v2/tests.go b/tests/e2e/languages/go-v2/tests.go index 953d77916a..0c401df5d3 100644 --- a/tests/e2e/languages/go-v2/tests.go +++ b/tests/e2e/languages/go-v2/tests.go @@ -427,11 +427,6 @@ func testIdHelpers() { func testTopicHelpers() { fmt.Println(topic.Path([]string{"user", "123", "notification"})) fmt.Println(topic.Path([]string{"org", "42", "user", "123"}).Path([]string{"notification"})) - fmt.Println(topic.Path([]string{"user"}).Any().Path([]string{"notification"})) - fmt.Println(topic.Path([]string{"chat"}).Any().Any().Path([]string{"message"})) - fmt.Println(topic.Path([]string{"org"}).Any().Path([]string{"logs"}).All()) - fmt.Println(topic.Any().Path([]string{"notification"})) - fmt.Println(topic.All()) cases := []struct { name string levels []string diff --git a/tests/e2e/languages/go/tests.go b/tests/e2e/languages/go/tests.go index 0e50bf4ef8..cc34e848d3 100644 --- a/tests/e2e/languages/go/tests.go +++ b/tests/e2e/languages/go/tests.go @@ -427,11 +427,6 @@ func testIdHelpers() { func testTopicHelpers() { fmt.Println(topic.Path([]string{"user", "123", "notification"})) fmt.Println(topic.Path([]string{"org", "42", "user", "123"}).Path([]string{"notification"})) - fmt.Println(topic.Path([]string{"user"}).Any().Path([]string{"notification"})) - fmt.Println(topic.Path([]string{"chat"}).Any().Any().Path([]string{"message"})) - fmt.Println(topic.Path([]string{"org"}).Any().Path([]string{"logs"}).All()) - fmt.Println(topic.Any().Path([]string{"notification"})) - fmt.Println(topic.All()) cases := []struct { name string levels []string From 9505823208401e1e9c68e30a741b3744c063e901 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:48 +0530 Subject: [PATCH 06/17] feat(rust): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/rust/src/topic.rs.twig | 109 ++----------------------------- templates/rust/tests/tests.rs | 5 -- 2 files changed, 5 insertions(+), 109 deletions(-) diff --git a/templates/rust/src/topic.rs.twig b/templates/rust/src/topic.rs.twig index 13e8935ed2..474a311db3 100644 --- a/templates/rust/src/topic.rs.twig +++ b/templates/rust/src/topic.rs.twig @@ -5,15 +5,10 @@ //! //! # fn main() -> Result<(), topic::TopicError> { //! assert_eq!(topic::path(&["user", "123", "notification"])?.to_string(), "user/123/notification"); -//! assert_eq!(topic::path(&["user"])?.any().path(&["notification"])?.to_string(), "user/+/notification"); -//! assert_eq!(topic::path(&["org"])?.any().path(&["logs"])?.all().to_string(), "org/+/logs/#"); +//! assert_eq!(topic::path(&["org", "42"])?.path(&["logs"])?.to_string(), "org/42/logs"); //! # Ok(()) //! # } //! ``` -//! -//! `any()` matches exactly one level and `all()` everything below, at any depth (including the -//! parent itself). `all()` ends the topic, so it returns a [`ResolvedTopic`], which has no methods -//! to append further levels. use std::fmt; @@ -44,18 +39,12 @@ impl fmt::Display for TopicError { impl std::error::Error for TopicError {} -/// A topic being built. Start one with [`path`] or [`any`]. +/// A topic being built. Start one with [`path`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Topic { levels: Vec, } -/// A topic ended by `all()`: nothing can be appended after the `#` wildcard. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedTopic { - levels: Vec, -} - /// Start a topic with the given levels, e.g. `topic::path(&["user", user_id])`. pub fn path>(levels: &[S]) -> Result { Ok(Topic { @@ -63,40 +52,12 @@ pub fn path>(levels: &[S]) -> Result { }) } -/// Start a topic with a single-level wildcard (`+`). -pub fn any() -> Topic { - Topic { - levels: vec!["+".to_string()], - } -} - -/// The multi-level wildcard alone (`#`): matches every topic. -pub fn all() -> ResolvedTopic { - ResolvedTopic { - levels: vec!["#".to_string()], - } -} - impl Topic { /// Append the given levels. pub fn path>(mut self, levels: &[S]) -> Result { self.levels.extend(levels_of(levels)?); Ok(self) } - - /// Append a single-level wildcard (`+`): matches exactly one level. - pub fn any(mut self) -> Topic { - self.levels.push("+".to_string()); - self - } - - /// Append the multi-level wildcard (`#`) and end the topic. - pub fn all(mut self) -> ResolvedTopic { - self.levels.push("#".to_string()); - ResolvedTopic { - levels: self.levels, - } - } } impl fmt::Display for Topic { @@ -105,14 +66,8 @@ impl fmt::Display for Topic { } } -impl fmt::Display for ResolvedTopic { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.levels.join("/")) - } -} - /// Validate the levels passed to `path()`: a non-empty list of non-empty strings, none holding -/// the level separator `/` or the wildcards `+` and `#` (use `any()` and `all()` for those). +/// the level separator `/` or the MQTT wildcards `+` and `#`. fn levels_of>(levels: &[S]) -> Result, TopicError> { if levels.is_empty() { return Err(TopicError::new("path() needs at least one level")); @@ -136,21 +91,15 @@ fn levels_of>(levels: &[S]) -> Result, TopicError> { level, parts ))); } - if level == "+" { - return Err(TopicError::new("\"+\" is reserved, use any()")); - } if level.contains('+') { return Err(TopicError::new(format!( - "\"{}\" contains \"+\", use any() for wildcards", + "\"{}\" contains \"+\", not allowed in a topic", level ))); } - if level == "#" { - return Err(TopicError::new("\"#\" is reserved, use all()")); - } if level.contains('#') { return Err(TopicError::new(format!( - "\"{}\" contains \"#\", use all() for wildcards", + "\"{}\" contains \"#\", not allowed in a topic", level ))); } @@ -185,53 +134,6 @@ mod tests { ); } - #[test] - fn test_any() { - assert_eq!( - path(&["user"]) - .unwrap() - .any() - .path(&["notification"]) - .unwrap() - .to_string(), - "user/+/notification" - ); - assert_eq!( - path(&["chat"]) - .unwrap() - .any() - .any() - .path(&["message"]) - .unwrap() - .to_string(), - "chat/+/+/message" - ); - } - - #[test] - fn test_all() { - assert_eq!( - path(&["org"]) - .unwrap() - .any() - .path(&["logs"]) - .unwrap() - .all() - .to_string(), - "org/+/logs/#" - ); - } - - #[test] - fn test_static_any_and_all() { - assert_eq!( - any().path(&["notification"]).unwrap().to_string(), - "+/notification" - ); - assert_eq!(any().to_string(), "+"); - assert_eq!(all().to_string(), "#"); - } - #[test] fn test_errors() { let invalid: [&[&str]; 7] = [ @@ -245,7 +147,6 @@ mod tests { ]; for levels in invalid { assert!(path(levels).is_err(), "{:?}", levels); - assert!(any().path(levels).is_err(), "{:?}", levels); } } } diff --git a/templates/rust/tests/tests.rs b/templates/rust/tests/tests.rs index d47a3bf21f..6f1f49c6f8 100644 --- a/templates/rust/tests/tests.rs +++ b/templates/rust/tests/tests.rs @@ -357,11 +357,6 @@ fn test_id_helpers() { fn test_topic_helpers() { println!("{}", topic::path(&["user", "123", "notification"]).unwrap()); println!("{}", topic::path(&["org", "42", "user", "123"]).unwrap().path(&["notification"]).unwrap()); - println!("{}", topic::path(&["user"]).unwrap().any().path(&["notification"]).unwrap()); - println!("{}", topic::path(&["chat"]).unwrap().any().any().path(&["message"]).unwrap()); - println!("{}", topic::path(&["org"]).unwrap().any().path(&["logs"]).unwrap().all()); - println!("{}", topic::any().path(&["notification"]).unwrap()); - println!("{}", topic::all()); let cases: Vec<(&str, Vec<&str>)> = vec![ ("empty path", vec![]), ("empty level", vec!["user", ""]), From 2d8accf32564c0b9bc53e14e11e85120a33cdf9b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:48 +0530 Subject: [PATCH 07/17] feat(python): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/python/package/topic.py.twig | 51 +++--------------------- templates/python/test/test_topic.py.twig | 19 ++------- tests/e2e/languages/python/tests.py | 5 --- 3 files changed, 8 insertions(+), 67 deletions(-) diff --git a/templates/python/package/topic.py.twig b/templates/python/package/topic.py.twig index b8b7c6386f..17aa2b1650 100644 --- a/templates/python/package/topic.py.twig +++ b/templates/python/package/topic.py.twig @@ -4,7 +4,7 @@ from types import MethodType def _levels_of(levels): """ Validate the levels passed to `path()`: a non-empty list of non-empty strings, none holding - the level separator `/` or the wildcards `+` and `#` (use `any()` and `all()` for those). + the level separator `/` or the MQTT wildcards `+` and `#`. """ if not isinstance(levels, (list, tuple)): raise TypeError("path() expects an array, e.g. path(['user'])") @@ -19,14 +19,10 @@ def _levels_of(levels): if "/" in level: parts = ", ".join(f"'{part}'" for part in level.split("/")) raise ValueError(f'"{level}" contains "/", split it: path([{parts}])') - if level == "+": - raise ValueError('"+" is reserved, use any()') if "+" in level: - raise ValueError(f'"{level}" contains "+", use any() for wildcards') - if level == "#": - raise ValueError('"#" is reserved, use all()') + raise ValueError(f'"{level}" contains "+", not allowed in a topic') if "#" in level: - raise ValueError(f'"{level}" contains "#", use all() for wildcards') + raise ValueError(f'"{level}" contains "#", not allowed in a topic') return tuple(levels) @@ -50,15 +46,11 @@ class Topic: Helper to build MQTT topics for `Push`, level by level. Topic.path(['user', user_id, 'notification']) # "user/123/notification" - Topic.path(['user']).any().path(['notification']) # "user/+/notification" - Topic.path(['org']).any().path(['logs']).all() # "org/+/logs/#" - - `any()` matches exactly one level and `all()` everything below, at any depth (including the - parent itself). `all()` ends the topic, so it returns a `ResolvedTopic`. + Topic.path(['org', org_id]).path(['logs']) # "org/42/logs" """ def __init__(self, levels): - # Internal: build topics with `Topic.path()`, `Topic.any()` and `Topic.all()`. + # Internal: build topics with `Topic.path()`. self._levels = tuple(levels) @_builder @@ -66,41 +58,8 @@ class Topic: """Start a topic with the given levels, or append them, e.g. `Topic.path(['user', user_id])`.""" return Topic(self._levels + _levels_of(levels)) - @_builder - def any(self): - """Start a topic with, or append, a single-level wildcard (`+`): matches exactly one level.""" - return Topic(self._levels + ("+",)) - - @_builder - def all(self): - """The multi-level wildcard (`#`) alone, or appended to end the topic.""" - return ResolvedTopic(self._levels + ("#",)) - def __str__(self): return "/".join(self._levels) def __repr__(self): return f"Topic({str(self)!r})" - - -class ResolvedTopic: - """A topic ended by `all()`: nothing can be appended after the `#` wildcard.""" - - def __init__(self, levels): - # Internal: created by `Topic.all()`. - self._levels = tuple(levels) - - def path(self, levels): - raise TypeError("nothing can come after all()") - - def any(self): - raise TypeError("nothing can come after all()") - - def all(self): - raise TypeError("nothing can come after all()") - - def __str__(self): - return "/".join(self._levels) - - def __repr__(self): - return f"ResolvedTopic({str(self)!r})" diff --git a/templates/python/test/test_topic.py.twig b/templates/python/test/test_topic.py.twig index 6242274afd..e580d7133b 100644 --- a/templates/python/test/test_topic.py.twig +++ b/templates/python/test/test_topic.py.twig @@ -1,6 +1,6 @@ import unittest -from {{ namespace | caseSnake }}.topic import ResolvedTopic, Topic +from {{ namespace | caseSnake }}.topic import Topic class TestTopicMethods(unittest.TestCase): @@ -9,20 +9,7 @@ class TestTopicMethods(unittest.TestCase): self.assertEqual(str(Topic.path(['user'])), 'user') self.assertEqual(str(Topic.path(['user', 'notification'])), 'user/notification') self.assertEqual(str(Topic.path(['user']).path(['notification'])), 'user/notification') - - def test_any(self): - self.assertEqual(str(Topic.path(['user']).any().path(['notification'])), 'user/+/notification') - self.assertEqual(str(Topic.path(['chat']).any().any().path(['message'])), 'chat/+/+/message') - self.assertEqual(str(Topic.any().path(['notification'])), '+/notification') - - def test_all(self): - self.assertEqual(str(Topic.path(['org']).any().path(['logs']).all()), 'org/+/logs/#') - self.assertEqual(str(Topic.all()), '#') - self.assertIsInstance(Topic.all(), ResolvedTopic) - - def test_nothing_after_all(self): - with self.assertRaises(TypeError): - Topic.all().any() + self.assertEqual(str(Topic.path(['org', '42']).path(['logs'])), 'org/42/logs') def test_errors(self): cases = [ @@ -42,4 +29,4 @@ class TestTopicMethods(unittest.TestCase): with self.assertRaises(error): Topic.path(levels) with self.assertRaises(error): - Topic.any().path(levels) + Topic.path(['user']).path(levels) diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index 604e7bdedd..32d0508687 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -271,11 +271,6 @@ # Topic helper tests print(Topic.path(['user', '123', 'notification'])) print(Topic.path(['org', '42', 'user', '123']).path(['notification'])) -print(Topic.path(['user']).any().path(['notification'])) -print(Topic.path(['chat']).any().any().path(['message'])) -print(Topic.path(['org']).any().path(['logs']).all()) -print(Topic.any().path(['notification'])) -print(Topic.all()) for name, levels in [ ('empty path', []), ('empty level', ['user', '']), From cff9f0c6e27aff71303d4b5af0771c0afed97efa Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:48 +0530 Subject: [PATCH 08/17] feat(ruby): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/ruby/lib/container/topic.rb.twig | 58 ++-------------------- tests/e2e/languages/ruby/tests.rb | 5 -- 2 files changed, 4 insertions(+), 59 deletions(-) diff --git a/templates/ruby/lib/container/topic.rb.twig b/templates/ruby/lib/container/topic.rb.twig index 306cb9fd28..2ec63c7c2d 100644 --- a/templates/ruby/lib/container/topic.rb.twig +++ b/templates/ruby/lib/container/topic.rb.twig @@ -2,11 +2,7 @@ module {{spec.info.title | caseUcfirst }} # Helper to build MQTT topics for `Push`, level by level. # # Topic.path(['user', user_id, 'notification']) # "user/123/notification" - # Topic.path(['user']).any.path(['notification']) # "user/+/notification" - # Topic.path(['org']).any.path(['logs']).all # "org/+/logs/#" - # - # `any` matches exactly one level and `all` everything below, at any depth (including the - # parent itself). `all` ends the topic, so it returns a ResolvedTopic. + # Topic.path(['org', org_id]).path(['logs']) # "org/42/logs" class Topic private_class_method :new @@ -15,16 +11,6 @@ module {{spec.info.title | caseUcfirst }} new([]).path(levels) end - # Start a topic with a single-level wildcard (`+`). - def self.any - new(['+']) - end - - # The multi-level wildcard alone (`#`): matches every topic. - def self.all - ResolvedTopic.new(['#']) - end - def initialize(levels) @levels = levels.freeze end @@ -34,22 +20,12 @@ module {{spec.info.title | caseUcfirst }} self.class.send(:new, @levels + self.class.send(:levels_of, levels)) end - # Append a single-level wildcard (`+`): matches exactly one level. - def any - self.class.send(:new, @levels + ['+']) - end - - # Append the multi-level wildcard (`#`) and end the topic. - def all - ResolvedTopic.new(@levels + ['#']) - end - def to_s @levels.join('/') end # Validate the levels passed to `path`: a non-empty array of non-empty strings, none holding - # the level separator `/` or the wildcards `+` and `#` (use `any` and `all` for those). + # the level separator `/` or the MQTT wildcards `+` and `#`. def self.levels_of(levels) raise ArgumentError, "path() expects an array, e.g. path(['user'])" unless levels.is_a?(Array) raise ArgumentError, 'path() needs at least one level' if levels.empty? @@ -65,38 +41,12 @@ module {{spec.info.title | caseUcfirst }} parts = level.split('/', -1).map { |part| "'#{part}'" }.join(', ') raise ArgumentError, "\"#{level}\" contains \"/\", split it: path([#{parts}])" end - raise ArgumentError, '"+" is reserved, use any()' if level == '+' - raise ArgumentError, "\"#{level}\" contains \"+\", use any() for wildcards" if level.include?('+') - raise ArgumentError, '"#" is reserved, use all()' if level == '#' - raise ArgumentError, "\"#{level}\" contains \"#\", use all() for wildcards" if level.include?('#') + raise ArgumentError, "\"#{level}\" contains \"+\", not allowed in a topic" if level.include?('+') + raise ArgumentError, "\"#{level}\" contains \"#\", not allowed in a topic" if level.include?('#') end levels.dup end private_class_method :levels_of end - - # A topic ended by `all`: nothing can be appended after the `#` wildcard. - class ResolvedTopic - # Created by Topic.all. - def initialize(levels) - @levels = levels.freeze - end - - def path(_levels) - raise NoMethodError, 'nothing can come after all()' - end - - def any - raise NoMethodError, 'nothing can come after all()' - end - - def all - raise NoMethodError, 'nothing can come after all()' - end - - def to_s - @levels.join('/') - end - end end diff --git a/tests/e2e/languages/ruby/tests.rb b/tests/e2e/languages/ruby/tests.rb index 94e3e7605f..93e9544af7 100644 --- a/tests/e2e/languages/ruby/tests.rb +++ b/tests/e2e/languages/ruby/tests.rb @@ -284,11 +284,6 @@ # Topic helper tests puts Topic.path(['user', '123', 'notification']) puts Topic.path(['org', '42', 'user', '123']).path(['notification']) -puts Topic.path(['user']).any.path(['notification']) -puts Topic.path(['chat']).any.any.path(['message']) -puts Topic.path(['org']).any.path(['logs']).all -puts Topic.any.path(['notification']) -puts Topic.all [ ['empty path', []], ['empty level', ['user', '']], From f1f4dc07b3972733bdcbea494eab7d5e7a88f48c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:56 +0530 Subject: [PATCH 09/17] feat(dart): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/dart/lib/topic.dart.twig | 45 ++++-------------------- templates/dart/test/topic_test.dart.twig | 32 ++--------------- tests/e2e/languages/dart/tests.dart | 5 --- 3 files changed, 9 insertions(+), 73 deletions(-) diff --git a/templates/dart/lib/topic.dart.twig b/templates/dart/lib/topic.dart.twig index 4563d0848a..00f43277a5 100644 --- a/templates/dart/lib/topic.dart.twig +++ b/templates/dart/lib/topic.dart.twig @@ -4,13 +4,8 @@ part of '{{ language.params.packageName }}.dart'; /// /// ```dart /// Topic.path(['user', userId, 'notification']); // "user/123/notification" -/// Topic.path(['user']).any().path(['notification']); // "user/+/notification" -/// Topic.path(['org']).any().path(['logs']).all(); // "org/+/logs/#" +/// Topic.path(['org', orgId]).path(['logs']); // "org/42/logs" /// ``` -/// -/// `any()` matches exactly one level and `all()` everything below, at any depth -/// (including the parent itself). `all()` ends the topic, so it returns a -/// [ResolvedTopic]. class Topic { final List _levels; @@ -19,42 +14,20 @@ class Topic { /// Start a topic with the given levels, e.g. `Topic.path(['user', userId])`. static Topic path(List levels) => Topic._(_topicLevelsOf(levels)); - /// Start a topic with a single-level wildcard (`+`). - static Topic any() => Topic._(const ['+']); - - /// The multi-level wildcard alone (`#`): matches every topic. - static ResolvedTopic all() => ResolvedTopic._(const ['#']); - @override String toString() => _levels.join('/'); } -/// Appends levels to a [Topic]: `Topic.path(['user']).any().path(['notification'])`. +/// Appends levels to a [Topic]: `Topic.path(['user']).path(['notification'])`. extension TopicLevels on Topic { /// Append the given levels. Topic path(List levels) => Topic._([..._levels, ..._topicLevelsOf(levels)]); - - /// Append a single-level wildcard (`+`): matches exactly one level. - Topic any() => Topic._([..._levels, '+']); - - /// Append the multi-level wildcard (`#`) and end the topic. - ResolvedTopic all() => ResolvedTopic._([..._levels, '#']); -} - -/// A topic ended by `all()`: nothing can be appended after the `#` wildcard. -class ResolvedTopic { - final List _levels; - - ResolvedTopic._(this._levels); - - @override - String toString() => _levels.join('/'); } /// Validate the levels passed to `path()`: a non-empty list of non-empty -/// strings, none holding the level separator `/` or the wildcards `+` and `#` -/// (use `any()` and `all()` for those). +/// strings, none holding the level separator `/` or the MQTT wildcards `+` +/// and `#`. List _topicLevelsOf(List levels) { if (levels.isEmpty) { throw ArgumentError('path() needs at least one level'); @@ -68,17 +41,11 @@ List _topicLevelsOf(List levels) { final parts = level.split('/').map((part) => "'$part'").join(', '); throw ArgumentError('"$level" contains "/", split it: path([$parts])'); } - if (level == '+') { - throw ArgumentError('"+" is reserved, use any()'); - } if (level.contains('+')) { - throw ArgumentError('"$level" contains "+", use any() for wildcards'); - } - if (level == '#') { - throw ArgumentError('"#" is reserved, use all()'); + throw ArgumentError('"$level" contains "+", not allowed in a topic'); } if (level.contains('#')) { - throw ArgumentError('"$level" contains "#", use all() for wildcards'); + throw ArgumentError('"$level" contains "#", not allowed in a topic'); } } return List.unmodifiable(levels); diff --git a/templates/dart/test/topic_test.dart.twig b/templates/dart/test/topic_test.dart.twig index a132b36bb1..fa6659e1da 100644 --- a/templates/dart/test/topic_test.dart.twig +++ b/templates/dart/test/topic_test.dart.twig @@ -20,37 +20,11 @@ void main() { Topic.path(['user']).path(['notification']).toString(), 'user/notification', ); - }); - }); - - group('any()', () { - test('appends a single-level wildcard', () { - expect( - Topic.path(['user']).any().path(['notification']).toString(), - 'user/+/notification', - ); - expect( - Topic.path(['chat']).any().any().path(['message']).toString(), - 'chat/+/+/message', - ); - }); - - test('starts a topic', () { - expect(Topic.any().path(['notification']).toString(), '+/notification'); - }); - }); - - group('all()', () { - test('ends a topic', () { expect( - Topic.path(['org']).any().path(['logs']).all().toString(), - 'org/+/logs/#', + Topic.path(['org', '42']).path(['logs']).toString(), + 'org/42/logs', ); }); - - test('matches every topic', () { - expect(Topic.all().toString(), '#'); - }); }); group('errors', () { @@ -67,7 +41,7 @@ void main() { invalid.forEach((name, levels) { test('rejects $name', () { expect(() => Topic.path(levels), throwsArgumentError); - expect(() => Topic.any().path(levels), throwsArgumentError); + expect(() => Topic.path(['user']).path(levels), throwsArgumentError); }); }); }); diff --git a/tests/e2e/languages/dart/tests.dart b/tests/e2e/languages/dart/tests.dart index 5d4d2941e0..f26bcee141 100644 --- a/tests/e2e/languages/dart/tests.dart +++ b/tests/e2e/languages/dart/tests.dart @@ -299,11 +299,6 @@ void main() async { // Topic helper tests print(Topic.path(['user', '123', 'notification']).toString()); print(Topic.path(['org', '42', 'user', '123']).path(['notification']).toString()); - print(Topic.path(['user']).any().path(['notification']).toString()); - print(Topic.path(['chat']).any().any().path(['message']).toString()); - print(Topic.path(['org']).any().path(['logs']).all().toString()); - print(Topic.any().path(['notification']).toString()); - print(Topic.all().toString()); final topicCases = >{ 'empty path': [], 'empty level': ['user', ''], From a5ebf4ff176325cf5419be07bb75c1df61a14573 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:56 +0530 Subject: [PATCH 10/17] feat(flutter): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/flutter/lib/src/mqtt.dart.twig | 4 ++-- templates/flutter/lib/src/mqtt_browser.dart.twig | 4 ++-- templates/flutter/lib/src/mqtt_io.dart.twig | 4 ++-- tests/e2e/languages/flutter/tests.dart | 5 ----- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/templates/flutter/lib/src/mqtt.dart.twig b/templates/flutter/lib/src/mqtt.dart.twig index 2f90323bbb..bc5f64523c 100644 --- a/templates/flutter/lib/src/mqtt.dart.twig +++ b/templates/flutter/lib/src/mqtt.dart.twig @@ -144,7 +144,7 @@ abstract class Push extends Service { /// Subscribe to one or more topics, resolving to a [PushSubscription] handle. /// /// [topics] is a single topic or a list of them, each a string or a `Topic` builder - /// (e.g. `Topic.path(['user', userId]).all()`). Pass `null` to subscribe to the + /// (e.g. `Topic.path(['user', userId])`). Pass `null` to subscribe to the /// signed-in user's own `users/` topic ("messages for me") without knowing any /// topic: the user id is read off the client's JWT (or, without one, its session) with /// no network call, and `{{spec.info.title | caseUcfirst}}Exception` is thrown when neither is set. @@ -185,7 +185,7 @@ abstract class Push extends Service { /// background isolate via `flutter_background_service`; on the web it posts a browser /// notification while the tab is open. Future subscribe( - Object? topics, // String, Topic, ResolvedTopic, a List, or null (own user) + Object? topics, // String, Topic, a List, or null (own user) PushCallback callback, { bool? background, String? title, diff --git a/templates/flutter/lib/src/mqtt_browser.dart.twig b/templates/flutter/lib/src/mqtt_browser.dart.twig index 1af9881df5..541d76184b 100644 --- a/templates/flutter/lib/src/mqtt_browser.dart.twig +++ b/templates/flutter/lib/src/mqtt_browser.dart.twig @@ -907,13 +907,13 @@ bool _matches(String filter, String topic) { return filterParts.length == topicParts.length; } -/// Resolve [Push.subscribe]'s `topics`: a String, Topic or ResolvedTopic, or a List of +/// Resolve [Push.subscribe]'s `topics`: a String or Topic, or a List of /// them, or null for the signed-in user's own `users/` topic. List _topicList(Client client, Object? topics) { if (topics == null) { return [_userTopic(client)]; } - // A topic is a String or a Topic / ResolvedTopic builder (via toString()). + // A topic is a String or a Topic builder (via toString()). return topics is List ? topics.map((topic) => topic.toString()).toList() : [topics.toString()]; diff --git a/templates/flutter/lib/src/mqtt_io.dart.twig b/templates/flutter/lib/src/mqtt_io.dart.twig index d945368b3f..027da9bfa8 100644 --- a/templates/flutter/lib/src/mqtt_io.dart.twig +++ b/templates/flutter/lib/src/mqtt_io.dart.twig @@ -1454,13 +1454,13 @@ bool _matches(String filter, String topic) { return filterParts.length == topicParts.length; } -/// Resolve [Push.subscribe]'s `topics`: a String, Topic or ResolvedTopic, or a List of +/// Resolve [Push.subscribe]'s `topics`: a String or Topic, or a List of /// them, or null for the signed-in user's own `users/` topic. List _topicList(Client client, Object? topics, String? cookieSession) { if (topics == null) { return [_userTopic(client, cookieSession)]; } - // A topic is a String or a Topic / ResolvedTopic builder (via toString()). + // A topic is a String or a Topic builder (via toString()). return topics is List ? topics.map((topic) => topic.toString()).toList() : [topics.toString()]; diff --git a/tests/e2e/languages/flutter/tests.dart b/tests/e2e/languages/flutter/tests.dart index 33f2b47974..b39a3ca28d 100644 --- a/tests/e2e/languages/flutter/tests.dart +++ b/tests/e2e/languages/flutter/tests.dart @@ -447,11 +447,6 @@ void main() async { // Topic helper tests print(Topic.path(['user', '123', 'notification']).toString()); print(Topic.path(['org', '42', 'user', '123']).path(['notification']).toString()); - print(Topic.path(['user']).any().path(['notification']).toString()); - print(Topic.path(['chat']).any().any().path(['message']).toString()); - print(Topic.path(['org']).any().path(['logs']).all().toString()); - print(Topic.any().path(['notification']).toString()); - print(Topic.all().toString()); final topicCases = >{ 'empty path': [], 'empty level': ['user', ''], From ea8ccba319a9e21ae74d76b2642d4f1e5d4c4eb0 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:56 +0530 Subject: [PATCH 11/17] feat(kotlin): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- .../src/main/kotlin/io/appwrite/Topic.kt.twig | 33 +++---------------- .../test/kotlin/io/appwrite/TopicTest.kt.twig | 19 +---------- tests/e2e/languages/kotlin/Tests.kt | 5 --- 3 files changed, 5 insertions(+), 52 deletions(-) diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/Topic.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/Topic.kt.twig index d342ae9eb9..6d5bff4435 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/Topic.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/Topic.kt.twig @@ -2,7 +2,7 @@ package {{ sdk.namespace | caseDot }} /** * Validate the levels passed to `path()`: a non-empty list of non-empty strings, none holding - * the level separator `/` or the wildcards `+` and `#` (use `any()` and `all()` for those). + * the level separator `/` or the MQTT wildcards `+` and `#`. */ private fun levelsOf(levels: List): List { require(levels.isNotEmpty()) { "path() needs at least one level" } @@ -12,10 +12,8 @@ private fun levelsOf(levels: List): List { val parts = level.split("/").joinToString(", ") { "'$it'" } "\"$level\" contains \"/\", split it: path([$parts])" } - require(level != "+") { "\"+\" is reserved, use any()" } - require(!level.contains("+")) { "\"$level\" contains \"+\", use any() for wildcards" } - require(level != "#") { "\"#\" is reserved, use all()" } - require(!level.contains("#")) { "\"$level\" contains \"#\", use all() for wildcards" } + require(!level.contains("+")) { "\"$level\" contains \"+\", not allowed in a topic" } + require(!level.contains("#")) { "\"$level\" contains \"#\", not allowed in a topic" } } return levels.toList() } @@ -25,13 +23,9 @@ private fun levelsOf(levels: List): List { * * ``` * Topic.path(listOf("user", userId, "notification")) // "user/123/notification" - * Topic.path(listOf("user")).any().path(listOf("notification")) // "user/+/notification" - * Topic.path(listOf("org")).any().path(listOf("logs")).all() // "org/+/logs/#" + * Topic.path(listOf("org", orgId)).path(listOf("logs")) // "org/42/logs" * ``` * - * `any()` matches exactly one level and `all()` everything below, at any depth (including the - * parent itself). `all()` ends the topic, so it returns a [ResolvedTopic]. - * * @throws IllegalArgumentException from `path()` when a level is empty or holds `/`, `+` or `#`. */ class Topic private constructor( @@ -40,29 +34,10 @@ class Topic private constructor( /** Append the given levels. */ fun path(levels: List): Topic = Topic(this.levels + levelsOf(levels)) - /** Append a single-level wildcard (`+`): matches exactly one level. */ - fun any(): Topic = Topic(levels + "+") - - /** Append the multi-level wildcard (`#`) and end the topic. */ - fun all(): ResolvedTopic = ResolvedTopic(levels + "#") - override fun toString(): String = levels.joinToString("/") companion object { /** Start a topic with the given levels, e.g. `Topic.path(listOf("user", userId))`. */ fun path(levels: List): Topic = Topic(levelsOf(levels)) - - /** Start a topic with a single-level wildcard (`+`). */ - fun any(): Topic = Topic(listOf("+")) - - /** The multi-level wildcard alone (`#`): matches every topic. */ - fun all(): ResolvedTopic = ResolvedTopic(listOf("#")) } } - -/** A topic ended by `all()`: nothing can be appended after the `#` wildcard. */ -class ResolvedTopic internal constructor( - private val levels: List, -) { - override fun toString(): String = levels.joinToString("/") -} diff --git a/templates/kotlin/src/test/kotlin/io/appwrite/TopicTest.kt.twig b/templates/kotlin/src/test/kotlin/io/appwrite/TopicTest.kt.twig index 1da55c13de..fd2b480f2f 100644 --- a/templates/kotlin/src/test/kotlin/io/appwrite/TopicTest.kt.twig +++ b/templates/kotlin/src/test/kotlin/io/appwrite/TopicTest.kt.twig @@ -7,7 +7,7 @@ import kotlin.test.assertFailsWith class TopicTest { private fun assertPathFails(levels: List) { assertFailsWith { Topic.path(levels) } - assertFailsWith { Topic.any().path(levels) } + assertFailsWith { Topic.path(listOf("user")).path(levels) } } @Test @@ -23,23 +23,6 @@ class TopicTest { ) } - @Test - fun anyAppendsSingleLevelWildcard() { - assertEquals("user/+/notification", Topic.path(listOf("user")).any().path(listOf("notification")).toString()) - assertEquals("chat/+/+/message", Topic.path(listOf("chat")).any().any().path(listOf("message")).toString()) - } - - @Test - fun allEndsTopic() { - assertEquals("org/+/logs/#", Topic.path(listOf("org")).any().path(listOf("logs")).all().toString()) - } - - @Test - fun staticAnyAndAll() { - assertEquals("+/notification", Topic.any().path(listOf("notification")).toString()) - assertEquals("#", Topic.all().toString()) - } - @Test fun pathRejectsInvalidLevels() { assertPathFails(emptyList()) diff --git a/tests/e2e/languages/kotlin/Tests.kt b/tests/e2e/languages/kotlin/Tests.kt index 29afe0e462..fcb9c4e351 100644 --- a/tests/e2e/languages/kotlin/Tests.kt +++ b/tests/e2e/languages/kotlin/Tests.kt @@ -323,11 +323,6 @@ class ServiceTest { // Topic helper tests writeToFile(Topic.path(listOf("user", "123", "notification")).toString()) writeToFile(Topic.path(listOf("org", "42", "user", "123")).path(listOf("notification")).toString()) - writeToFile(Topic.path(listOf("user")).any().path(listOf("notification")).toString()) - writeToFile(Topic.path(listOf("chat")).any().any().path(listOf("message")).toString()) - writeToFile(Topic.path(listOf("org")).any().path(listOf("logs")).all().toString()) - writeToFile(Topic.any().path(listOf("notification")).toString()) - writeToFile(Topic.all().toString()) val topicErrorCases = listOf( "empty path" to emptyList(), "empty level" to listOf("user", ""), From f4e422d7ccfbceb5376e0bead357c66c716739b2 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:14:56 +0530 Subject: [PATCH 12/17] feat(android): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- .../src/main/java/io/package/Topic.kt.twig | 33 +++-------------- .../java/io/package/services/Push.kt.twig | 35 +++++-------------- .../test/java/io/package/TopicTest.kt.twig | 19 +--------- tests/e2e/languages/android/Tests.kt | 5 --- 4 files changed, 14 insertions(+), 78 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/Topic.kt.twig b/templates/android/library/src/main/java/io/package/Topic.kt.twig index 81f3fcc592..bb5711802c 100644 --- a/templates/android/library/src/main/java/io/package/Topic.kt.twig +++ b/templates/android/library/src/main/java/io/package/Topic.kt.twig @@ -2,7 +2,7 @@ package {{ sdk.namespace | caseDot }} /** * Validate the levels passed to `path()`: a non-empty list of non-empty strings, none holding - * the level separator `/` or the wildcards `+` and `#` (use `any()` and `all()` for those). + * the level separator `/` or the MQTT wildcards `+` and `#`. */ private fun levelsOf(levels: List): List { require(levels.isNotEmpty()) { "path() needs at least one level" } @@ -12,10 +12,8 @@ private fun levelsOf(levels: List): List { val parts = level.split("/").joinToString(", ") { "'$it'" } "\"$level\" contains \"/\", split it: path([$parts])" } - require(level != "+") { "\"+\" is reserved, use any()" } - require(!level.contains("+")) { "\"$level\" contains \"+\", use any() for wildcards" } - require(level != "#") { "\"#\" is reserved, use all()" } - require(!level.contains("#")) { "\"$level\" contains \"#\", use all() for wildcards" } + require(!level.contains("+")) { "\"$level\" contains \"+\", not allowed in a topic" } + require(!level.contains("#")) { "\"$level\" contains \"#\", not allowed in a topic" } } return levels.toList() } @@ -25,13 +23,9 @@ private fun levelsOf(levels: List): List { * * ``` * Topic.path(listOf("user", userId, "notification")) // "user/123/notification" - * Topic.path(listOf("user")).any().path(listOf("notification")) // "user/+/notification" - * Topic.path(listOf("org")).any().path(listOf("logs")).all() // "org/+/logs/#" + * Topic.path(listOf("org", orgId)).path(listOf("logs")) // "org/42/logs" * ``` * - * `any()` matches exactly one level and `all()` everything below, at any depth (including the - * parent itself). `all()` ends the topic, so it returns a [ResolvedTopic]. - * * @throws IllegalArgumentException from `path()` when a level is empty or holds `/`, `+` or `#`. */ class Topic private constructor( @@ -40,29 +34,10 @@ class Topic private constructor( /** Append the given levels. */ fun path(levels: List): Topic = Topic(this.levels + levelsOf(levels)) - /** Append a single-level wildcard (`+`): matches exactly one level. */ - fun any(): Topic = Topic(levels + "+") - - /** Append the multi-level wildcard (`#`) and end the topic. */ - fun all(): ResolvedTopic = ResolvedTopic(levels + "#") - override fun toString(): String = levels.joinToString("/") companion object { /** Start a topic with the given levels, e.g. `Topic.path(listOf("user", userId))`. */ fun path(levels: List): Topic = Topic(levelsOf(levels)) - - /** Start a topic with a single-level wildcard (`+`). */ - fun any(): Topic = Topic(listOf("+")) - - /** The multi-level wildcard alone (`#`): matches every topic. */ - fun all(): ResolvedTopic = ResolvedTopic(listOf("#")) } } - -/** A topic ended by `all()`: nothing can be appended after the `#` wildcard. */ -class ResolvedTopic internal constructor( - private val levels: List, -) { - override fun toString(): String = levels.joinToString("/") -} diff --git a/templates/android/library/src/main/java/io/package/services/Push.kt.twig b/templates/android/library/src/main/java/io/package/services/Push.kt.twig index f0818a1494..2b1b9ad1ec 100644 --- a/templates/android/library/src/main/java/io/package/services/Push.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Push.kt.twig @@ -14,7 +14,6 @@ import androidx.core.content.ContextCompat import com.hivemq.client.mqtt.datatypes.MqttQos import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient import {{ sdk.namespace | caseDot }}.Client -import {{ sdk.namespace | caseDot }}.ResolvedTopic import {{ sdk.namespace | caseDot }}.Service import {{ sdk.namespace | caseDot }}.Topic import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.info.title | caseUcfirst }}Exception @@ -338,7 +337,7 @@ class Push( /** * Subscribe to a single topic built with [{{ sdk.namespace | caseDot }}.Topic], e.g. - * `push.subscribe(Topic.path(listOf("user", userId)).any()) { message -> ... }`. + * `push.subscribe(Topic.path(listOf("user", userId))) { message -> ... }`. * Returns a [PushSubscription] handle; see the `List` overload for the options. */ @JvmOverloads @@ -352,28 +351,12 @@ class Push( ): PushSubscription = subscribe(listOf(topic.toString()), background, title, retry, notifyInForeground, callback) - /** - * Subscribe to a single topic ended with `all()`, e.g. - * `push.subscribe(Topic.path(listOf("user", userId)).all()) { message -> ... }`. - * Returns a [PushSubscription] handle; see the `List` overload for the options. - */ - @JvmOverloads - fun subscribe( - topic: ResolvedTopic, - background: Boolean = false, - title: String? = null, - retry: Boolean = true, - notifyInForeground: Boolean = false, - callback: (PushMessage) -> Unit, - ): PushSubscription = - subscribe(listOf(topic.toString()), background, title, retry, notifyInForeground, callback) - /** * Subscribe to a list mixing raw strings and topics built with [{{ sdk.namespace | caseDot }}.Topic], e.g. - * `push.subscribe(listOf("news", Topic.path(listOf("chat")).any())) { message -> ... }`. - * Each element must be a `String`, `Topic` or `ResolvedTopic` (Kotlin has no common type for - * them, so this is checked at runtime). Returns a [PushSubscription] handle; see the - * `List` overload for the options. + * `push.subscribe(listOf("news", Topic.path(listOf("chat", chatId)))) { message -> ... }`. + * Each element must be a `String` or `Topic` (Kotlin has no common type for them, so this is + * checked at runtime). Returns a [PushSubscription] handle; see the `List` overload + * for the options. */ @JvmOverloads @JvmName("subscribeTopics") @@ -386,8 +369,8 @@ class Push( callback: (PushMessage) -> Unit, ): PushSubscription { val filters = topics.mapIndexed { index, topic -> - require(topic is String || topic is Topic || topic is ResolvedTopic) { - "topics[$index] must be a String, Topic or ResolvedTopic, got ${topic::class.simpleName}" + require(topic is String || topic is Topic) { + "topics[$index] must be a String or Topic, got ${topic::class.simpleName}" } topic.toString() } @@ -397,8 +380,8 @@ class Push( /** * Subscribe to one or more topics. Returns a [PushSubscription] handle. * - * Topics are MQTT filters: pass raw strings, or build them with [{{ sdk.namespace | caseDot }}.Topic] - * through the `Topic` overloads, e.g. `push.subscribe(Topic.path(listOf("user")).any()) { ... }`. + * Pass raw strings, or build them with [{{ sdk.namespace | caseDot }}.Topic] + * through the `Topic` overloads, e.g. `push.subscribe(Topic.path(listOf("user", userId))) { ... }`. * * For a foreground subscription this blocks until the broker has acknowledged every * SUBSCRIBE (SUBACK): the broker only starts routing a topic once its subscription is diff --git a/templates/android/library/src/test/java/io/package/TopicTest.kt.twig b/templates/android/library/src/test/java/io/package/TopicTest.kt.twig index 3c26a4aad7..51066451b5 100644 --- a/templates/android/library/src/test/java/io/package/TopicTest.kt.twig +++ b/templates/android/library/src/test/java/io/package/TopicTest.kt.twig @@ -7,7 +7,7 @@ import org.junit.Test class TopicTest { private fun assertPathFails(levels: List) { assertThrows(IllegalArgumentException::class.java) { Topic.path(levels) } - assertThrows(IllegalArgumentException::class.java) { Topic.any().path(levels) } + assertThrows(IllegalArgumentException::class.java) { Topic.path(listOf("user")).path(levels) } } @Test @@ -23,23 +23,6 @@ class TopicTest { ) } - @Test - fun anyAppendsSingleLevelWildcard() { - assertEquals("user/+/notification", Topic.path(listOf("user")).any().path(listOf("notification")).toString()) - assertEquals("chat/+/+/message", Topic.path(listOf("chat")).any().any().path(listOf("message")).toString()) - } - - @Test - fun allEndsTopic() { - assertEquals("org/+/logs/#", Topic.path(listOf("org")).any().path(listOf("logs")).all().toString()) - } - - @Test - fun staticAnyAndAll() { - assertEquals("+/notification", Topic.any().path(listOf("notification")).toString()) - assertEquals("#", Topic.all().toString()) - } - @Test fun pathRejectsInvalidLevels() { assertPathFails(emptyList()) diff --git a/tests/e2e/languages/android/Tests.kt b/tests/e2e/languages/android/Tests.kt index 47d2feac20..2dacb4f655 100644 --- a/tests/e2e/languages/android/Tests.kt +++ b/tests/e2e/languages/android/Tests.kt @@ -478,11 +478,6 @@ class ServiceTest { // Topic helper tests writeToFile(Topic.path(listOf("user", "123", "notification")).toString()) writeToFile(Topic.path(listOf("org", "42", "user", "123")).path(listOf("notification")).toString()) - writeToFile(Topic.path(listOf("user")).any().path(listOf("notification")).toString()) - writeToFile(Topic.path(listOf("chat")).any().any().path(listOf("message")).toString()) - writeToFile(Topic.path(listOf("org")).any().path(listOf("logs")).all().toString()) - writeToFile(Topic.any().path(listOf("notification")).toString()) - writeToFile(Topic.all().toString()) val topicErrorCases = listOf( "empty path" to emptyList(), "empty level" to listOf("user", ""), From ec9e164932dfa50b14a5ed07169a0de598ee3212 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:15:03 +0530 Subject: [PATCH 13/17] feat(dotnet): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/dotnet/Package/Topic.cs.twig | 66 ++++---------------------- tests/e2e/languages/dotnet/Tests.cs | 5 -- 2 files changed, 8 insertions(+), 63 deletions(-) diff --git a/templates/dotnet/Package/Topic.cs.twig b/templates/dotnet/Package/Topic.cs.twig index bcf7a69175..3d69fc4235 100644 --- a/templates/dotnet/Package/Topic.cs.twig +++ b/templates/dotnet/Package/Topic.cs.twig @@ -8,13 +8,9 @@ namespace {{ spec.info.title | caseUcfirst }} /// Helper to build MQTT topics for Push, level by level. /// /// Topic.Path(new[] { "user", userId, "notification" }); // "user/123/notification" - /// Topic.Path(new[] { "user" }).Any().Path(new[] { "notification" }); // "user/+/notification" - /// Topic.Path(new[] { "org" }).Any().Path(new[] { "logs" }).All(); // "org/+/logs/#" + /// Topic.Path(new[] { "org", orgId }).Path(new[] { "logs" }); // "org/42/logs" /// - /// Any() matches exactly one level and All() everything below, at any depth - /// (including the parent itself). All() ends the topic, so it returns a - /// . The instance Path, Any and All that - /// append to a topic live in . + /// The instance Path that appends to a topic lives in . /// public sealed class Topic { @@ -32,24 +28,11 @@ namespace {{ spec.info.title | caseUcfirst }} return new Topic(LevelsOf(levels)); } - /// Start a topic with a single-level wildcard (+). - public static Topic Any() - { - return new Topic(new[] { "+" }); - } - - /// The multi-level wildcard alone (#): matches every topic. - public static ResolvedTopic All() - { - return new ResolvedTopic(new[] { "#" }); - } - public override string ToString() => string.Join("/", Levels); /// /// Validate the levels passed to Path: a non-empty list of non-empty strings, none - /// holding the level separator / or the wildcards + and # (use - /// Any() and All() for those). + /// holding the level separator / or the MQTT wildcards + and #. /// internal static List LevelsOf(IEnumerable levels) { @@ -80,21 +63,13 @@ namespace {{ spec.info.title | caseUcfirst }} var parts = string.Join(", ", level.Split('/').Select(part => $"'{part}'")); throw new ArgumentException($"\"{level}\" contains \"/\", split it: path([{parts}])"); } - if (level == "+") - { - throw new ArgumentException("\"+\" is reserved, use any()"); - } if (level.Contains("+")) { - throw new ArgumentException($"\"{level}\" contains \"+\", use any() for wildcards"); - } - if (level == "#") - { - throw new ArgumentException("\"#\" is reserved, use all()"); + throw new ArgumentException($"\"{level}\" contains \"+\", not allowed in a topic"); } if (level.Contains("#")) { - throw new ArgumentException($"\"{level}\" contains \"#\", use all() for wildcards"); + throw new ArgumentException($"\"{level}\" contains \"#\", not allowed in a topic"); } } @@ -103,9 +78,9 @@ namespace {{ spec.info.title | caseUcfirst }} } /// - /// Appends levels to a . These are extension methods because C# does not - /// allow a static and an instance method with the same signature on one class, and they - /// keep chains reading Topic.Path(...).Any().Path(...). + /// Appends levels to a . This is an extension method because C# does not + /// allow a static and an instance method with the same signature on one class, and it keeps + /// chains reading Topic.Path(...).Path(...). /// public static class TopicExtensions { @@ -115,30 +90,5 @@ namespace {{ spec.info.title | caseUcfirst }} { return new Topic(topic.Levels.Concat(Topic.LevelsOf(levels)).ToList()); } - - /// Append a single-level wildcard (+): matches exactly one level. - public static Topic Any(this Topic topic) - { - return new Topic(topic.Levels.Concat(new[] { "+" }).ToList()); - } - - /// Append the multi-level wildcard (#) and end the topic. - public static ResolvedTopic All(this Topic topic) - { - return new ResolvedTopic(topic.Levels.Concat(new[] { "#" }).ToList()); - } - } - - /// A topic ended by All(): nothing can be appended after the # wildcard. - public sealed class ResolvedTopic - { - private readonly IReadOnlyList _levels; - - internal ResolvedTopic(IReadOnlyList levels) - { - _levels = levels; - } - - public override string ToString() => string.Join("/", _levels); } } diff --git a/tests/e2e/languages/dotnet/Tests.cs b/tests/e2e/languages/dotnet/Tests.cs index eb96598037..6396e25046 100644 --- a/tests/e2e/languages/dotnet/Tests.cs +++ b/tests/e2e/languages/dotnet/Tests.cs @@ -307,11 +307,6 @@ public async Task Test1() // Topic helper tests TestContext.WriteLine(Topic.Path(new[] { "user", "123", "notification" }).ToString()); TestContext.WriteLine(Topic.Path(new[] { "org", "42", "user", "123" }).Path(new[] { "notification" }).ToString()); - TestContext.WriteLine(Topic.Path(new[] { "user" }).Any().Path(new[] { "notification" }).ToString()); - TestContext.WriteLine(Topic.Path(new[] { "chat" }).Any().Any().Path(new[] { "message" }).ToString()); - TestContext.WriteLine(Topic.Path(new[] { "org" }).Any().Path(new[] { "logs" }).All().ToString()); - TestContext.WriteLine(Topic.Any().Path(new[] { "notification" }).ToString()); - TestContext.WriteLine(Topic.All().ToString()); var topicCases = new (string Name, string[] Levels)[] { ("empty path", new string[0]), From 4aa3531d0e6a48d6f252c5b6e4f989723ec52540 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:15:03 +0530 Subject: [PATCH 14/17] feat(unity): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/unity/Assets/Runtime/Push.cs.twig | 22 ++++----------------- tests/e2e/languages/unity/Tests.cs | 5 ----- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/templates/unity/Assets/Runtime/Push.cs.twig b/templates/unity/Assets/Runtime/Push.cs.twig index d5757b0413..a1f45844b6 100644 --- a/templates/unity/Assets/Runtime/Push.cs.twig +++ b/templates/unity/Assets/Runtime/Push.cs.twig @@ -299,7 +299,7 @@ namespace {{ spec.info.title | caseUcfirst }} /// /// Topics can be raw strings or built with : /// - /// await push.Subscribe(Topic.Path(new[] { "user", userId }).Any(), message => Debug.Log(message.Data)); + /// await push.Subscribe(Topic.Path(new[] { "user", userId }), message => Debug.Log(message.Data)); /// /// /// Leave the topic out to subscribe to the signed-in user's own topic @@ -336,27 +336,15 @@ namespace {{ spec.info.title | caseUcfirst }} return Subscribe(new[] { topic.ToString() }, callback, retry); } - /// Subscribe to a topic ended with All(). - public Task Subscribe(ResolvedTopic topic, Action callback, bool retry = true) - { - return Subscribe(new[] { topic.ToString() }, callback, retry); - } - /// Subscribe to several topics built with . public Task Subscribe(IEnumerable topics, Action callback, bool retry = true) { return Subscribe(topics.Select(topic => topic.ToString()), callback, retry); } - /// Subscribe to several topics ended with All(). - public Task Subscribe(IEnumerable topics, Action callback, bool retry = true) - { - return Subscribe(topics.Select(topic => topic.ToString()), callback, retry); - } - /// /// Subscribe to a list mixing raw strings and built topics, e.g. - /// new TopicValue[] { "news", Topic.Path(new[] { "chat" }).Any() }. + /// new TopicValue[] { "news", Topic.Path(new[] { "chat", chatId }) }. /// public Task Subscribe(IEnumerable topics, Action callback, bool retry = true) { @@ -1065,8 +1053,8 @@ namespace {{ spec.info.title | caseUcfirst }} } /// - /// A topic accepted by : a raw string, a or a - /// . It converts implicitly from each, so a list can mix them. + /// A topic accepted by : a raw string or a . It converts + /// implicitly from each, so a list can mix them. /// public readonly struct TopicValue { @@ -1081,8 +1069,6 @@ namespace {{ spec.info.title | caseUcfirst }} public static implicit operator TopicValue(Topic topic) => new TopicValue(topic.ToString()); - public static implicit operator TopicValue(ResolvedTopic topic) => new TopicValue(topic.ToString()); - public override string ToString() => _value; } diff --git a/tests/e2e/languages/unity/Tests.cs b/tests/e2e/languages/unity/Tests.cs index c99dcaaae5..4f6f9736b3 100644 --- a/tests/e2e/languages/unity/Tests.cs +++ b/tests/e2e/languages/unity/Tests.cs @@ -440,11 +440,6 @@ private async Task RunAsyncTest() // Topic helper tests LogResult(Topic.Path(new[] { "user", "123", "notification" }).ToString()); LogResult(Topic.Path(new[] { "org", "42", "user", "123" }).Path(new[] { "notification" }).ToString()); - LogResult(Topic.Path(new[] { "user" }).Any().Path(new[] { "notification" }).ToString()); - LogResult(Topic.Path(new[] { "chat" }).Any().Any().Path(new[] { "message" }).ToString()); - LogResult(Topic.Path(new[] { "org" }).Any().Path(new[] { "logs" }).All().ToString()); - LogResult(Topic.Any().Path(new[] { "notification" }).ToString()); - LogResult(Topic.All().ToString()); var topicCases = new (string Name, string[] Levels)[] { ("empty path", new string[0]), From b97190de97ff1d5ac16acfc6cd033eaeb206a71f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:15:03 +0530 Subject: [PATCH 15/17] feat(swift): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/swift/Sources/Topic.swift.twig | 56 ++---------------------- tests/e2e/languages/swift/Tests.swift | 5 --- 2 files changed, 4 insertions(+), 57 deletions(-) diff --git a/templates/swift/Sources/Topic.swift.twig b/templates/swift/Sources/Topic.swift.twig index 3543d25ce9..4a479ebb2b 100644 --- a/templates/swift/Sources/Topic.swift.twig +++ b/templates/swift/Sources/Topic.swift.twig @@ -19,7 +19,7 @@ public struct TopicError: Swift.Error, LocalizedError, CustomStringConvertible { } /// Validate the levels passed to `path(_:)`: a non-empty list of non-empty strings, none holding -/// the level separator `/` or the wildcards `+` and `#` (use `any()` and `all()` for those). +/// the level separator `/` or the MQTT wildcards `+` and `#`. private func levelsOf(_ levels: [String]) throws -> [String] { if levels.isEmpty { throw TopicError("path() needs at least one level") @@ -36,17 +36,11 @@ private func levelsOf(_ levels: [String]) throws -> [String] { .joined(separator: ", ") throw TopicError("\"\(level)\" contains \"/\", split it: path([\(parts)])") } - if level == "+" { - throw TopicError("\"+\" is reserved, use any()") - } if level.contains("+") { - throw TopicError("\"\(level)\" contains \"+\", use any() for wildcards") - } - if level == "#" { - throw TopicError("\"#\" is reserved, use all()") + throw TopicError("\"\(level)\" contains \"+\", not allowed in a topic") } if level.contains("#") { - throw TopicError("\"\(level)\" contains \"#\", use all() for wildcards") + throw TopicError("\"\(level)\" contains \"#\", not allowed in a topic") } } return levels @@ -55,11 +49,7 @@ private func levelsOf(_ levels: [String]) throws -> [String] { /// Helper to build MQTT topics for `Push`, level by level. /// /// try Topic.path(["user", userId, "notification"]) // "user/123/notification" -/// try Topic.path(["user"]).any().path(["notification"]) // "user/+/notification" -/// try Topic.path(["org"]).any().path(["logs"]).all() // "org/+/logs/#" -/// -/// `any()` matches exactly one level and `all()` everything below, at any depth (including the -/// parent itself). `all()` ends the topic, so it returns a `ResolvedTopic`. +/// try Topic.path(["org", orgId]).path(["logs"]) // "org/42/logs" public final class Topic: CustomStringConvertible, Sendable { private let levels: [String] @@ -72,49 +62,11 @@ public final class Topic: CustomStringConvertible, Sendable { return Topic(try levelsOf(levels)) } - /// Start a topic with a single-level wildcard (`+`). - public static func any() -> Topic { - return Topic(["+"]) - } - - /// The multi-level wildcard alone (`#`): matches every topic. - public static func all() -> ResolvedTopic { - return ResolvedTopic(["#"]) - } - /// Append the given levels. public func path(_ levels: [String]) throws -> Topic { return Topic(self.levels + (try levelsOf(levels))) } - /// Append a single-level wildcard (`+`): matches exactly one level. - public func any() -> Topic { - return Topic(levels + ["+"]) - } - - /// Append the multi-level wildcard (`#`) and end the topic. - public func all() -> ResolvedTopic { - return ResolvedTopic(levels + ["#"]) - } - - /// The topic string, levels joined with `/`. - public func toString() -> String { - return levels.joined(separator: "/") - } - - public var description: String { - return toString() - } -} - -/// A topic ended by `all()`: nothing can be appended after the `#` wildcard. -public final class ResolvedTopic: CustomStringConvertible, Sendable { - private let levels: [String] - - fileprivate init(_ levels: [String]) { - self.levels = levels - } - /// The topic string, levels joined with `/`. public func toString() -> String { return levels.joined(separator: "/") diff --git a/tests/e2e/languages/swift/Tests.swift b/tests/e2e/languages/swift/Tests.swift index ab370a4031..a7ffa4e64d 100644 --- a/tests/e2e/languages/swift/Tests.swift +++ b/tests/e2e/languages/swift/Tests.swift @@ -330,11 +330,6 @@ class Tests: XCTestCase { // Topic helper tests print(try Topic.path(["user", "123", "notification"]).toString()) print(try Topic.path(["org", "42", "user", "123"]).path(["notification"]).toString()) - print(try Topic.path(["user"]).any().path(["notification"]).toString()) - print(try Topic.path(["chat"]).any().any().path(["message"]).toString()) - print(try Topic.path(["org"]).any().path(["logs"]).all().toString()) - print(try Topic.any().path(["notification"]).toString()) - print(Topic.all().toString()) let topicCases: [(String, [String])] = [ ("empty path", []), ("empty level", ["user", ""]), From 50fce4d46bce746f76763aff008b3292c649d7c0 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:15:03 +0530 Subject: [PATCH 16/17] feat(apple): drop topic wildcards from the Topic helper Remove the any()/all() wildcard builders and the ResolvedTopic type so a topic is only a path of plain id levels. A level containing + or # is now rejected outright rather than steered toward a wildcard builder. --- templates/apple/Sources/Services/Push.swift.twig | 6 ++---- tests/e2e/languages/apple/Tests.swift | 5 ----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 5d7eadcd7a..77d26218f3 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -151,7 +151,7 @@ public struct PushNotificationOpened { } #endif -/// A topic as accepted by `Push.subscribe`: a raw `String`, a `Topic` or a `ResolvedTopic`. +/// A topic as accepted by `Push.subscribe`: a raw `String` or a `Topic`. public protocol TopicValue { func toString() -> String } @@ -161,8 +161,6 @@ extension String: TopicValue {} extension Topic: TopicValue {} -extension ResolvedTopic: TopicValue {} - /// Handle for a live subscription created with `Push.subscribe`. /// /// Unlike the realtime service's bare unsubscribe callable, a push subscription carries @@ -402,7 +400,7 @@ open class Push: Service { /// Subscribe to one or more topics. Returns a `PushSubscription` handle. /// /// Topics are raw strings or built with `Topic`, e.g. - /// `try await push.subscribe(try Topic.path(["user", userId]).all()) { message in ... }`. + /// `try await push.subscribe(try Topic.path(["user", userId])) { message in ... }`. /// Omit the topic (`try await push.subscribe { message in ... }`) to subscribe to the /// signed-in user's own topic, `users/`. /// diff --git a/tests/e2e/languages/apple/Tests.swift b/tests/e2e/languages/apple/Tests.swift index f3c18c6db5..f6e8cd8893 100644 --- a/tests/e2e/languages/apple/Tests.swift +++ b/tests/e2e/languages/apple/Tests.swift @@ -451,11 +451,6 @@ class Tests: XCTestCase { // Topic helper tests print(try Topic.path(["user", "123", "notification"]).toString()) print(try Topic.path(["org", "42", "user", "123"]).path(["notification"]).toString()) - print(try Topic.path(["user"]).any().path(["notification"]).toString()) - print(try Topic.path(["chat"]).any().any().path(["message"]).toString()) - print(try Topic.path(["org"]).any().path(["logs"]).all().toString()) - print(try Topic.any().path(["notification"]).toString()) - print(Topic.all().toString()) let topicCases: [(String, [String])] = [ ("empty path", []), ("empty level", ["user", ""]), From 35f4d6a5c4ee40cb183994fb1f58c21316cc973b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 15 Oct 2026 11:15:03 +0530 Subject: [PATCH 17/17] test(e2e): drop topic wildcard expectations The language programs no longer print the any()/all() cases, so remove the matching lines from the shared topic helper expectations. --- tests/e2e/Base.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index 9f83998aad..ba964bf427 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -463,11 +463,6 @@ abstract class Base extends TestCase protected const TOPIC_HELPER_RESPONSES = [ 'user/123/notification', 'org/42/user/123/notification', - 'user/+/notification', - 'chat/+/+/message', - 'org/+/logs/#', - '+/notification', - '#', 'Topic empty path:passed', 'Topic empty level:passed', 'Topic slash:passed',