Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9b0d29e
feat(php): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
8b3c9d2
feat(web): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
2d80be0
feat(node): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
2e363f5
feat(react-native): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
d0cb9a7
feat(go): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
9505823
feat(rust): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
2d8accf
feat(python): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
cff9f0c
feat(ruby): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
f1f4dc0
feat(dart): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
a5ebf4f
feat(flutter): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
ea8ccba
feat(kotlin): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
f4e422d
feat(android): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
ec9e164
feat(dotnet): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
4aa3531
feat(unity): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
b97190d
feat(swift): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
50fce4d
feat(apple): drop topic wildcards from the Topic helper
ArnabChatterjee20k Oct 15, 2026
35f4d6a
test(e2e): drop topic wildcard expectations
ArnabChatterjee20k Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions src/SDK/Language/PHP.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
33 changes: 4 additions & 29 deletions templates/android/library/src/main/java/io/package/Topic.kt.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>): List<String> {
require(levels.isNotEmpty()) { "path() needs at least one level" }
Expand All @@ -12,10 +12,8 @@ private fun levelsOf(levels: List<String>): List<String> {
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()
}
Expand All @@ -25,13 +23,9 @@ private fun levelsOf(levels: List<String>): List<String> {
*
* ```
* 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(
Expand All @@ -40,29 +34,10 @@ class Topic private constructor(
/** Append the given levels. */
fun path(levels: List<String>): 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<String>): 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<String>,
) {
override fun toString(): String = levels.joinToString("/")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>` overload for the options.
*/
@JvmOverloads
Expand All @@ -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<String>` 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<String>` 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<String>` overload
* for the options.
*/
@JvmOverloads
@JvmName("subscribeTopics")
Expand All @@ -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()
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import org.junit.Test
class TopicTest {
private fun assertPathFails(levels: List<String>) {
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
Expand All @@ -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())
Expand Down
6 changes: 2 additions & 4 deletions templates/apple/Sources/Services/Push.swift.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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/<userId>`.
///
Expand Down
45 changes: 6 additions & 39 deletions templates/dart/lib/topic.dart.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _levels;

Expand All @@ -19,42 +14,20 @@ class Topic {
/// Start a topic with the given levels, e.g. `Topic.path(['user', userId])`.
static Topic path(List<String> 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<String> 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<String> _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<String> _topicLevelsOf(List<String> levels) {
if (levels.isEmpty) {
throw ArgumentError('path() needs at least one level');
Expand All @@ -68,17 +41,11 @@ List<String> _topicLevelsOf(List<String> 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);
Expand Down
32 changes: 3 additions & 29 deletions templates/dart/test/topic_test.dart.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand All @@ -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);
});
});
});
Expand Down
Loading
Loading