Skip to content

Commit

Permalink
Topic template extension maintenance (#2022)
Browse files Browse the repository at this point in the history
* Topic templates: Ensure parameter values are checked for wildcard

* Topic templates: Complement extension methods

- add template.BuildMessage()
- add subscribeOptionsBuilder.WithTopicTemplate()

* Topic templates: switch some samples to use a topic template

The changes should be self-explanatory and are
intended to provide more examples.

* Topic templates: add README
  • Loading branch information
simonthum authored Jun 22, 2024
1 parent 8650002 commit c5f4cf1
Show file tree
Hide file tree
Showing 10 changed files with 264 additions and 54 deletions.
43 changes: 13 additions & 30 deletions Samples/Client/Client_Subscribe_Samples.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// ReSharper disable UnusedMember.Local

using MQTTnet.Client;
using MQTTnet.Extensions.TopicTemplate;
using MQTTnet.Packets;
using MQTTnet.Protocol;
using MQTTnet.Samples.Helpers;
Expand All @@ -16,6 +17,8 @@ namespace MQTTnet.Samples.Client;

public static class Client_Subscribe_Samples
{
static MqttTopicTemplate sampleTemplate = new MqttTopicTemplate("mqttnet/samples/topic/{id}");

public static async Task Handle_Received_Application_Message()
{
/*
Expand All @@ -42,11 +45,7 @@ public static async Task Handle_Received_Application_Message()
await mqttClient.ConnectAsync(mqttClientOptions, CancellationToken.None);

var mqttSubscribeOptions = mqttFactory.CreateSubscribeOptionsBuilder()
.WithTopicFilter(
f =>
{
f.WithTopic("mqttnet/samples/topic/2");
})
.WithTopicTemplate(sampleTemplate.WithParameter("id", "2"))
.Build();

await mqttClient.SubscribeAsync(mqttSubscribeOptions, CancellationToken.None);
Expand Down Expand Up @@ -88,11 +87,8 @@ public static async Task Send_Responses()
await mqttClient.ConnectAsync(mqttClientOptions, CancellationToken.None);

var mqttSubscribeOptions = mqttFactory.CreateSubscribeOptionsBuilder()
.WithTopicFilter(
f =>
{
f.WithTopic("mqttnet/samples/topic/1");
})
.WithTopicTemplate(
sampleTemplate.WithParameter("id", "1"))
.Build();

var response = await mqttClient.SubscribeAsync(mqttSubscribeOptions, CancellationToken.None);
Expand Down Expand Up @@ -121,21 +117,12 @@ public static async Task Subscribe_Multiple_Topics()
// Create the subscribe options including several topics with different options.
// It is also possible to all of these topics using a dedicated call of _SubscribeAsync_ per topic.
var mqttSubscribeOptions = mqttFactory.CreateSubscribeOptionsBuilder()
.WithTopicFilter(
f =>
{
f.WithTopic("mqttnet/samples/topic/1");
})
.WithTopicFilter(
f =>
{
f.WithTopic("mqttnet/samples/topic/2").WithNoLocal();
})
.WithTopicFilter(
f =>
{
f.WithTopic("mqttnet/samples/topic/3").WithRetainHandling(MqttRetainHandling.SendAtSubscribe);
})
.WithTopicTemplate(
sampleTemplate.WithParameter("id", "1"))
.WithTopicTemplate(
sampleTemplate.WithParameter("id", "2"), noLocal: true)
.WithTopicTemplate(
sampleTemplate.WithParameter("id", "3"), retainHandling: MqttRetainHandling.SendAtSubscribe)
.Build();

var response = await mqttClient.SubscribeAsync(mqttSubscribeOptions, CancellationToken.None);
Expand All @@ -162,11 +149,7 @@ public static async Task Subscribe_Topic()
await mqttClient.ConnectAsync(mqttClientOptions, CancellationToken.None);

var mqttSubscribeOptions = mqttFactory.CreateSubscribeOptionsBuilder()
.WithTopicFilter(
f =>
{
f.WithTopic("mqttnet/samples/topic/1");
})
.WithTopicTemplate(sampleTemplate.WithParameter("id", "1"))
.Build();

var response = await mqttClient.SubscribeAsync(mqttSubscribeOptions, CancellationToken.None);
Expand Down
1 change: 1 addition & 0 deletions Samples/MQTTnet.Samples.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<ProjectReference Include="..\Source\MQTTnet.AspnetCore\MQTTnet.AspNetCore.csproj" />
<ProjectReference Include="..\Source\MQTTnet.Extensions.ManagedClient\MQTTnet.Extensions.ManagedClient.csproj" />
<ProjectReference Include="..\Source\MQTTnet.Extensions.Rpc\MQTTnet.Extensions.Rpc.csproj" />
<ProjectReference Include="..\Source\MQTTnet.Extensions.TopicTemplate\MQTTnet.Extensions.TopicTemplate.csproj" />
<ProjectReference Include="..\Source\MQTTnet.Extensions.WebSocket4Net\MQTTnet.Extensions.WebSocket4Net.csproj" />
<ProjectReference Include="..\Source\MQTTnet\MQTTnet.csproj" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
<Company>The contributors of MQTTnet</Company>
<Product>MQTTnet</Product>
<Description>
This is an extension library which provides mqtt topic templating logic to support dispatch,
routing and similar functionality based on the well known moustache syntax, aka
AsyncAPI dynamic channel address.
Provides mqtt topic templating logic to support dispatch,
routing and similar functionality based on the well known moustache syntax (AsyncAPI compatible).
</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Authors>The contributors of MQTTnet</Authors>
<PackageId>MQTTnet.Extensions.TopicTemplate</PackageId>
<SignAssembly>false</SignAssembly>
Expand Down Expand Up @@ -51,6 +51,10 @@
</PropertyGroup>

<ItemGroup>
<None Include="README.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="..\..\Images\nuget.png">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
Expand Down
24 changes: 18 additions & 6 deletions Source/MQTTnet.Extensions.TopicTemplate/MqttTopicTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ namespace MQTTnet.Extensions.TopicTemplate
/// segments in curly braces called parameters. This well-known
/// 'moustache' syntax also matches AsyncAPI Channel Address Expressions.
/// The topic template is designed to support dynamic subscription/publication,
/// message-topic matching and routing. It is intended to be more convenient
/// than String.Format() for aforementioned purposes.
/// message-topic matching and routing. It is intended to be more safe and
/// convenient than String.Format() for aforementioned purposes.
/// </summary>
/// <example>
/// topic/subtopic/{parameter}/{otherParameter}
Expand Down Expand Up @@ -282,12 +282,17 @@ public MqttTopicTemplate TrySetParameter(string parameter, string value)
/// <returns>the topic template (without the parameter)</returns>
public MqttTopicTemplate WithoutParameter(string parameter)
{
return WithParameter(parameter, MqttTopicFilterComparer.SingleLevelWildcard.ToString());
if (string.IsNullOrEmpty(parameter) || !_parameterSegments.Contains(parameter))
{
throw new ArgumentException("topic template parameter must exist.");
}

return ReplaceInternal(parameter, MqttTopicFilterComparer.SingleLevelWildcard.ToString());
}

/// <summary>
/// Substitute a parameter with a given value, thus removing the parameter. If the parameter is not present,
/// the method trows. The value must not contain slashes.
/// the method trows. The value must not contain slashes or wildcards.
/// </summary>
/// <param
/// name="parameter">
Expand All @@ -304,12 +309,19 @@ public MqttTopicTemplate WithoutParameter(string parameter)
/// <returns>the topic template (without the parameter)</returns>
public MqttTopicTemplate WithParameter(string parameter, string value)
{
if (value == null || string.IsNullOrEmpty(parameter) || !_parameterSegments.Contains(parameter) || value.Contains(MqttTopicFilterComparer.LevelSeparator) ||
if (value == null || string.IsNullOrEmpty(parameter) || !_parameterSegments.Contains(parameter) ||
value.Contains(MqttTopicFilterComparer.LevelSeparator) ||
value.Contains(MqttTopicFilterComparer.SingleLevelWildcard) ||
value.Contains(MqttTopicFilterComparer.MultiLevelWildcard))
{
throw new ArgumentException("parameter must exist and value must not contain slashes.");
throw new ArgumentException("parameter must exist and value must not contain slashes or wildcard.");
}

return ReplaceInternal(parameter, value);
}

private MqttTopicTemplate ReplaceInternal(string parameter, string value)
{
var moustache = "{" + parameter + "}";
return new MqttTopicTemplate(Template.Replace(moustache, value));
}
Expand Down
79 changes: 79 additions & 0 deletions Source/MQTTnet.Extensions.TopicTemplate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# MQTTNet.Extensions.TopicTemplate

Provides mqtt topic templating logic to support dispatch, routing and
similar functionality based on the well known moustache syntax, aka
AsyncAPI dynamic channel address.

Generating and parsing MQTT topics and topic filters is often done in
an ad-hoc fashion, leading to error-prone code and subtly introduced
showstoppers like forgetting to strip a slash from a parameter. To
remedy, this extension aids you write safe, maintainable logic
for topic generation, routing and dispatch.


## How it works

The package lets you write MQTTNet code like:
```csharp
var template = new MqttTopicTemplate("App/v1/{sender}/message");

var filter = new MqttTopicFilterBuilder()
.WithTopicTemplate(template) // (1)
.WithAtLeastOnceQoS()
.WithNoLocal()
.Build();

// filter.Topic == "App/v1/+/message"
var myTopic = template
.WithParameter("sender", "me, myself & i"); // (2)
var message = new MqttApplicationMessageBuilder()
.WithTopicTemplate( // (3)
myTopic)
.WithPayload("Hello!")
.Build();

// message.Topic == "App/v1/me, myself & i/message"
Assert.IsTrue(message.MatchesTopicTemplate(template)); // (4)
```

At (1), the parameter "sender" was automatically replaced by a plus sign
(single level wildcard) because the caller is creating a topic filter.

Using the same template (they are immutable), you can send
a message. At (2), we replace the parameter with a known value.
If that value contained a level separator or topic filter only
characters, we'd get an ArgumentException. If we forgot to replace
the parameter "sender" (e.g. when placing template not myTopic),
we would be prevented from sending at (3).

On the receiving side, a message can be matched to a template
using code like at (4).

These daily tasks become much safer and easier to code than
string.Format() and Split() allow for. More elaborate computation is supported
too. You could extract the sender using the template or route
messages based on topic content.

## Features

- Safe handling of topic parameters in moustache syntax
- Simplifies topic generation and parsing logic
- Match topics and extract the values of their parameters
- Translate between topic templates to ease dynamic routing
- Derive a canonical topic filter from a bunch of templates
- Integrates with MQTTNet message, subscription and filter builders
- Extension method friendly (sealed class with one public ctor)

## Additional documentation

- Some of the [unit tests](https://github.com/dotnet/MQTTnet/blob/master/Source/MQTTnet.Tests/Extensions/MqttTopicTemplate_Tests.cs) are instructive
- Some examples feature the extension, e.g. TopicGenerator, Client_Subscribe_Samples

## Feedback

Report any issues with the main project on https://github.com/dotnet/MQTTnet/issues


43 changes: 43 additions & 0 deletions Source/MQTTnet.Extensions.TopicTemplate/TopicTemplateExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

using System;
using System.Linq;
using MQTTnet.Client;
using MQTTnet.Packets;
using MQTTnet.Protocol;

namespace MQTTnet.Extensions.TopicTemplate
Expand Down Expand Up @@ -55,6 +57,24 @@ public static MqttTopicFilterBuilder BuildFilter(this MqttTopicTemplate topicTem
return new MqttTopicFilterBuilder().WithTopicTemplate(topicTemplate, subscribeTreeRoot);
}

/// <summary>
/// Create a message builder from this template. The template must not have
/// remaining parameters.
/// </summary>
/// <param
/// name="topicTemplate">
/// a parameterless topic template
/// </param>
/// <returns>a new message builder</returns>
/// <exception
/// cref="ArgumentException">
/// if the topic template has parameters
/// </exception>
public static MqttApplicationMessageBuilder BuildMessage(this MqttTopicTemplate topicTemplate)
{
return new MqttApplicationMessageBuilder().WithTopicTemplate(topicTemplate);
}

/// <summary>
/// Return a message builder to respond to this message. The
/// message's response topic and correlation data are included
Expand Down Expand Up @@ -116,6 +136,29 @@ public static MqttTopicFilterBuilder WithTopicTemplate(this MqttTopicFilterBuild
return builder.WithTopic(subscribeTreeRoot ? topicTemplate.TopicTreeRootFilter : topicTemplate.TopicFilter);
}

/// <summary>
/// Set the subscription to the template's topic filter.
/// </summary>
/// <returns>the builder</returns>
public static MqttClientSubscribeOptionsBuilder WithTopicTemplate(
this MqttClientSubscribeOptionsBuilder builder,
MqttTopicTemplate topicTemplate,
MqttQualityOfServiceLevel qualityOfServiceLevel = MqttQualityOfServiceLevel.AtMostOnce,
bool noLocal = false,
bool retainAsPublished = false,
MqttRetainHandling retainHandling = MqttRetainHandling.SendAtSubscribe)
{
return builder.WithTopicFilter(
new MqttTopicFilter
{
Topic = topicTemplate.TopicFilter,
QualityOfServiceLevel = qualityOfServiceLevel,
NoLocal = noLocal,
RetainAsPublished = retainAsPublished,
RetainHandling = retainHandling
});
}

/// <summary>
/// Set the publication topic according to the topic template. The template
/// must not have remaining (unset) parameters or contain wildcards.
Expand Down
1 change: 1 addition & 0 deletions Source/MQTTnet.TestApp/MQTTnet.TestApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<ProjectReference Include="..\..\Source\MQTTnet.Extensions.ManagedClient\MQTTnet.Extensions.ManagedClient.csproj" />
<ProjectReference Include="..\..\Source\MQTTnet.Extensions.WebSocket4Net\MQTTnet.Extensions.WebSocket4Net.csproj" />
<ProjectReference Include="..\..\Source\MQTTnet\MQTTnet.csproj" />
<ProjectReference Include="..\MQTTnet.Extensions.TopicTemplate\MQTTnet.Extensions.TopicTemplate.csproj" />
</ItemGroup>

</Project>
22 changes: 18 additions & 4 deletions Source/MQTTnet.TestApp/TopicGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text;
using MQTTnet.Extensions.TopicTemplate;

namespace MQTTnet.TestApp
{
Expand All @@ -17,6 +17,8 @@ out Dictionary<string, List<string>> multiWildcardTopicsByPublisher
singleWildcardTopicsByPublisher = new Dictionary<string, List<string>>();
multiWildcardTopicsByPublisher = new Dictionary<string, List<string>>();

MqttTopicTemplate baseTemplate = new MqttTopicTemplate("{publisher}/{building}/{level}/{sensor}");

// Find some reasonable distribution across three topic levels
var topicsPerLevel = (int)Math.Pow(numTopicsPerPublisher, (1.0 / 3.0));

Expand All @@ -40,25 +42,37 @@ out Dictionary<string, List<string>> multiWildcardTopicsByPublisher
int publisherTopicCount = 0;

var publisherName = "pub" + p;
var publisherTemplate = baseTemplate
.WithParameter("publisher", publisherName);
for (var l1 = 0; l1 < numLevel1Topics; ++l1)
{
var l1Template = publisherTemplate
.WithParameter("building", "building" + (l1 + 1));
for (var l2 = 0; l2 < numLevel2Topics; ++l2)
{
var l2Template = l1Template
.WithParameter("level", "level" + (l2 + 1));
for (var l3 = 0; l3 < maxNumLevel3Topics; ++l3)
{
if (publisherTopicCount >= numTopicsPerPublisher)
break;

var topic = string.Format("{0}/building{1}/level{2}/sensor{3}", publisherName, l1 + 1, l2 + 1, l3 + 1);
var topic = l2Template
.WithParameter("sensor", "sensor" + (l3 + 1))
.TopicFilter;
AddPublisherTopic(publisherName, topic, topicsByPublisher);

if (l2 == 0)
{
var singleWildcardTopic = string.Format("{0}/building{1}/+/sensor{2}", publisherName, l1 + 1, l3 + 1);
var singleWildcardTopic = l1Template
.WithParameter("sensor", "sensor" + (l3 + 1))
.TopicFilter;
AddPublisherTopic(publisherName, singleWildcardTopic, singleWildcardTopicsByPublisher);
if (l1 == 0)
{
var multiWildcardTopic = string.Format("{0}/+/level{1}/+", publisherName, l3 + 1);
var multiWildcardTopic = publisherTemplate
.WithParameter("sensor", "sensor" + (l3 + 1))
.TopicFilter;
AddPublisherTopic(publisherName, multiWildcardTopic, multiWildcardTopicsByPublisher);
}
}
Expand Down
Loading

0 comments on commit c5f4cf1

Please sign in to comment.