diff --git a/build.gradle.kts b/build.gradle.kts index 272a6f63e..dd695d692 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -229,6 +229,8 @@ dependencies { testImplementation("org.mockito:mockito-core:${property("mockito.version")}") testImplementation("com.squareup.okhttp3:mockwebserver:${property("ok-http.version")}") testImplementation("com.ginsberg:junit5-system-exit:${property("system-exit.version")}") + testImplementation("com.github.stefanbirkner:system-lambda:${property("system-lambda.version")}") + testImplementation("org.assertj:assertj-core:${property("assertj.version")}") } /* ******************** integration Tests ******************** */ diff --git a/gradle.properties b/gradle.properties index 644969044..916477d23 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,6 +30,8 @@ tinylog.version=2.5.0 junit-jupiter.version=5.9.0 mockito.version=4.7.0 system-exit.version=1.1.2 +system-lambda.version=1.2.1 +assertj.version=3.24.2 # # integration test dependencies # diff --git a/src/main/java/com/hivemq/cli/MqttCLIMain.java b/src/main/java/com/hivemq/cli/MqttCLIMain.java index c7fcde63d..98f9ff29e 100644 --- a/src/main/java/com/hivemq/cli/MqttCLIMain.java +++ b/src/main/java/com/hivemq/cli/MqttCLIMain.java @@ -18,21 +18,11 @@ import com.hivemq.cli.ioc.DaggerMqttCLI; import com.hivemq.cli.ioc.MqttCLI; -import com.hivemq.cli.mqtt.ClientData; -import com.hivemq.cli.mqtt.ClientKey; -import com.hivemq.cli.mqtt.MqttClientExecutor; -import com.hivemq.client.mqtt.MqttClient; -import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; -import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import picocli.CommandLine; import java.security.Security; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; public class MqttCLIMain { @@ -58,39 +48,12 @@ public static void main(final @NotNull String... args) { System.exit(0); } - Runtime.getRuntime().addShutdownHook(new DisconnectAllClientsTask()); - final int exitCode = commandLine.execute(args); System.exit(exitCode); } - private static class DisconnectAllClientsTask extends Thread { - - @Override - public void run() { - final Map clientKeyToClientData = MqttClientExecutor.getClientDataMap(); - - final List> disconnectFutures = new ArrayList<>(); - - for (final Map.Entry entry : clientKeyToClientData.entrySet()) { - final MqttClient client = entry.getValue().getClient(); - if (client.getConfig().getState().isConnectedOrReconnect()) { - switch (client.getConfig().getMqttVersion()) { - case MQTT_5_0: - disconnectFutures.add(((Mqtt5Client) client).toAsync().disconnect()); - break; - case MQTT_3_1_1: - disconnectFutures.add(((Mqtt3Client) client).toAsync().disconnect()); - break; - } - } - } - CompletableFuture.allOf(disconnectFutures.toArray(new CompletableFuture[0])).join(); - } - } - public static class CLIVersionProvider implements CommandLine.IVersionProvider { @Override diff --git a/src/main/java/com/hivemq/cli/commands/cli/PublishCommand.java b/src/main/java/com/hivemq/cli/commands/cli/PublishCommand.java index c4ca77431..e005a669d 100644 --- a/src/main/java/com/hivemq/cli/commands/cli/PublishCommand.java +++ b/src/main/java/com/hivemq/cli/commands/cli/PublishCommand.java @@ -21,9 +21,8 @@ import com.hivemq.cli.commands.options.DebugOptions; import com.hivemq.cli.commands.options.DefaultOptions; import com.hivemq.cli.commands.options.PublishOptions; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.CliMqttClient; import com.hivemq.cli.utils.LoggerUtils; -import com.hivemq.client.mqtt.MqttClient; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; import picocli.CommandLine; @@ -55,11 +54,8 @@ public class PublishCommand implements Callable { @CommandLine.Mixin private final @NotNull DefaultOptions defaultOptions = new DefaultOptions(); - private final @NotNull MqttClientExecutor mqttClientExecutor; - @Inject - public PublishCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { - this.mqttClientExecutor = mqttClientExecutor; + public PublishCommand() { } @Override @@ -80,17 +76,17 @@ public PublishCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { publishOptions.logUnusedOptions(connectOptions.getVersion()); publishOptions.arrangeQosToMatchTopics(); - final MqttClient client; + final CliMqttClient client; try { - client = mqttClientExecutor.connect(connectOptions, null); - } catch (final Exception exception) { + client = CliMqttClient.connectWith(connectOptions).send(); + } catch (final @NotNull Exception exception) { LoggerUtils.logCommandError("Unable to connect", exception, debugOptions); return 1; } try { - mqttClientExecutor.publish(client, publishOptions); - } catch (final Exception exception) { + client.publish(publishOptions); + } catch (final @NotNull Exception exception) { LoggerUtils.logCommandError("Unable to publish", exception, debugOptions); return 1; } @@ -111,8 +107,6 @@ public PublishCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { debugOptions + ", defaultOptions=" + defaultOptions + - ", mqttClientExecutor=" + - mqttClientExecutor + '}'; } } diff --git a/src/main/java/com/hivemq/cli/commands/cli/SubscribeCommand.java b/src/main/java/com/hivemq/cli/commands/cli/SubscribeCommand.java index c3abb5a82..30e5fd5fa 100644 --- a/src/main/java/com/hivemq/cli/commands/cli/SubscribeCommand.java +++ b/src/main/java/com/hivemq/cli/commands/cli/SubscribeCommand.java @@ -20,18 +20,15 @@ import com.hivemq.cli.commands.options.ConnectOptions; import com.hivemq.cli.commands.options.DebugOptions; import com.hivemq.cli.commands.options.DefaultOptions; +import com.hivemq.cli.commands.options.DisconnectOptions; import com.hivemq.cli.commands.options.SubscribeOptions; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.CliMqttClient; import com.hivemq.cli.utils.LoggerUtils; -import com.hivemq.client.mqtt.MqttClient; -import com.hivemq.client.mqtt.exceptions.ConnectionFailedException; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.tinylog.Logger; import picocli.CommandLine; import javax.inject.Inject; -import java.util.Objects; import java.util.concurrent.Callable; @CommandLine.Command(name = "sub", @@ -41,8 +38,6 @@ public class SubscribeCommand implements Callable { private static final int IDLE_TIME = 5000; - private final @NotNull MqttClientExecutor mqttClientExecutor; - private @Nullable MqttClient subscribeClient; @SuppressWarnings("unused") @CommandLine.Option(names = {"-l"}, @@ -73,8 +68,7 @@ private void printToSTDOUT(final boolean printToSTDOUT) { private final @NotNull DefaultOptions defaultOptions = new DefaultOptions(); @Inject - public SubscribeCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { - this.mqttClientExecutor = mqttClientExecutor; + public SubscribeCommand() { } @Override @@ -100,23 +94,28 @@ public SubscribeCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { return 1; } + final CliMqttClient client; try { - subscribeClient = mqttClientExecutor.connect(connectOptions, subscribeOptions); - } catch (final Exception exception) { + client = CliMqttClient.connectWith(connectOptions) + .subscribeOptions(subscribeOptions) + .send(); + } catch (final @NotNull Exception exception) { LoggerUtils.logCommandError("Unable to connect", exception, debugOptions); return 1; } + Runtime.getRuntime().addShutdownHook(new Thread(() -> client.disconnect(new DisconnectOptions()))); + try { - mqttClientExecutor.subscribe(subscribeClient, subscribeOptions); - } catch (final ConnectionFailedException exception) { + client.subscribe(subscribeOptions); + } catch (final @NotNull Exception exception) { LoggerUtils.logCommandError("Unable to subscribe", exception, debugOptions); return 1; } try { - stay(); - } catch (final InterruptedException exception) { + stay(client); + } catch (final @NotNull InterruptedException exception) { LoggerUtils.logCommandError("Unable to stay", exception, debugOptions); return 1; } @@ -124,8 +123,8 @@ public SubscribeCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { return 0; } - private void stay() throws InterruptedException { - while (Objects.requireNonNull(subscribeClient).getState().isConnectedOrReconnect()) { + private void stay(final @NotNull CliMqttClient client) throws InterruptedException { + while (client.isConnected()) { Thread.sleep(IDLE_TIME); } } @@ -133,10 +132,6 @@ private void stay() throws InterruptedException { @Override public @NotNull String toString() { return "SubscribeCommand{" + - "mqttClientExecutor=" + - mqttClientExecutor + - ", subscribeClient=" + - subscribeClient + ", logToLogfile=" + logToLogfile + ", connectOptions=" + diff --git a/src/main/java/com/hivemq/cli/commands/options/ConnectOptions.java b/src/main/java/com/hivemq/cli/commands/options/ConnectOptions.java index 0ec922224..2a1dec68a 100644 --- a/src/main/java/com/hivemq/cli/commands/options/ConnectOptions.java +++ b/src/main/java/com/hivemq/cli/commands/options/ConnectOptions.java @@ -125,7 +125,7 @@ public int getPort() { return sessionExpiryInterval; } - public @Nullable Mqtt5UserProperties getConnectUserProperties() { + public @NotNull Mqtt5UserProperties getUserProperties() { return MqttUtils.convertToMqtt5UserProperties(connectUserProperties); } diff --git a/src/main/java/com/hivemq/cli/commands/options/DisconnectOptions.java b/src/main/java/com/hivemq/cli/commands/options/DisconnectOptions.java index c84a5f58a..b1a09e88b 100644 --- a/src/main/java/com/hivemq/cli/commands/options/DisconnectOptions.java +++ b/src/main/java/com/hivemq/cli/commands/options/DisconnectOptions.java @@ -74,7 +74,7 @@ public boolean isDisconnectAll() { return reasonString; } - public @Nullable Mqtt5UserProperties getUserProperties() { + public @NotNull Mqtt5UserProperties getUserProperties() { return MqttUtils.convertToMqtt5UserProperties(userProperties); } diff --git a/src/main/java/com/hivemq/cli/commands/options/PublishOptions.java b/src/main/java/com/hivemq/cli/commands/options/PublishOptions.java index dc0eb79bc..8e7eeb615 100644 --- a/src/main/java/com/hivemq/cli/commands/options/PublishOptions.java +++ b/src/main/java/com/hivemq/cli/commands/options/PublishOptions.java @@ -127,7 +127,7 @@ public class PublishOptions { return correlationData; } - public @Nullable Mqtt5UserProperties getUserProperties() { + public @NotNull Mqtt5UserProperties getUserProperties() { return MqttUtils.convertToMqtt5UserProperties(userProperties); } diff --git a/src/main/java/com/hivemq/cli/commands/options/SubscribeOptions.java b/src/main/java/com/hivemq/cli/commands/options/SubscribeOptions.java index afe88264f..2946b026f 100644 --- a/src/main/java/com/hivemq/cli/commands/options/SubscribeOptions.java +++ b/src/main/java/com/hivemq/cli/commands/options/SubscribeOptions.java @@ -106,7 +106,7 @@ public boolean isJsonOutput() { return jsonOutput; } - public @Nullable Mqtt5UserProperties getUserProperties() { + public @NotNull Mqtt5UserProperties getUserProperties() { return MqttUtils.convertToMqtt5UserProperties(userProperties); } diff --git a/src/main/java/com/hivemq/cli/commands/options/WillOptions.java b/src/main/java/com/hivemq/cli/commands/options/WillOptions.java index bbe188a14..2851e29de 100644 --- a/src/main/java/com/hivemq/cli/commands/options/WillOptions.java +++ b/src/main/java/com/hivemq/cli/commands/options/WillOptions.java @@ -143,7 +143,7 @@ public class WillOptions { return willCorrelationData; } - public @Nullable Mqtt5UserProperties getWillUserProperties() { + public @NotNull Mqtt5UserProperties getUserProperties() { return MqttUtils.convertToMqtt5UserProperties(willUserProperties); } diff --git a/src/main/java/com/hivemq/cli/commands/shell/ContextDisconnectCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ContextDisconnectCommand.java index 8f0719c88..cd9ba5b45 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ContextDisconnectCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ContextDisconnectCommand.java @@ -18,8 +18,8 @@ import com.hivemq.cli.commands.options.DefaultOptions; import com.hivemq.cli.commands.options.DisconnectOptions; -import com.hivemq.cli.mqtt.ClientKey; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import com.hivemq.cli.utils.LoggerUtils; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; @@ -29,7 +29,7 @@ import java.util.concurrent.Callable; @CommandLine.Command(name = "dis", aliases = "disconnect", description = "Disconnects this MQTT client") -public class ContextDisconnectCommand extends ShellContextCommand implements Callable { +public class ContextDisconnectCommand implements Callable { @CommandLine.Mixin private final @NotNull DisconnectOptions disconnectOptions = new DisconnectOptions(); @@ -37,28 +37,29 @@ public class ContextDisconnectCommand extends ShellContextCommand implements Cal @CommandLine.Mixin private final @NotNull DefaultOptions defaultOptions = new DefaultOptions(); + private final @NotNull ShellClients shellClients; + @Inject - public ContextDisconnectCommand(final @NotNull MqttClientExecutor executor) { - super(executor); + public ContextDisconnectCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override public @NotNull Integer call() { Logger.trace("Command {} ", this); - if (contextClient != null) { - disconnectOptions.logUnusedDisconnectOptions(contextClient.getConfig().getMqttVersion()); + final CliMqttClient client = shellClients.getContextClient(); + if (client != null) { + disconnectOptions.logUnusedDisconnectOptions(client.getMqttVersion()); } try { if (disconnectOptions.isDisconnectAll()) { - mqttClientExecutor.disconnectAllClients(disconnectOptions); - } else if (disconnectOptions.getClientIdentifier() != null && disconnectOptions.getHost() != null) { - final ClientKey clientKey = - ClientKey.of(disconnectOptions.getClientIdentifier(), disconnectOptions.getHost()); - mqttClientExecutor.disconnect(clientKey, disconnectOptions); - } else if (contextClient != null) { - mqttClientExecutor.disconnect(contextClient, disconnectOptions); + shellClients.disconnectAllClients(disconnectOptions); + } else if (disconnectOptions.getClientIdentifier() != null) { + shellClients.disconnect(disconnectOptions); + } else if (client != null) { + client.disconnect(disconnectOptions); } } catch (final Exception ex) { LoggerUtils.logShellError("Unable to disconnect", ex); diff --git a/src/main/java/com/hivemq/cli/commands/shell/ContextExitCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ContextExitCommand.java index 1681d0aa3..d4724109e 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ContextExitCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ContextExitCommand.java @@ -16,7 +16,7 @@ package com.hivemq.cli.commands.shell; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.ShellClients; import org.jetbrains.annotations.NotNull; import picocli.CommandLine; @@ -24,16 +24,18 @@ import java.util.concurrent.Callable; @CommandLine.Command(name = "exit", description = "Exit the current context", mixinStandardHelpOptions = true) -public class ContextExitCommand extends ShellContextCommand implements Callable { +public class ContextExitCommand implements Callable { + + private final @NotNull ShellClients shellClients; @Inject - public ContextExitCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { - super(mqttClientExecutor); + public ContextExitCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override public @NotNull Integer call() { - removeContext(); + shellClients.removeContextClient(); return 0; } diff --git a/src/main/java/com/hivemq/cli/commands/shell/ContextPublishCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ContextPublishCommand.java index f3c91c6d3..0f7d5cf68 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ContextPublishCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ContextPublishCommand.java @@ -17,7 +17,8 @@ package com.hivemq.cli.commands.shell; import com.hivemq.cli.commands.options.PublishOptions; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import com.hivemq.cli.utils.LoggerUtils; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; @@ -30,26 +31,28 @@ aliases = "publish", description = "Publish a message to a list of topics", mixinStandardHelpOptions = true) -public class ContextPublishCommand extends ShellContextCommand implements Callable { +public class ContextPublishCommand implements Callable { @CommandLine.Mixin private final @NotNull PublishOptions publishOptions = new PublishOptions(); + private final @NotNull ShellClients shellClients; @Inject - public ContextPublishCommand(final @NotNull MqttClientExecutor executor) { - super(executor); + public ContextPublishCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override public @NotNull Integer call() { Logger.trace("Command {} ", this); - if (contextClient != null) { - publishOptions.logUnusedOptions(contextClient.getConfig().getMqttVersion()); + final CliMqttClient client = shellClients.getContextClient(); + if (client != null) { + publishOptions.logUnusedOptions(client.getMqttVersion()); publishOptions.arrangeQosToMatchTopics(); try { - mqttClientExecutor.publish(contextClient, publishOptions); + client.publish(publishOptions); } catch (final Exception ex) { LoggerUtils.logShellError("Unable to publish", ex); return 1; diff --git a/src/main/java/com/hivemq/cli/commands/shell/ContextSubscribeCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ContextSubscribeCommand.java index 41d15df2a..591784d9d 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ContextSubscribeCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ContextSubscribeCommand.java @@ -18,14 +18,14 @@ import com.hivemq.cli.commands.options.SubscribeOptions; import com.hivemq.cli.commands.options.UnsubscribeOptions; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import com.hivemq.cli.utils.LoggerUtils; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; import picocli.CommandLine; import javax.inject.Inject; -import java.util.Objects; import java.util.Scanner; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; @@ -36,7 +36,7 @@ aliases = "subscribe", description = "Subscribe this MQTT client to a list of topics", mixinStandardHelpOptions = true) -public class ContextSubscribeCommand extends ShellContextCommand implements Callable { +public class ContextSubscribeCommand implements Callable { private static final int IDLE_TIME = 1000; @@ -57,22 +57,25 @@ private void printToSTDOUT(final boolean printToSTDOUT) { @CommandLine.Mixin private final @NotNull SubscribeOptions subscribeOptions = new SubscribeOptions(); + private final @NotNull ShellClients shellClients; + @Inject - public ContextSubscribeCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { - super(mqttClientExecutor); + public ContextSubscribeCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override public @NotNull Integer call() { Logger.trace("Command {} ", this); - if (contextClient == null) { + final CliMqttClient client = shellClients.getContextClient(); + if (client == null) { Logger.error("The client to subscribe with does not exist"); return 1; } subscribeOptions.setDefaultOptions(); - subscribeOptions.logUnusedOptions(contextClient.getConfig().getMqttVersion()); + subscribeOptions.logUnusedOptions(client.getMqttVersion()); subscribeOptions.arrangeQosToMatchTopics(); if (stay) { @@ -84,7 +87,7 @@ public ContextSubscribeCommand(final @NotNull MqttClientExecutor mqttClientExecu } try { - mqttClientExecutor.subscribe(Objects.requireNonNull(contextClient), subscribeOptions); + client.subscribe(subscribeOptions); } catch (final Exception ex) { LoggerUtils.logShellError("Unable to subscribe", ex); return 1; @@ -92,7 +95,7 @@ public ContextSubscribeCommand(final @NotNull MqttClientExecutor mqttClientExecu if (stay) { try { - stay(); + stay(client); } catch (final InterruptedException ex) { LoggerUtils.logShellError("Unable to stay", ex); return 1; @@ -102,11 +105,11 @@ public ContextSubscribeCommand(final @NotNull MqttClientExecutor mqttClientExecu return 0; } - private void stay() throws InterruptedException { + private void stay(final @NotNull CliMqttClient client) throws InterruptedException { final CountDownLatch latch = new CountDownLatch(1); final Runnable waitForDisconnectRunnable = () -> { - while (Objects.requireNonNull(contextClient).getState().isConnected()) { + while (client.isConnected()) { try { Thread.sleep(IDLE_TIME); } catch (final InterruptedException e) { @@ -131,12 +134,10 @@ private void stay() throws InterruptedException { WORKER_THREADS.shutdownNow(); - if (contextClient != null) { - if (!contextClient.getState().isConnectedOrReconnect()) { - removeContext(); - } else { - mqttClientExecutor.unsubscribe(contextClient, UnsubscribeOptions.of(subscribeOptions)); - } + if (!client.isConnected()) { + shellClients.removeContextClient(); + } else { + client.unsubscribe(UnsubscribeOptions.of(subscribeOptions)); } } diff --git a/src/main/java/com/hivemq/cli/commands/shell/ContextSwitchCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ContextSwitchCommand.java index 81d3b2eaf..49b9b7d4d 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ContextSwitchCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ContextSwitchCommand.java @@ -16,21 +16,19 @@ package com.hivemq.cli.commands.shell; -import com.hivemq.cli.mqtt.ClientKey; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import com.hivemq.cli.utils.LoggerUtils; -import com.hivemq.client.mqtt.MqttClient; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.tinylog.Logger; import picocli.CommandLine; import javax.inject.Inject; -import java.util.Objects; import java.util.concurrent.Callable; @CommandLine.Command(name = "switch", description = "Switch the current context", mixinStandardHelpOptions = true) -public class ContextSwitchCommand extends ShellContextCommand implements Callable { +public class ContextSwitchCommand implements Callable { @SuppressWarnings("unused") @CommandLine.Parameters(index = "0", arity = "0..1", description = "The name of the context, e.g. client@localhost") @@ -45,9 +43,11 @@ public class ContextSwitchCommand extends ShellContextCommand implements Callabl description = "The hostname of the message broker (default 'localhost')") private @Nullable String host; + private final @NotNull ShellClients shellClients; + @Inject - public ContextSwitchCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { - super(mqttClientExecutor); + public ContextSwitchCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override @@ -60,19 +60,22 @@ public ContextSwitchCommand(final @NotNull MqttClientExecutor mqttClientExecutor } if (contextName != null) { - try { - extractKeyFromContextName(contextName); - } catch (final IllegalArgumentException ex) { - LoggerUtils.logShellError("Unable to switch context", ex); + final String[] context = contextName.split("@"); + if (context.length == 1) { + identifier = context[0]; + } else if (context.length == 2) { + identifier = context[0]; + host = context[1]; + } else { + LoggerUtils.logShellError("Unable to switch context", new IllegalArgumentException("Context name is not valid: " + contextName)); return 1; } } - final MqttClient client = - mqttClientExecutor.getMqttClient(ClientKey.of(identifier, Objects.requireNonNull(host))); + final CliMqttClient client = shellClients.getClient(identifier, host); if (client != null) { - updateContext(client); + shellClients.updateContextClient(client); } else { Logger.error("Context {}@{} not found", identifier, host); return 1; @@ -81,19 +84,6 @@ public ContextSwitchCommand(final @NotNull MqttClientExecutor mqttClientExecutor return 0; } - private void extractKeyFromContextName(final String contextName) { - final String[] context = contextName.split("@"); - - if (context.length == 1) { - identifier = context[0]; - } else if (context.length == 2) { - identifier = context[0]; - host = context[1]; - } else { - throw new IllegalArgumentException("Context name is not valid: " + contextName); - } - } - @Override public @NotNull String toString() { return "ContextSwitchCommand{" + diff --git a/src/main/java/com/hivemq/cli/commands/shell/ContextUnsubscribeCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ContextUnsubscribeCommand.java index 33d6eec21..801dd3e3e 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ContextUnsubscribeCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ContextUnsubscribeCommand.java @@ -17,7 +17,8 @@ package com.hivemq.cli.commands.shell; import com.hivemq.cli.commands.options.UnsubscribeOptions; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import com.hivemq.cli.utils.LoggerUtils; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; @@ -30,29 +31,31 @@ aliases = "unsubscribe", description = "Unsubscribe this MQTT client from a list of topics", mixinStandardHelpOptions = true) -public class ContextUnsubscribeCommand extends ShellContextCommand implements Callable { +public class ContextUnsubscribeCommand implements Callable { @CommandLine.Mixin private final @NotNull UnsubscribeOptions unsubscribeOptions = new UnsubscribeOptions(); + private final @NotNull ShellClients shellClients; @Inject - public ContextUnsubscribeCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { - super(mqttClientExecutor); + public ContextUnsubscribeCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override public @NotNull Integer call() { Logger.trace("Command {} ", this); - if (contextClient == null) { + final CliMqttClient client = shellClients.getContextClient(); + if (client == null) { Logger.error("The client to unsubscribe with does not exist"); return 1; } - unsubscribeOptions.logUnusedUnsubscribeOptions(contextClient.getConfig().getMqttVersion()); + unsubscribeOptions.logUnusedUnsubscribeOptions(client.getMqttVersion()); try { - mqttClientExecutor.unsubscribe(contextClient, unsubscribeOptions); + client.unsubscribe(unsubscribeOptions); } catch (final Exception ex) { LoggerUtils.logShellError("Unable to unsubscribe", ex); return 1; diff --git a/src/main/java/com/hivemq/cli/commands/shell/ListClientsCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ListClientsCommand.java index 6ae46be75..fb1cf8a2f 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ListClientsCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ListClientsCommand.java @@ -16,9 +16,8 @@ package com.hivemq.cli.commands.shell; -import com.hivemq.cli.mqtt.ClientData; -import com.hivemq.cli.mqtt.MqttClientExecutor; -import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; import picocli.CommandLine; @@ -26,13 +25,10 @@ import javax.inject.Inject; import java.io.PrintWriter; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Objects; -import java.util.Set; import java.util.concurrent.Callable; -import java.util.stream.Collectors; @CommandLine.Command(name = "ls", aliases = "list", @@ -63,50 +59,50 @@ public class ListClientsCommand implements Callable { defaultValue = "false", description = "list subscribed topics of clients") private boolean listSubscriptions; + private final @NotNull ShellClients shellClients; @Inject - public ListClientsCommand() { + public ListClientsCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override public @NotNull Integer call() { Logger.trace("Command {}", this); - final List sortedClientData = getSortedClientData(); + final List sortedClients = getSortedClientData(); final PrintWriter writer = ShellCommand.TERMINAL_WRITER; if (longOutput) { - Objects.requireNonNull(writer).println("total " + sortedClientData.size()); + Objects.requireNonNull(writer).println("total " + sortedClients.size()); - if (sortedClientData.size() == 0) { + if (sortedClients.size() == 0) { return 0; } - final Set clients = - sortedClientData.stream().map(ClientData::getClient).collect(Collectors.toSet()); - - final int longestID = clients.stream() - .filter(c -> c.getConfig().getClientIdentifier().isPresent()) - .map(c -> c.getConfig().getClientIdentifier().get().toString().length()) + final int longestId = sortedClients.stream() + .map(client -> client.getClientIdentifier().length()) .max(Integer::compareTo) .orElse(0); - final int longestHost = - clients.stream().map(c -> c.getConfig().getServerHost().length()).max(Integer::compareTo).orElse(0); + final int longestHost = sortedClients.stream() + .map(client -> client.getServerHost().length()) + .max(Integer::compareTo) + .orElse(0); - final int longestState = clients.stream() - .map(c -> c.getConfig().getState().toString().length()) + final int longestState = sortedClients.stream() + .map(client -> client.getState().toString().length()) .max(Integer::compareTo) .orElse(0); - final int longestVersion = clients.stream() - .map(c -> c.getConfig().getMqttVersion().toString().length()) + final int longestVersion = sortedClients.stream() + .map(client -> client.getMqttVersion().toString().length()) .max(Integer::compareTo) .orElse(0); - final int longestSSLVersion = clients.stream() - .map(c -> c.getConfig().getSslConfig().toString().length()) + final int longestSslVersion = sortedClients.stream() + .map(client -> client.getSslProtocols().length()) .max(Integer::compareTo) .orElse("NO_SSL".length()); @@ -115,7 +111,7 @@ public ListClientsCommand() { "s " + "%02d:%02d:%02d " + "%-" + - longestID + + longestId + "s " + "%-" + longestHost + @@ -125,12 +121,11 @@ public ListClientsCommand() { longestVersion + "s " + "%-" + - longestSSLVersion + + longestSslVersion + "s\n"; - for (final ClientData clientData : sortedClientData) { - final MqttClient client = clientData.getClient(); - final LocalDateTime dateTime = clientData.getCreationTime(); + for (final CliMqttClient client : sortedClients) { + final LocalDateTime dateTime = client.getConnectedAt(); final String connectionState = client.getState().toString(); writer.printf(format, @@ -138,29 +133,22 @@ public ListClientsCommand() { dateTime.getHour(), dateTime.getMinute(), dateTime.getSecond(), - client.getConfig().getClientIdentifier().map(Object::toString).orElse(""), - client.getConfig().getServerHost(), - client.getConfig().getServerPort(), - client.getConfig().getMqttVersion().name(), - client.getConfig() - .getSslConfig() - .flatMap(ssl -> ssl.getProtocols().map(Objects::toString)) - .orElse("NO_SSL")); + client.getClientIdentifier(), + client.getServerHost(), + client.getServerPort(), + client.getMqttVersion().name(), + client.getSslProtocols()); if (listSubscriptions) { - writer.printf(" -subscribed topics: %s\n", clientData.getSubscribedTopics()); + writer.printf(" -subscribed topics: %s\n", client.getSubscribedTopics()); } } } else { - for (final ClientData clientData : sortedClientData) { + for (final CliMqttClient client : sortedClients) { Objects.requireNonNull(writer) - .println(clientData.getClient() - .getConfig() - .getClientIdentifier() - .map(Object::toString) - .orElse("") + "@" + clientData.getClient().getConfig().getServerHost()); + .println(client.getClientIdentifier() + "@" + client.getServerHost()); if (listSubscriptions) { - writer.printf(" -subscribed topics: %s\n", clientData.getSubscribedTopics()); + writer.printf(" -subscribed topics: %s\n", client.getSubscribedTopics()); } } } @@ -168,29 +156,22 @@ public ListClientsCommand() { return 0; } - public @NotNull List getSortedClientData() { - final List sortedClientData = new ArrayList<>(MqttClientExecutor.getClientDataMap().values()); + public @NotNull List getSortedClientData() { + Comparator comparator; if (doNotSort) { - return sortedClientData; - } - - Comparator comparator; - if (sortByTime) { - comparator = Comparator.comparing(ClientData::getCreationTime); + comparator = (client1, client2) -> 0; // No-op comparator + } else if (sortByTime) { + comparator = Comparator.comparing(CliMqttClient::getConnectedAt); } else { - comparator = Comparator.comparing(clientData -> clientData.getClient() - .getConfig() - .getClientIdentifier() - .map(Object::toString) - .orElse("")); + comparator = Comparator.comparing(CliMqttClient::getClientIdentifier); } + if (reverse) { comparator = comparator.reversed(); } - sortedClientData.sort(comparator); - return sortedClientData; + return shellClients.listClients(comparator); } @Override diff --git a/src/main/java/com/hivemq/cli/commands/shell/ShellCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ShellCommand.java index aa2c54f05..4f25f9f6c 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ShellCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ShellCommand.java @@ -19,8 +19,9 @@ import com.google.common.base.Throwables; import com.hivemq.cli.DefaultCLIProperties; import com.hivemq.cli.MqttCLIMain; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import com.hivemq.cli.utils.LoggerUtils; -import com.hivemq.client.mqtt.datatypes.MqttClientIdentifier; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jline.reader.LineReaderBuilder; @@ -80,12 +81,14 @@ public class ShellCommand implements Callable { private @NotNull CommandLine.Model.CommandSpec spec; private final @NotNull DefaultCLIProperties defaultCLIProperties; + private final @NotNull ShellClients shellClients; private @Nullable String logfilePath; @Inject - ShellCommand(final @NotNull DefaultCLIProperties defaultCLIProperties) { + ShellCommand(final @NotNull DefaultCLIProperties defaultCLIProperties, final @NotNull ShellClients shellClients) { this.defaultCLIProperties = defaultCLIProperties; + this.shellClients = shellClients; } @Override @@ -133,6 +136,14 @@ public class ShellCommand implements Callable { TERMINAL_WRITER.printf("No Logfile used - Activate logging with the 'mqtt sh -l' option\n"); } + shellClients.addContextClientChangedListener(client -> { + if (client == null) { + readFromShell(); + } else { + readFromContext(client); + } + }); + Logger.info("--- Shell-Mode started ---"); String line; @@ -164,25 +175,21 @@ static void exitShell() { exitShell = true; } - static void readFromContext() { + void readFromContext(final @NotNull CliMqttClient client) { currentReader = contextReader; currentCommandLine = contextCommandLine; prompt = new AttributedStringBuilder().style(AttributedStyle.BOLD.foreground(AttributedStyle.YELLOW)) - .append(Objects.requireNonNull(ShellContextCommand.contextClient) - .getConfig() - .getClientIdentifier() - .orElse(MqttClientIdentifier.of("")) - .toString()) + .append(client.getClientIdentifier()) .style(AttributedStyle.DEFAULT) .append("@") .style(AttributedStyle.BOLD.foreground(AttributedStyle.YELLOW)) - .append(ShellContextCommand.contextClient.getConfig().getServerHost()) + .append(client.getServerHost()) .style(AttributedStyle.DEFAULT) .append("> ") .toAnsi(); } - static void readFromShell() { + void readFromShell() { currentReader = shellReader; currentCommandLine = shellCommandLine; prompt = new AttributedStringBuilder().style(AttributedStyle.DEFAULT).append(DEFAULT_PROMPT).toAnsi(); diff --git a/src/main/java/com/hivemq/cli/commands/shell/ShellConnectCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ShellConnectCommand.java index 655c16644..28675b05b 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ShellConnectCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ShellConnectCommand.java @@ -18,9 +18,9 @@ import com.hivemq.cli.commands.options.ConnectOptions; import com.hivemq.cli.commands.options.DefaultOptions; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import com.hivemq.cli.utils.LoggerUtils; -import com.hivemq.client.mqtt.MqttClient; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; import picocli.CommandLine; @@ -39,12 +39,11 @@ public class ShellConnectCommand implements Callable { @CommandLine.Mixin private final @NotNull DefaultOptions defaultOptions = new DefaultOptions(); - - private final @NotNull MqttClientExecutor mqttClientExecutor; + private final @NotNull ShellClients shellClients; @Inject - public ShellConnectCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { - this.mqttClientExecutor = mqttClientExecutor; + public ShellConnectCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } public @NotNull Integer call() { @@ -53,15 +52,15 @@ public ShellConnectCommand(final @NotNull MqttClientExecutor mqttClientExecutor) connectOptions.setDefaultOptions(); connectOptions.logUnusedOptions(); - final MqttClient client; + final CliMqttClient client; try { - client = mqttClientExecutor.connect(connectOptions); + client = shellClients.connect(connectOptions); } catch (final Exception exception) { LoggerUtils.logShellError("Unable to connect", exception); return 1; } - ShellContextCommand.updateContext(client); + shellClients.updateContextClient(client); return 0; } @@ -73,8 +72,6 @@ public ShellConnectCommand(final @NotNull MqttClientExecutor mqttClientExecutor) connectOptions + ", defaultOptions=" + defaultOptions + - ", mqttClientExecutor=" + - mqttClientExecutor + '}'; } } diff --git a/src/main/java/com/hivemq/cli/commands/shell/ShellContextCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ShellContextCommand.java index 84d7d7010..3050744a9 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ShellContextCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ShellContextCommand.java @@ -16,10 +16,8 @@ package com.hivemq.cli.commands.shell; -import com.hivemq.cli.mqtt.MqttClientExecutor; -import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.cli.mqtt.clients.ShellClients; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import picocli.CommandLine; import javax.inject.Inject; @@ -37,25 +35,11 @@ separator = " ") public class ShellContextCommand implements Callable { - public static @Nullable MqttClient contextClient; - - @NotNull MqttClientExecutor mqttClientExecutor; + final @NotNull ShellClients shellClients; @Inject - public ShellContextCommand(final @NotNull MqttClientExecutor mqttClientExecutor) { - this.mqttClientExecutor = mqttClientExecutor; - } - - static void updateContext(final @Nullable MqttClient client) { - if (client != null && client.getConfig().getState().isConnectedOrReconnect()) { - contextClient = client; - ShellCommand.readFromContext(); - } - } - - public static void removeContext() { - contextClient = null; - ShellCommand.readFromShell(); + public ShellContextCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override diff --git a/src/main/java/com/hivemq/cli/commands/shell/ShellDisconnectCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ShellDisconnectCommand.java index 5ea3cd329..0d7e54017 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ShellDisconnectCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ShellDisconnectCommand.java @@ -19,8 +19,7 @@ import com.hivemq.cli.DefaultCLIProperties; import com.hivemq.cli.commands.options.DefaultOptions; import com.hivemq.cli.commands.options.DisconnectOptions; -import com.hivemq.cli.mqtt.ClientKey; -import com.hivemq.cli.mqtt.MqttClientExecutor; +import com.hivemq.cli.mqtt.clients.ShellClients; import com.hivemq.cli.utils.LoggerUtils; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; @@ -38,14 +37,14 @@ public class ShellDisconnectCommand implements Callable { @CommandLine.Mixin private final @NotNull DefaultOptions defaultOptions = new DefaultOptions(); - private final @NotNull MqttClientExecutor mqttClientExecutor; + private final @NotNull ShellClients shellClients; private final @NotNull DefaultCLIProperties defaultCLIProperties; @Inject ShellDisconnectCommand( - final @NotNull MqttClientExecutor mqttClientExecutor, + final @NotNull ShellClients shellClients, final @NotNull DefaultCLIProperties defaultCLIProperties) { - this.mqttClientExecutor = mqttClientExecutor; + this.shellClients = shellClients; this.defaultCLIProperties = defaultCLIProperties; } @@ -58,15 +57,13 @@ public class ShellDisconnectCommand implements Callable { try { if (disconnectOptions.isDisconnectAll()) { - mqttClientExecutor.disconnectAllClients(disconnectOptions); + shellClients.disconnectAllClients(disconnectOptions); } else { if (disconnectOptions.getClientIdentifier() == null) { Logger.error("Missing required option '--identifier='"); return 1; } - final ClientKey clientKey = - ClientKey.of(disconnectOptions.getClientIdentifier(), disconnectOptions.getHost()); - mqttClientExecutor.disconnect(clientKey, disconnectOptions); + shellClients.disconnect(disconnectOptions); } } catch (final Exception ex) { LoggerUtils.logShellError("Unable to disconnect", ex); @@ -82,8 +79,6 @@ public class ShellDisconnectCommand implements Callable { disconnectOptions + ", defaultOptions=" + defaultOptions + - ", mqttClientExecutor=" + - mqttClientExecutor + ", defaultCLIProperties=" + defaultCLIProperties + '}'; diff --git a/src/main/java/com/hivemq/cli/commands/shell/ShellExitCommand.java b/src/main/java/com/hivemq/cli/commands/shell/ShellExitCommand.java index 5fa1763c4..147eb47a0 100644 --- a/src/main/java/com/hivemq/cli/commands/shell/ShellExitCommand.java +++ b/src/main/java/com/hivemq/cli/commands/shell/ShellExitCommand.java @@ -16,6 +16,8 @@ package com.hivemq.cli.commands.shell; +import com.hivemq.cli.commands.options.DisconnectOptions; +import com.hivemq.cli.mqtt.clients.ShellClients; import org.jetbrains.annotations.NotNull; import picocli.CommandLine; @@ -25,12 +27,16 @@ @CommandLine.Command(name = "exit", description = "Exit the shell", mixinStandardHelpOptions = true) public class ShellExitCommand implements Callable { + private final @NotNull ShellClients shellClients; + @Inject - public ShellExitCommand() { + public ShellExitCommand(final @NotNull ShellClients shellClients) { + this.shellClients = shellClients; } @Override public @NotNull Integer call() { + shellClients.disconnectAllClients(new DisconnectOptions()); ShellCommand.exitShell(); return 0; } diff --git a/src/main/java/com/hivemq/cli/mqtt/AbstractMqttClientExecutor.java b/src/main/java/com/hivemq/cli/mqtt/AbstractMqttClientExecutor.java deleted file mode 100644 index 19085eb7b..000000000 --- a/src/main/java/com/hivemq/cli/mqtt/AbstractMqttClientExecutor.java +++ /dev/null @@ -1,524 +0,0 @@ -/* - * Copyright 2019-present HiveMQ and the HiveMQ Community - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.hivemq.cli.mqtt; - -import com.hivemq.cli.commands.options.AuthenticationOptions; -import com.hivemq.cli.commands.options.ConnectOptions; -import com.hivemq.cli.commands.options.ConnectRestrictionOptions; -import com.hivemq.cli.commands.options.DisconnectOptions; -import com.hivemq.cli.commands.options.PublishOptions; -import com.hivemq.cli.commands.options.SubscribeOptions; -import com.hivemq.cli.commands.options.UnsubscribeOptions; -import com.hivemq.cli.commands.options.WillOptions; -import com.hivemq.cli.utils.IntersectionUtil; -import com.hivemq.client.mqtt.MqttClient; -import com.hivemq.client.mqtt.MqttClientBuilder; -import com.hivemq.client.mqtt.MqttClientState; -import com.hivemq.client.mqtt.MqttGlobalPublishFilter; -import com.hivemq.client.mqtt.datatypes.MqttQos; -import com.hivemq.client.mqtt.datatypes.MqttSharedTopicFilter; -import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; -import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; -import com.hivemq.client.mqtt.mqtt3.message.auth.Mqtt3SimpleAuth; -import com.hivemq.client.mqtt.mqtt3.message.connect.Mqtt3Connect; -import com.hivemq.client.mqtt.mqtt3.message.connect.Mqtt3ConnectBuilder; -import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; -import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3PublishBuilder; -import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; -import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5SimpleAuth; -import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect; -import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5ConnectBuilder; -import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5ConnectRestrictions; -import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5ConnectRestrictionsBuilder; -import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; -import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5WillPublish; -import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5WillPublishBuilder; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.VisibleForTesting; -import org.tinylog.Logger; - -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Consumer; - -abstract class AbstractMqttClientExecutor { - - private static final @NotNull Map clientKeyToClientData = new ConcurrentHashMap<>(); - - abstract void mqtt5Connect( - final @NotNull Mqtt5Client client, final @NotNull Mqtt5Connect connectMessage); - - abstract void mqtt3Connect( - final @NotNull Mqtt3Client client, final @NotNull Mqtt3Connect connectMessage); - - abstract void mqtt5Subscribe( - final @NotNull Mqtt5Client client, - final @NotNull SubscribeOptions subscribeOptions, - final @NotNull String topic, - final @NotNull MqttQos qos); - - abstract void mqtt3Subscribe( - final @NotNull Mqtt3Client client, - final @NotNull SubscribeOptions subscribeOptions, - final @NotNull String topic, - final @NotNull MqttQos qos); - - abstract void mqtt5Publish( - final @NotNull Mqtt5Client client, - final @NotNull PublishOptions publishOptions, - final @NotNull String topic, - final @NotNull MqttQos qos); - - abstract void mqtt3Publish( - final @NotNull Mqtt3Client client, - final @NotNull PublishOptions publishOptions, - final @NotNull String topic, - final @NotNull MqttQos qos); - - abstract void mqtt5Unsubscribe( - final @NotNull Mqtt5Client client, final @NotNull UnsubscribeOptions unsubscribeOptions); - - abstract void mqtt3Unsubscribe( - final @NotNull Mqtt3Client client, final @NotNull UnsubscribeOptions unsubscribeOptions); - - abstract void mqtt5Disconnect( - final @NotNull Mqtt5Client client, final @NotNull DisconnectOptions disconnectOptions); - - abstract void mqtt3Disconnect( - final @NotNull Mqtt3Client client, final @NotNull DisconnectOptions disconnectOptions); - - public @NotNull MqttClient connect(final @NotNull ConnectOptions connectOptions) throws Exception { - return connect(connectOptions, null); - } - - public @NotNull MqttClient connect( - final @NotNull ConnectOptions connectOptions, final @Nullable SubscribeOptions subscribeOptions) - throws Exception { - - final ClientKey clientKey = ClientKey.of(connectOptions.getIdentifier(), connectOptions.getHost()); - if (isConnected(clientKey)) { - Logger.debug("Client is already connected ({})", clientKey); - Logger.info("Using already connected ({})", clientKey); - return clientKeyToClientData.get(clientKey).getClient(); - } - - switch (connectOptions.getVersion()) { - case MQTT_5_0: - return connectMqtt5Client(connectOptions, subscribeOptions); - case MQTT_3_1_1: - return connectMqtt3Client(connectOptions, subscribeOptions); - } - - throw new IllegalStateException("The MQTT Version specified is not supported. Version was " + - connectOptions.getVersion()); - } - - public void subscribe(final @NotNull MqttClient client, final @NotNull SubscribeOptions subscribeOptions) { - for (int i = 0; i < subscribeOptions.getTopics().length; i++) { - final String topic = subscribeOptions.getTopics()[i]; - - // This check only works as subscribes are implemented blocking. - // Otherwise, we would need to check the topics before they are iterated as they are added to the client data after a successful subscribe. - final List intersectingFilters = - checkForSharedTopicDuplicate(clientKeyToClientData.get(ClientKey.of(client)).getSubscribedTopics(), - topic); - // Client{clientIdentifier='hmq_RcrDi_18591249_30a12e2c4bbd17e322a300a4257d76bd', hostname='broker.hivemq.com'} -> {ClientData@3806} - // Client{clientIdentifier='hmq_RcrDi_18591249_30a12e2c4bbd17e322a300a4257d76bd', hostname='broker.hivemq.com'} - if (!intersectingFilters.isEmpty()) { - Logger.warn("WARN: New subscription to '{}' intersects with already existing subscription(s) {}", - topic, - intersectingFilters); - } - - final int qosI = i < subscribeOptions.getQos().length ? i : subscribeOptions.getQos().length - 1; - final MqttQos qos = subscribeOptions.getQos()[qosI]; - - switch (client.getConfig().getMqttVersion()) { - case MQTT_5_0: - mqtt5Subscribe((Mqtt5Client) client, subscribeOptions, topic, qos); - break; - case MQTT_3_1_1: - mqtt3Subscribe((Mqtt3Client) client, subscribeOptions, topic, qos); - break; - } - } - } - - @VisibleForTesting - @NotNull List checkForSharedTopicDuplicate( - final @NotNull Set subscribedFilters, final @NotNull String topic) { - final List intersectingFilters = new ArrayList<>(); - final MqttTopicFilter newUnidentifiedFilter = MqttTopicFilter.of(topic); - - final MqttTopicFilter newFilter; - if (newUnidentifiedFilter.isShared()) { - newFilter = ((MqttSharedTopicFilter) newUnidentifiedFilter).getTopicFilter(); - } else { - newFilter = newUnidentifiedFilter; - } - - for (final MqttTopicFilter subscribedFilter : subscribedFilters) { - final MqttTopicFilter existingFilter; - if (subscribedFilter.isShared()) { - existingFilter = ((MqttSharedTopicFilter) subscribedFilter).getTopicFilter(); - } else { - existingFilter = subscribedFilter; - } - - if (IntersectionUtil.intersects(existingFilter, newFilter)) { - intersectingFilters.add(subscribedFilter); - } - } - return intersectingFilters; - } - - public void publish(final @NotNull MqttClient client, final @NotNull PublishOptions publishOptions) { - for (int i = 0; i < publishOptions.getTopics().length; i++) { - final String topic = publishOptions.getTopics()[i]; - final int qosI = i < publishOptions.getQos().length ? i : publishOptions.getQos().length - 1; - final MqttQos qos = publishOptions.getQos()[qosI]; - - switch (client.getConfig().getMqttVersion()) { - case MQTT_5_0: - mqtt5Publish((Mqtt5Client) client, publishOptions, topic, qos); - break; - case MQTT_3_1_1: - mqtt3Publish((Mqtt3Client) client, publishOptions, topic, qos); - break; - } - } - } - - public void disconnect(final @NotNull ClientKey clientKey, final @NotNull DisconnectOptions disconnectOptions) { - final ClientData clientData = clientKeyToClientData.get(clientKey); - if (clientData != null) { - disconnect(clientData.getClient(), disconnectOptions); - } - } - - public void disconnect(final @NotNull MqttClient client, final @NotNull DisconnectOptions disconnectOptions) { - switch (client.getConfig().getMqttVersion()) { - case MQTT_5_0: - mqtt5Disconnect((Mqtt5Client) client, disconnectOptions); - break; - case MQTT_3_1_1: - mqtt3Disconnect((Mqtt3Client) client, disconnectOptions); - break; - } - clientKeyToClientData.remove(ClientKey.of(client)); - } - - public void disconnectAllClients(final @NotNull DisconnectOptions disconnectOptions) { - for (final Map.Entry entry : clientKeyToClientData.entrySet()) { - final MqttClient client = entry.getValue().getClient(); - switch (client.getConfig().getMqttVersion()) { - case MQTT_5_0: - mqtt5Disconnect((Mqtt5Client) client, disconnectOptions); - break; - case MQTT_3_1_1: - mqtt3Disconnect((Mqtt3Client) client, disconnectOptions); - break; - } - } - clientKeyToClientData.clear(); - } - - public void unsubscribe(final @NotNull MqttClient client, final @NotNull UnsubscribeOptions unsubscribeOptions) { - switch (client.getConfig().getMqttVersion()) { - case MQTT_5_0: - mqtt5Unsubscribe((Mqtt5Client) client, unsubscribeOptions); - break; - case MQTT_3_1_1: - mqtt3Unsubscribe((Mqtt3Client) client, unsubscribeOptions); - break; - } - - } - - public boolean isConnected(final @NotNull ClientKey key) { - if (clientKeyToClientData.containsKey(key)) { - final MqttClient client = clientKeyToClientData.get(key).getClient(); - final MqttClientState state = client.getState(); - return state.isConnected(); - } - return false; - } - - private @NotNull Mqtt5Client connectMqtt5Client( - final @NotNull ConnectOptions connectOptions, final @Nullable SubscribeOptions subscribeOptions) - throws Exception { - final MqttClientBuilder clientBuilder = createBuilder(connectOptions); - final Mqtt5Client client = clientBuilder.useMqttVersion5().build(); - final Mqtt5Publish willPublish = createMqtt5WillPublish(connectOptions.getWillOptions()); - final Mqtt5ConnectRestrictions connectRestrictions = - createMqtt5ConnectRestrictions(connectOptions.getConnectRestrictionOptions()); - - final Mqtt5ConnectBuilder connectBuilder = Mqtt5Connect.builder().willPublish(willPublish); - - // Workaround : if the built connect restrictions are the default ones do not append them to the connect builder - // -> Else the connectMessage.toString() method will flood the logging output - if (!connectRestrictions.equals(Mqtt5ConnectRestrictions.builder().build())) { - //noinspection ResultOfMethodCallIgnored - connectBuilder.restrictions(connectRestrictions); - } - - if (connectOptions.getCleanStart() != null) { - //noinspection ResultOfMethodCallIgnored - connectBuilder.cleanStart(connectOptions.getCleanStart()); - } - if (connectOptions.getKeepAlive() != null) { - //noinspection ResultOfMethodCallIgnored - connectBuilder.keepAlive(connectOptions.getKeepAlive()); - } - if (connectOptions.getSessionExpiryInterval() != null) { - //noinspection ResultOfMethodCallIgnored - connectBuilder.sessionExpiryInterval(connectOptions.getSessionExpiryInterval()); - } - if (connectOptions.getConnectUserProperties() != null) { - //noinspection ResultOfMethodCallIgnored - connectBuilder.userProperties(connectOptions.getConnectUserProperties()); - } - - //noinspection ResultOfMethodCallIgnored - connectBuilder.simpleAuth(buildMqtt5Authentication(connectOptions.getAuthenticationOptions())); - - client.toAsync() - .publishes(MqttGlobalPublishFilter.REMAINING, - buildRemainingMqtt5PublishesCallback(subscribeOptions, client)); - - - mqtt5Connect(client, connectBuilder.build()); - - final ClientData clientData = new ClientData(client); - - clientKeyToClientData.put(ClientKey.of(client), clientData); - - return client; - } - - private @NotNull Mqtt3Client connectMqtt3Client( - final @NotNull ConnectOptions connectOptions, final @Nullable SubscribeOptions subscribeOptions) - throws Exception { - final MqttClientBuilder clientBuilder = createBuilder(connectOptions); - final Mqtt3Client client = clientBuilder.useMqttVersion3().build(); - - final Mqtt3Publish willPublish = createMqtt3WillPublish(connectOptions.getWillOptions()); - - final Mqtt3ConnectBuilder connectBuilder = Mqtt3Connect.builder().willPublish(willPublish); - - if (connectOptions.getCleanStart() != null) { - //noinspection ResultOfMethodCallIgnored - connectBuilder.cleanSession(connectOptions.getCleanStart()); - } - if (connectOptions.getKeepAlive() != null) { - //noinspection ResultOfMethodCallIgnored - connectBuilder.keepAlive(connectOptions.getKeepAlive()); - } - - //noinspection ResultOfMethodCallIgnored - connectBuilder.simpleAuth(buildMqtt3Authentication(connectOptions.getAuthenticationOptions())); - - client.toAsync() - .publishes(MqttGlobalPublishFilter.REMAINING, - buildRemainingMqtt3PublishesCallback(subscribeOptions, client)); - - mqtt3Connect(client, connectBuilder.build()); - - final ClientData clientData = new ClientData(client); - - clientKeyToClientData.put(ClientKey.of(client), clientData); - - return client; - } - - private @Nullable Mqtt5Publish createMqtt5WillPublish(final @NotNull WillOptions willOptions) { - // only topic is mandatory for will message creation - if (willOptions.getWillTopic() != null) { - final ByteBuffer willPayload = willOptions.getWillMessage(); - final Mqtt5WillPublishBuilder.Complete builder = Mqtt5WillPublish.builder() - .topic(willOptions.getWillTopic()) - .payload(willPayload) - .qos(Objects.requireNonNull(willOptions.getWillQos())) - .payloadFormatIndicator(willOptions.getWillPayloadFormatIndicator()) - .contentType(willOptions.getWillContentType()) - .responseTopic(willOptions.getWillResponseTopic()) - .correlationData(willOptions.getWillCorrelationData()); - - if (willOptions.getWillRetain() != null) { - //noinspection ResultOfMethodCallIgnored - builder.retain(willOptions.getWillRetain()); - } - if (willOptions.getWillMessageExpiryInterval() != null) { - //noinspection ResultOfMethodCallIgnored - builder.messageExpiryInterval(willOptions.getWillMessageExpiryInterval()); - } - if (willOptions.getWillDelayInterval() != null) { - //noinspection ResultOfMethodCallIgnored - builder.delayInterval(willOptions.getWillDelayInterval()); - } - if (willOptions.getWillUserProperties() != null) { // user Properties can't be completed with null - //noinspection ResultOfMethodCallIgnored - builder.userProperties(willOptions.getWillUserProperties()); - } - return builder.build().asWill(); - } else if (willOptions.getWillMessage() != null) { - Logger.warn("option -wt is missing if a will message is configured - will options were: {} ", - willOptions.toString()); - } - return null; - } - - private @Nullable Mqtt3Publish createMqtt3WillPublish(final @NotNull WillOptions willOptions) { - if (willOptions.getWillTopic() != null) { - final ByteBuffer willPayload = willOptions.getWillMessage(); - final Mqtt3PublishBuilder.Complete builder = Mqtt3Publish.builder() - .topic(willOptions.getWillTopic()) - .payload(willPayload) - .qos(Objects.requireNonNull(willOptions.getWillQos())); - - if (willOptions.getWillRetain() != null) { - //noinspection ResultOfMethodCallIgnored - builder.retain(willOptions.getWillRetain()); - } - return builder.build(); - } else if (willOptions.getWillMessage() != null) { - Logger.warn("option -wt is missing if a will message is configured - will options were: {} ", - willOptions.toString()); - } - return null; - } - - private @NotNull Mqtt5ConnectRestrictions createMqtt5ConnectRestrictions(final @NotNull ConnectRestrictionOptions connectRestrictionOptions) { - final Mqtt5ConnectRestrictionsBuilder restrictionsBuilder = Mqtt5ConnectRestrictions.builder(); - - if (connectRestrictionOptions.getReceiveMaximum() != null) { - //noinspection ResultOfMethodCallIgnored - restrictionsBuilder.receiveMaximum(connectRestrictionOptions.getReceiveMaximum()); - } - if (connectRestrictionOptions.getSendMaximum() != null) { - //noinspection ResultOfMethodCallIgnored - restrictionsBuilder.sendMaximum(connectRestrictionOptions.getSendMaximum()); - } - if (connectRestrictionOptions.getMaximumPacketSize() != null) { - //noinspection ResultOfMethodCallIgnored - restrictionsBuilder.maximumPacketSize(connectRestrictionOptions.getMaximumPacketSize()); - } - if (connectRestrictionOptions.getSendMaximumPacketSize() != null) { - //noinspection ResultOfMethodCallIgnored - restrictionsBuilder.sendMaximumPacketSize(connectRestrictionOptions.getSendMaximumPacketSize()); - } - if (connectRestrictionOptions.getTopicAliasMaximum() != null) { - //noinspection ResultOfMethodCallIgnored - restrictionsBuilder.topicAliasMaximum(connectRestrictionOptions.getTopicAliasMaximum()); - } - if (connectRestrictionOptions.getSendTopicAliasMaximum() != null) { - //noinspection ResultOfMethodCallIgnored - restrictionsBuilder.sendTopicAliasMaximum(connectRestrictionOptions.getSendTopicAliasMaximum()); - } - if (connectRestrictionOptions.getRequestProblemInformation() != null) { - //noinspection ResultOfMethodCallIgnored - restrictionsBuilder.requestProblemInformation(connectRestrictionOptions.getRequestProblemInformation()); - } - if (connectRestrictionOptions.getRequestResponseInformation() != null) { - //noinspection ResultOfMethodCallIgnored - restrictionsBuilder.requestResponseInformation(connectRestrictionOptions.getRequestResponseInformation()); - } - return restrictionsBuilder.build(); - } - - private @NotNull MqttClientBuilder createBuilder(final @NotNull ConnectOptions connectOptions) throws Exception { - return MqttClient.builder() - .addDisconnectedListener(new ContextClientDisconnectListener()) - .webSocketConfig(connectOptions.getWebSocketConfig()) - .serverHost(connectOptions.getHost()) - .serverPort(connectOptions.getPort()) - .sslConfig(connectOptions.buildSslConfig()) - .identifier(connectOptions.getIdentifier()); - } - - private @Nullable Mqtt5SimpleAuth buildMqtt5Authentication(final @NotNull AuthenticationOptions authenticationOptions) { - if (authenticationOptions.getUser() != null && authenticationOptions.getPassword() != null) { - return Mqtt5SimpleAuth.builder() - .username(authenticationOptions.getUser()) - .password(authenticationOptions.getPassword()) - .build(); - } else if (authenticationOptions.getPassword() != null) { - return Mqtt5SimpleAuth.builder().password(authenticationOptions.getPassword()).build(); - } else if (authenticationOptions.getUser() != null) { - return Mqtt5SimpleAuth.builder().username(authenticationOptions.getUser()).build(); - } - return null; - } - - private @Nullable Mqtt3SimpleAuth buildMqtt3Authentication(final @NotNull AuthenticationOptions authenticationOptions) { - if (authenticationOptions.getUser() != null && authenticationOptions.getPassword() != null) { - return Mqtt3SimpleAuth.builder() - .username(authenticationOptions.getUser()) - .password(authenticationOptions.getPassword()) - .build(); - } else if (authenticationOptions.getUser() != null) { - return Mqtt3SimpleAuth.builder().username(authenticationOptions.getUser()).build(); - } else if (authenticationOptions.getPassword() != null) { - throw new IllegalArgumentException("Password-Only Authentication is not allowed in MQTT 3"); - } - return null; - } - - public static @NotNull Map getClientDataMap() { - return clientKeyToClientData; - } - - public @Nullable MqttClient getMqttClient(final @NotNull ClientKey clientKey) { - MqttClient client = null; - - if (clientKeyToClientData.containsKey(clientKey)) { - client = clientKeyToClientData.get(clientKey).getClient(); - } - - return client; - } - - private @NotNull Consumer buildRemainingMqtt5PublishesCallback( - final @Nullable SubscribeOptions subscribeOptions, final @NotNull Mqtt5Client client) { - if (subscribeOptions != null) { - return new SubscribeMqtt5PublishCallback(subscribeOptions, client); - } else { - return mqtt5Publish -> Logger.debug("received PUBLISH: {}, MESSAGE: '{}'", - mqtt5Publish, - new String(mqtt5Publish.getPayloadAsBytes(), StandardCharsets.UTF_8)); - } - } - - private @NotNull Consumer buildRemainingMqtt3PublishesCallback( - final @Nullable SubscribeOptions subscribeOptions, final @NotNull Mqtt3Client client) { - if (subscribeOptions != null) { - return new SubscribeMqtt3PublishCallback(subscribeOptions, client); - } else { - return mqtt3Publish -> Logger.debug("received PUBLISH: {}, MESSAGE: '{}'", - mqtt3Publish, - new String(mqtt3Publish.getPayloadAsBytes(), StandardCharsets.UTF_8)); - } - } - -} diff --git a/src/main/java/com/hivemq/cli/mqtt/ClientData.java b/src/main/java/com/hivemq/cli/mqtt/ClientData.java deleted file mode 100644 index c0f0cdf96..000000000 --- a/src/main/java/com/hivemq/cli/mqtt/ClientData.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2019-present HiveMQ and the HiveMQ Community - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.hivemq.cli.mqtt; - -import com.hivemq.client.mqtt.MqttClient; -import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; -import org.jetbrains.annotations.NotNull; - -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; - -public class ClientData { - - private final @NotNull MqttClient mqttClient; - private final @NotNull LocalDateTime creationTime; - private final @NotNull Set subscribedTopics; - - public ClientData(final @NotNull MqttClient mqttClient) { - this.mqttClient = mqttClient; - this.creationTime = LocalDateTime.now(); - this.subscribedTopics = new HashSet<>(); - } - - public void addSubscription(final @NotNull MqttTopicFilter topic) { - subscribedTopics.add(topic); - } - - public void removeSubscription(final @NotNull MqttTopicFilter topic) { - subscribedTopics.remove(topic); - } - - public void removeAllSubscriptions() { - subscribedTopics.clear(); - } - - public @NotNull LocalDateTime getCreationTime() { - return creationTime; - } - - public @NotNull Set getSubscribedTopics() { - return subscribedTopics; - } - - public @NotNull MqttClient getClient() { - return this.mqttClient; - } -} diff --git a/src/main/java/com/hivemq/cli/mqtt/ClientKey.java b/src/main/java/com/hivemq/cli/mqtt/ClientKey.java deleted file mode 100644 index 5ad8f4c79..000000000 --- a/src/main/java/com/hivemq/cli/mqtt/ClientKey.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2019-present HiveMQ and the HiveMQ Community - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.hivemq.cli.mqtt; - -import com.hivemq.client.mqtt.MqttClient; -import com.hivemq.client.mqtt.MqttClientConfig; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Objects; - -public class ClientKey { - - private final @Nullable String clientIdentifier; - private final @NotNull String hostname; - - private ClientKey(final @Nullable String clientIdentifier, final @NotNull String hostname) { - this.clientIdentifier = clientIdentifier; - this.hostname = hostname; - } - - public static ClientKey of(final @Nullable String clientIdentifier, final @NotNull String hostname) { - return new ClientKey(clientIdentifier, hostname); - } - - public static ClientKey of(final @NotNull MqttClient client) { - return new ClientKey(client.getConfig().getClientIdentifier().map(Objects::toString).orElse(""), - client.getConfig().getServerHost()); - } - - public static ClientKey of(final @NotNull MqttClientConfig clientConfig) { - return new ClientKey(clientConfig.getClientIdentifier().map(Objects::toString).orElse(""), - clientConfig.getServerHost()); - } - - @Override - public boolean equals(final @Nullable Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - final ClientKey clientKey = (ClientKey) o; - return Objects.equals(clientIdentifier, clientKey.clientIdentifier) && hostname.equals(clientKey.hostname); - } - - @Override - public int hashCode() { - return Objects.hash(clientIdentifier, hostname); - } - - @Override - public @NotNull String toString() { - return "client{" + "clientIdentifier='" + clientIdentifier + '\'' + ", hostname='" + hostname + '\'' + '}'; - } -} diff --git a/src/main/java/com/hivemq/cli/mqtt/ContextClientDisconnectListener.java b/src/main/java/com/hivemq/cli/mqtt/ContextClientDisconnectListener.java deleted file mode 100644 index 1e0a489ba..000000000 --- a/src/main/java/com/hivemq/cli/mqtt/ContextClientDisconnectListener.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2019-present HiveMQ and the HiveMQ Community - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.hivemq.cli.mqtt; - -import com.google.common.base.Throwables; -import com.hivemq.cli.commands.shell.ShellCommand; -import com.hivemq.cli.commands.shell.ShellContextCommand; -import com.hivemq.cli.utils.LoggerUtils; -import com.hivemq.client.mqtt.MqttClientConfig; -import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedContext; -import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener; -import com.hivemq.client.mqtt.lifecycle.MqttDisconnectSource; -import org.jetbrains.annotations.NotNull; -import org.tinylog.Logger; - -import java.util.Objects; - -public class ContextClientDisconnectListener implements MqttClientDisconnectedListener { - - @Override - public void onDisconnected(final @NotNull MqttClientDisconnectedContext context) { - if (context.getSource() != MqttDisconnectSource.USER) { - final Throwable cause = context.getCause(); - - Logger.debug(cause, - "{} DISCONNECTED {}", - LoggerUtils.getClientPrefix(context.getClientConfig()), - Throwables.getRootCause(cause).getMessage()); - - // If the currently active shell client gets disconnected from the server prompt the user to enter - if (contextEqualsShellContext(context)) { - Logger.error(cause, Throwables.getRootCause(cause).getMessage()); - ShellContextCommand.removeContext(); - Objects.requireNonNull(ShellCommand.TERMINAL_WRITER).printf("Press ENTER to resume: "); - ShellCommand.TERMINAL_WRITER.flush(); - } - } else if (contextEqualsShellContext(context)) { - ShellContextCommand.removeContext(); - } - MqttClientExecutor.getClientDataMap().remove(ClientKey.of(context.getClientConfig())); - } - - private boolean contextEqualsShellContext(final @NotNull MqttClientDisconnectedContext context) { - final MqttClientConfig clientConfig = context.getClientConfig(); - final MqttClientConfig shellClientConfig = - Objects.requireNonNull(ShellContextCommand.contextClient).getConfig(); - - return clientConfig.getClientIdentifier().equals(shellClientConfig.getClientIdentifier()) && - clientConfig.getServerHost().equals(shellClientConfig.getServerHost()); - } -} diff --git a/src/main/java/com/hivemq/cli/mqtt/MqttClientExecutor.java b/src/main/java/com/hivemq/cli/mqtt/MqttClientExecutor.java deleted file mode 100644 index 25f8e6da3..000000000 --- a/src/main/java/com/hivemq/cli/mqtt/MqttClientExecutor.java +++ /dev/null @@ -1,318 +0,0 @@ -/* - * Copyright 2019-present HiveMQ and the HiveMQ Community - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.hivemq.cli.mqtt; - -import com.google.common.base.Throwables; -import com.hivemq.cli.commands.options.DisconnectOptions; -import com.hivemq.cli.commands.options.PublishOptions; -import com.hivemq.cli.commands.options.SubscribeOptions; -import com.hivemq.cli.commands.options.UnsubscribeOptions; -import com.hivemq.cli.utils.LoggerUtils; -import com.hivemq.client.mqtt.datatypes.MqttQos; -import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; -import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; -import com.hivemq.client.mqtt.mqtt3.message.connect.Mqtt3Connect; -import com.hivemq.client.mqtt.mqtt3.message.connect.connack.Mqtt3ConnAck; -import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; -import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3PublishBuilder; -import com.hivemq.client.mqtt.mqtt3.message.subscribe.Mqtt3Subscribe; -import com.hivemq.client.mqtt.mqtt3.message.subscribe.Mqtt3SubscribeBuilder; -import com.hivemq.client.mqtt.mqtt3.message.unsubscribe.Mqtt3Unsubscribe; -import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; -import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect; -import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAck; -import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5Disconnect; -import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5DisconnectBuilder; -import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; -import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5PublishBuilder; -import com.hivemq.client.mqtt.mqtt5.message.subscribe.Mqtt5Subscribe; -import com.hivemq.client.mqtt.mqtt5.message.subscribe.Mqtt5SubscribeBuilder; -import com.hivemq.client.mqtt.mqtt5.message.unsubscribe.Mqtt5Unsubscribe; -import org.jetbrains.annotations.NotNull; -import org.tinylog.Logger; - -import javax.inject.Inject; -import javax.inject.Singleton; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; - -@Singleton -public class MqttClientExecutor extends AbstractMqttClientExecutor { - - @Inject - MqttClientExecutor() { - } - - void mqtt5Connect( - final @NotNull Mqtt5Client client, final @NotNull Mqtt5Connect connectMessage) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - - Logger.debug("{} sending CONNECT {}", clientLogPrefix, connectMessage); - - final Mqtt5ConnAck connAck = client.toBlocking().connect(connectMessage); - - Logger.debug("{} received CONNACK {} ", clientLogPrefix, connAck); - } - - void mqtt3Connect( - final @NotNull Mqtt3Client client, final @NotNull Mqtt3Connect connectMessage) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - - Logger.debug("{} sending CONNECT {}", clientLogPrefix, connectMessage); - - final Mqtt3ConnAck connAck = client.toBlocking().connect(connectMessage); - - Logger.debug("{} received CONNACK {} ", clientLogPrefix, connAck); - } - - void mqtt5Subscribe( - final @NotNull Mqtt5Client client, - final @NotNull SubscribeOptions subscribeOptions, - final @NotNull String topic, - final @NotNull MqttQos qos) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - final Mqtt5SubscribeBuilder.Start.Complete builder = Mqtt5Subscribe.builder().topicFilter(topic).qos(qos); - - if (subscribeOptions.getUserProperties() != null) { - //noinspection ResultOfMethodCallIgnored - builder.userProperties(subscribeOptions.getUserProperties()); - } - - final Mqtt5Subscribe subscribeMessage = builder.build(); - - Logger.debug("{} sending SUBSCRIBE {}", clientLogPrefix, subscribeMessage); - - client.toAsync() - .subscribe(subscribeMessage, new SubscribeMqtt5PublishCallback(subscribeOptions, client)) - .whenComplete((subAck, throwable) -> { - if (throwable != null) { - Logger.error(throwable, - "{} failed SUBSCRIBE to TOPIC '{}': {}", - clientLogPrefix, - topic, - Throwables.getRootCause(throwable).getMessage()); - } else { - final ClientKey clientKey = ClientKey.of(client); - getClientDataMap().get(clientKey).addSubscription(MqttTopicFilter.of(topic)); - Logger.debug("{} received SUBACK {}", clientLogPrefix, subAck); - } - }) - .join(); - } - - void mqtt3Subscribe( - final @NotNull Mqtt3Client client, - final @NotNull SubscribeOptions subscribeOptions, - final @NotNull String topic, - final @NotNull MqttQos qos) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - final Mqtt3SubscribeBuilder.Start.Complete builder = Mqtt3Subscribe.builder().topicFilter(topic).qos(qos); - final Mqtt3Subscribe subscribeMessage = builder.build(); - - Logger.debug("{} sending SUBSCRIBE {}", clientLogPrefix, subscribeMessage); - - client.toAsync() - .subscribe(subscribeMessage, new SubscribeMqtt3PublishCallback(subscribeOptions, client)) - .whenComplete((subAck, throwable) -> { - if (throwable != null) { - Logger.error(throwable, - "{} failed SUBSCRIBE to TOPIC '{}': {}", - clientLogPrefix, - topic, - Throwables.getRootCause(throwable).getMessage()); - } else { - getClientDataMap().get(ClientKey.of(client)).addSubscription(MqttTopicFilter.of(topic)); - - Logger.debug("{} received SUBACK {}", clientLogPrefix, subAck); - } - }) - .join(); - } - - void mqtt5Publish( - final @NotNull Mqtt5Client client, - final @NotNull PublishOptions publishOptions, - final @NotNull String topic, - final @NotNull MqttQos qos) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - - final Mqtt5PublishBuilder.Complete publishBuilder = Mqtt5Publish.builder() - .topic(topic) - .qos(qos) - .payload(publishOptions.getMessage()) - .payloadFormatIndicator(publishOptions.getPayloadFormatIndicator()) - .contentType(publishOptions.getContentType()) - .responseTopic(publishOptions.getResponseTopic()) - .correlationData(publishOptions.getCorrelationData()); - - if (publishOptions.getRetain() != null) { - //noinspection ResultOfMethodCallIgnored - publishBuilder.retain(publishOptions.getRetain()); - } - if (publishOptions.getMessageExpiryInterval() != null) { - //noinspection ResultOfMethodCallIgnored - publishBuilder.messageExpiryInterval(publishOptions.getMessageExpiryInterval()); - } - if (publishOptions.getUserProperties() != null) { - //noinspection ResultOfMethodCallIgnored - publishBuilder.userProperties(publishOptions.getUserProperties()); - } - - final Mqtt5Publish publishMessage = publishBuilder.build(); - - Logger.debug("{} sending PUBLISH ('{}') {}", - clientLogPrefix, - bufferToString(publishOptions.getMessage()), - publishMessage); - - client.toAsync().publish(publishMessage).whenComplete((publishResult, throwable) -> { - if (throwable != null) { - Logger.error(throwable, - "{} failed PUBLISH to TOPIC '{}': {}", - clientLogPrefix, - topic, - Throwables.getRootCause(throwable).getMessage()); - } else { - Logger.debug("{} received PUBLISH acknowledgement {}", clientLogPrefix, publishResult); - } - }).join(); - } - - void mqtt3Publish( - final @NotNull Mqtt3Client client, - final @NotNull PublishOptions publishOptions, - final @NotNull String topic, - final @NotNull MqttQos qos) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - - final Mqtt3PublishBuilder.Complete publishBuilder = - Mqtt3Publish.builder().topic(topic).qos(qos).payload(publishOptions.getMessage()); - - if (publishOptions.getRetain() != null) { - //noinspection ResultOfMethodCallIgnored - publishBuilder.retain(publishOptions.getRetain()); - } - - final Mqtt3Publish publishMessage = publishBuilder.build(); - - Logger.debug("{} sending PUBLISH ('{}') {}", - clientLogPrefix, - bufferToString(publishOptions.getMessage()), - publishMessage); - - client.toAsync().publish(publishMessage).whenComplete((publishResult, throwable) -> { - if (throwable != null) { - Logger.error(throwable, - "{} failed PUBLISH to TOPIC '{}': {}", - clientLogPrefix, - topic, - Throwables.getRootCause(throwable).getMessage()); - } else { - Logger.debug("{} received PUBLISH acknowledgement {}", clientLogPrefix, publishResult); - } - }).join(); - } - - @Override - void mqtt5Unsubscribe(final @NotNull Mqtt5Client client, final @NotNull UnsubscribeOptions unsubscribeOptions) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - - for (final String topic : unsubscribeOptions.getTopics()) { - final Mqtt5Unsubscribe unsubscribeMessage = Mqtt5Unsubscribe.builder() - .topicFilter(topic) - .userProperties(unsubscribeOptions.getUserProperties()) - .build(); - - Logger.debug("{} sending UNSUBSCRIBE {}", clientLogPrefix, unsubscribeMessage); - - client.toAsync().unsubscribe(unsubscribeMessage).whenComplete((unsubAck, throwable) -> { - if (throwable != null) { - - Logger.error(throwable, - "{} failed UNSUBSCRIBE from TOPIC '{}': {}", - clientLogPrefix, - topic, - Throwables.getRootCause(throwable).getMessage()); - } else { - getClientDataMap().get(ClientKey.of(client)).removeSubscription(MqttTopicFilter.of(topic)); - - Logger.debug("{} received UNSUBACK {}", clientLogPrefix, unsubAck); - } - }).join(); - } - } - - @Override - void mqtt3Unsubscribe(final @NotNull Mqtt3Client client, final @NotNull UnsubscribeOptions unsubscribeOptions) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - - for (final String topic : unsubscribeOptions.getTopics()) { - final Mqtt3Unsubscribe unsubscribeMessage = Mqtt3Unsubscribe.builder().topicFilter(topic).build(); - - Logger.debug("{} sending UNSUBSCRIBE {}", clientLogPrefix, unsubscribeMessage); - - client.toAsync().unsubscribe(unsubscribeMessage).whenComplete((unsubAck, throwable) -> { - if (throwable != null) { - Logger.error(throwable, - "{} failed UNSUBSCRIBE from TOPIC '{}': {}", - clientLogPrefix, - topic, - Throwables.getRootCause(throwable).getMessage()); - } else { - getClientDataMap().get(ClientKey.of(client)).removeSubscription(MqttTopicFilter.of(topic)); - Logger.debug("{} received UNSUBACK", clientLogPrefix); - } - }).join(); - } - } - - @Override - void mqtt5Disconnect(final @NotNull Mqtt5Client client, final @NotNull DisconnectOptions disconnectOptions) { - final String clientLogPrefix = LoggerUtils.getClientPrefix(client.getConfig()); - final Mqtt5DisconnectBuilder disconnectBuilder = Mqtt5Disconnect.builder(); - - if (disconnectOptions.getReasonString() != null) { - //noinspection ResultOfMethodCallIgnored - disconnectBuilder.reasonString(disconnectOptions.getReasonString()); - } - if (disconnectOptions.getSessionExpiryInterval() != null) { - //noinspection ResultOfMethodCallIgnored - disconnectBuilder.sessionExpiryInterval(disconnectOptions.getSessionExpiryInterval()); - } - if (disconnectOptions.getUserProperties() != null) { - //noinspection ResultOfMethodCallIgnored - disconnectBuilder.userProperties(disconnectOptions.getUserProperties()); - } - - final Mqtt5Disconnect disconnectMessage = disconnectBuilder.build(); - - Logger.debug("{} sending DISCONNECT {}", clientLogPrefix, disconnectMessage); - - client.toBlocking().disconnect(disconnectMessage); - } - - @Override - void mqtt3Disconnect(final @NotNull Mqtt3Client client, final @NotNull DisconnectOptions disconnectOptions) { - Logger.debug("{} sending DISCONNECT", LoggerUtils.getClientPrefix(client.getConfig())); - - client.toBlocking().disconnect(); - } - - private @NotNull String bufferToString(final @NotNull ByteBuffer b) { - return new String(b.array(), StandardCharsets.UTF_8); - } -} diff --git a/src/main/java/com/hivemq/cli/mqtt/clients/CliMqttClient.java b/src/main/java/com/hivemq/cli/mqtt/clients/CliMqttClient.java new file mode 100644 index 000000000..40a4996c3 --- /dev/null +++ b/src/main/java/com/hivemq/cli/mqtt/clients/CliMqttClient.java @@ -0,0 +1,64 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients; + +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.DisconnectOptions; +import com.hivemq.cli.commands.options.PublishOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.UnsubscribeOptions; +import com.hivemq.cli.mqtt.clients.listeners.LogDisconnectedListener; +import com.hivemq.client.mqtt.MqttClientState; +import com.hivemq.client.mqtt.MqttVersion; +import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; +import org.jetbrains.annotations.NotNull; + +import java.time.LocalDateTime; +import java.util.List; + +public interface CliMqttClient { + + static @NotNull ConnectBuilder connectWith(final @NotNull ConnectOptions connectOptions) { + return new ConnectBuilder(connectOptions) + .addDisconnectedListener(LogDisconnectedListener.INSTANCE); + } + + void publish(final @NotNull PublishOptions publishOptions); + + void subscribe(final @NotNull SubscribeOptions subscribeOptions); + + void unsubscribe(final @NotNull UnsubscribeOptions unsubscribeOptions); + + void disconnect(final @NotNull DisconnectOptions disconnectOptions); + + boolean isConnected(); + + @NotNull String getClientIdentifier(); + + @NotNull String getServerHost(); + + @NotNull MqttVersion getMqttVersion(); + + @NotNull LocalDateTime getConnectedAt(); + + @NotNull MqttClientState getState(); + + @NotNull String getSslProtocols(); + + int getServerPort(); + + @NotNull List getSubscribedTopics(); +} diff --git a/src/main/java/com/hivemq/cli/mqtt/clients/ConnectBuilder.java b/src/main/java/com/hivemq/cli/mqtt/clients/ConnectBuilder.java new file mode 100644 index 000000000..fbdbbcc43 --- /dev/null +++ b/src/main/java/com/hivemq/cli/mqtt/clients/ConnectBuilder.java @@ -0,0 +1,65 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients; + +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.mqtt.clients.mqtt3.CliMqtt3Client; +import com.hivemq.cli.mqtt.clients.mqtt3.CliMqtt3ClientFactory; +import com.hivemq.cli.mqtt.clients.mqtt5.CliMqtt5Client; +import com.hivemq.cli.mqtt.clients.mqtt5.CliMqtt5ClientFactory; +import com.hivemq.client.mqtt.MqttVersion; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +public class ConnectBuilder { + + final @NotNull ConnectOptions connectOptions; + final @NotNull List disconnectedListeners = new ArrayList<>(); + @Nullable SubscribeOptions subscribeOptions; + + public ConnectBuilder(final @NotNull ConnectOptions connectOptions) { + this.connectOptions = connectOptions; + } + + public @NotNull ConnectBuilder subscribeOptions(final @NotNull SubscribeOptions subscribeOptions) { + this.subscribeOptions = subscribeOptions; + return this; + } + + public @NotNull ConnectBuilder addDisconnectedListener(final @NotNull MqttClientDisconnectedListener disconnectedListener) { + this.disconnectedListeners.add(disconnectedListener); + return this; + } + + public @NotNull CliMqttClient send() throws Exception { + if (connectOptions.getVersion() == MqttVersion.MQTT_5_0) { + final CliMqtt5Client + client = CliMqtt5ClientFactory.create(connectOptions, subscribeOptions, disconnectedListeners); + client.connect(connectOptions); + return client; + } else { + final CliMqtt3Client + client = CliMqtt3ClientFactory.create(connectOptions, subscribeOptions, disconnectedListeners); + client.connect(connectOptions); + return client; + } + } +} diff --git a/src/main/java/com/hivemq/cli/mqtt/clients/ShellClients.java b/src/main/java/com/hivemq/cli/mqtt/clients/ShellClients.java new file mode 100644 index 000000000..46cc03a15 --- /dev/null +++ b/src/main/java/com/hivemq/cli/mqtt/clients/ShellClients.java @@ -0,0 +1,158 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients; + +import com.google.common.base.Throwables; +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.DisconnectOptions; +import com.hivemq.cli.commands.shell.ShellCommand; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedContext; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener; +import com.hivemq.client.mqtt.lifecycle.MqttDisconnectSource; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.tinylog.Logger; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Singleton +public class ShellClients { + + private final @NotNull Map> hostToClients = new ConcurrentHashMap<>(); + private final @NotNull CopyOnWriteArrayList> contextClientChangedListeners = new CopyOnWriteArrayList<>(); + private @Nullable CliMqttClient contextClient; + + @Inject + public ShellClients() { + } + + public @NotNull CliMqttClient connect(final @NotNull ConnectOptions connectOptions) throws Exception { + final CliMqttClient client = CliMqttClient.connectWith(connectOptions) + .addDisconnectedListener(new UpdateContextClientDisconnectedListener()) + .send(); + hostToClients.putIfAbsent(client.getServerHost(), new ConcurrentHashMap<>()); + final Map idToClient = hostToClients.get(client.getServerHost()); + idToClient.put(client.getClientIdentifier(), client); + return client; + } + + public void disconnectAllClients(final @NotNull DisconnectOptions disconnectOptions) { + hostToClients.values() + .stream() + .flatMap(idToClient -> idToClient.values().stream()) + .forEach(client -> client.disconnect(disconnectOptions)); + hostToClients.clear(); + } + + public void disconnect(final @NotNull DisconnectOptions disconnectOptions) { + if (disconnectOptions.getClientIdentifier() == null) { + return; + } + final CliMqttClient client = getClient(disconnectOptions.getClientIdentifier(), disconnectOptions.getHost()); + if (client == null) { + return; + } + client.disconnect(disconnectOptions); + } + + public @Nullable CliMqttClient getClient(final @NotNull String identifier, final @Nullable String serverHost) { + if (serverHost != null) { + final Map idToClient = + hostToClients.getOrDefault(serverHost, Collections.emptyMap()); + return idToClient.get(identifier); + } else { + return hostToClients.values() + .stream() + .flatMap(idToClient -> idToClient.values().stream()) + .filter(client -> client.getClientIdentifier().equals(identifier)) + .findFirst() + .orElse(null); + } + } + + public @NotNull List listClients(final @NotNull Comparator comparator) { + return hostToClients.values() + .stream() + .flatMap(idToClient -> idToClient.values().stream()) + .sorted(comparator) + .collect(Collectors.toList()); + } + + public void addContextClientChangedListener(final @NotNull Consumer consumer) { + this.contextClientChangedListeners.add(consumer); + } + + public void updateContextClient(final @NotNull CliMqttClient client) { + if (client.isConnected()) { + this.contextClient = client; + for (final Consumer contextClientChangedListener : contextClientChangedListeners) { + contextClientChangedListener.accept(client); + } + } + } + + public void removeContextClient() { + this.contextClient = null; + for (final Consumer contextClientChangedListener : contextClientChangedListeners) { + contextClientChangedListener.accept(null); + } + } + + public @Nullable CliMqttClient getContextClient() { + return contextClient; + } + + private boolean isContextClient(final @NotNull String clientId, final @NotNull String host) { + if (contextClient == null) { + return false; + } + return contextClient.getClientIdentifier().equals(clientId) && contextClient.getServerHost().equals(host); + } + + private class UpdateContextClientDisconnectedListener implements MqttClientDisconnectedListener { + + @Override + public void onDisconnected(@NotNull final MqttClientDisconnectedContext context) { + final String clientId = context.getClientConfig().getClientIdentifier().map(Objects::toString).orElse(""); + final String serverHost = context.getClientConfig().getServerHost(); + if (context.getSource() != MqttDisconnectSource.USER) { + if (isContextClient(clientId, serverHost)) { + Logger.error(context.getCause(), Throwables.getRootCause(context.getCause()).getMessage()); + removeContextClient(); + // TODO refactor static variable + Objects.requireNonNull(ShellCommand.TERMINAL_WRITER).printf("Press ENTER to resume: "); + ShellCommand.TERMINAL_WRITER.flush(); + } + } else if (isContextClient(clientId, serverHost)) { + removeContextClient(); + } + + final Map idToClient = + hostToClients.getOrDefault(serverHost, Collections.emptyMap()); + idToClient.remove(clientId); + } + } +} diff --git a/src/main/java/com/hivemq/cli/mqtt/clients/listeners/LogDisconnectedListener.java b/src/main/java/com/hivemq/cli/mqtt/clients/listeners/LogDisconnectedListener.java new file mode 100644 index 000000000..bed9fd46f --- /dev/null +++ b/src/main/java/com/hivemq/cli/mqtt/clients/listeners/LogDisconnectedListener.java @@ -0,0 +1,44 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.listeners; + +import com.google.common.base.Throwables; +import com.hivemq.cli.utils.LoggerUtils; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedContext; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener; +import com.hivemq.client.mqtt.lifecycle.MqttDisconnectSource; +import org.jetbrains.annotations.NotNull; +import org.tinylog.Logger; + +public class LogDisconnectedListener implements MqttClientDisconnectedListener { + + public static final @NotNull LogDisconnectedListener INSTANCE = new LogDisconnectedListener(); + + private LogDisconnectedListener() { + } + + @Override + public void onDisconnected(@NotNull final MqttClientDisconnectedContext context) { + if (context.getSource() != MqttDisconnectSource.USER) { + final Throwable cause = context.getCause(); + + Logger.debug(cause, + "{} DISCONNECTED {}", + LoggerUtils.getClientPrefix(context.getClientConfig()), + Throwables.getRootCause(cause).getMessage()); + } + } +} diff --git a/src/main/java/com/hivemq/cli/mqtt/SubscribeMqtt3PublishCallback.java b/src/main/java/com/hivemq/cli/mqtt/clients/listeners/SubscribeMqtt3PublishCallback.java similarity index 93% rename from src/main/java/com/hivemq/cli/mqtt/SubscribeMqtt3PublishCallback.java rename to src/main/java/com/hivemq/cli/mqtt/clients/listeners/SubscribeMqtt3PublishCallback.java index 7d0ba4401..761cc7c1e 100644 --- a/src/main/java/com/hivemq/cli/mqtt/SubscribeMqtt3PublishCallback.java +++ b/src/main/java/com/hivemq/cli/mqtt/clients/listeners/SubscribeMqtt3PublishCallback.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.hivemq.cli.mqtt; +package com.hivemq.cli.mqtt.clients.listeners; import com.hivemq.cli.commands.options.SubscribeOptions; import com.hivemq.cli.utils.LoggerUtils; @@ -39,7 +39,9 @@ public class SubscribeMqtt3PublishCallback implements Consumer { private final boolean isJsonOutput; private final boolean showTopics; - SubscribeMqtt3PublishCallback(final @NotNull SubscribeOptions subscribeOptions, final @NotNull Mqtt3Client client) { + public SubscribeMqtt3PublishCallback( + final @NotNull SubscribeOptions subscribeOptions, + final @NotNull Mqtt3Client client) { printToStdout = subscribeOptions.isPrintToSTDOUT(); outputFile = subscribeOptions.getOutputFile(); isBase64 = subscribeOptions.isBase64(); diff --git a/src/main/java/com/hivemq/cli/mqtt/SubscribeMqtt5PublishCallback.java b/src/main/java/com/hivemq/cli/mqtt/clients/listeners/SubscribeMqtt5PublishCallback.java similarity index 93% rename from src/main/java/com/hivemq/cli/mqtt/SubscribeMqtt5PublishCallback.java rename to src/main/java/com/hivemq/cli/mqtt/clients/listeners/SubscribeMqtt5PublishCallback.java index 6b37a4c51..db620e0d0 100644 --- a/src/main/java/com/hivemq/cli/mqtt/SubscribeMqtt5PublishCallback.java +++ b/src/main/java/com/hivemq/cli/mqtt/clients/listeners/SubscribeMqtt5PublishCallback.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.hivemq.cli.mqtt; +package com.hivemq.cli.mqtt.clients.listeners; import com.hivemq.cli.commands.options.SubscribeOptions; import com.hivemq.cli.utils.LoggerUtils; @@ -39,7 +39,9 @@ public class SubscribeMqtt5PublishCallback implements Consumer { private final boolean isJsonOutput; private final boolean showTopics; - SubscribeMqtt5PublishCallback(final @NotNull SubscribeOptions subscribeOptions, final @NotNull Mqtt5Client client) { + public SubscribeMqtt5PublishCallback( + final @NotNull SubscribeOptions subscribeOptions, + final @NotNull Mqtt5Client client) { printToStdout = subscribeOptions.isPrintToSTDOUT(); outputFile = subscribeOptions.getOutputFile(); isBase64 = subscribeOptions.isBase64(); diff --git a/src/main/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3Client.java b/src/main/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3Client.java new file mode 100644 index 000000000..bbafd6f34 --- /dev/null +++ b/src/main/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3Client.java @@ -0,0 +1,296 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.mqtt3; + +import com.google.common.base.Throwables; +import com.hivemq.cli.commands.options.AuthenticationOptions; +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.DisconnectOptions; +import com.hivemq.cli.commands.options.PublishOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.UnsubscribeOptions; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.listeners.SubscribeMqtt3PublishCallback; +import com.hivemq.cli.utils.LoggerUtils; +import com.hivemq.client.internal.util.collections.ImmutableList; +import com.hivemq.client.mqtt.MqttClientSslConfig; +import com.hivemq.client.mqtt.MqttClientState; +import com.hivemq.client.mqtt.MqttVersion; +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; +import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; +import com.hivemq.client.mqtt.mqtt3.message.auth.Mqtt3SimpleAuth; +import com.hivemq.client.mqtt.mqtt3.message.connect.Mqtt3Connect; +import com.hivemq.client.mqtt.mqtt3.message.connect.Mqtt3ConnectBuilder; +import com.hivemq.client.mqtt.mqtt3.message.connect.connack.Mqtt3ConnAck; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3PublishBuilder; +import com.hivemq.client.mqtt.mqtt3.message.subscribe.Mqtt3Subscribe; +import com.hivemq.client.mqtt.mqtt3.message.subscribe.Mqtt3Subscription; +import com.hivemq.client.mqtt.mqtt3.message.unsubscribe.Mqtt3Unsubscribe; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.tinylog.Logger; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +public class CliMqtt3Client implements CliMqttClient { + + private final @NotNull Mqtt3Client delegate; + private final @NotNull List subscriptions = new CopyOnWriteArrayList<>(); + private @Nullable LocalDateTime connectedAt; + + public CliMqtt3Client(final @NotNull Mqtt3Client delegate) { + this.delegate = delegate; + } + + public void connect(final @NotNull ConnectOptions connectOptions) { + final Mqtt3Connect connect = buildConnect(connectOptions); + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + Logger.debug("{} sending CONNECT {}", clientLogPrefix, connect); + final Mqtt3ConnAck connAck = delegate.toBlocking().connect(connect); + this.connectedAt = LocalDateTime.now(); + Logger.debug("{} received CONNACK {} ", clientLogPrefix, connAck); + } + + @Override + public void publish(final @NotNull PublishOptions publishOptions) { + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + final ArrayList> publishFutures = new ArrayList<>(); + + final int topicsSize = publishOptions.getTopics().length; + final int qosSize = publishOptions.getQos().length; + if (topicsSize != qosSize) { + throw new IllegalArgumentException(String.format("Topics size (%d) does not match QoS size (%d)", + topicsSize, + qosSize)); + } + + for (int i = 0; i < topicsSize; i++) { + final String topic = publishOptions.getTopics()[i]; + final MqttQos qos = publishOptions.getQos()[i]; + + final Mqtt3PublishBuilder.Complete publishBuilder = + Mqtt3Publish.builder().topic(topic).qos(qos).payload(publishOptions.getMessage()); + + if (publishOptions.getRetain() != null) { + //noinspection ResultOfMethodCallIgnored + publishBuilder.retain(publishOptions.getRetain()); + } + + final Mqtt3Publish publishMessage = publishBuilder.build(); + + Logger.debug("{} sending PUBLISH ('{}') {}", + clientLogPrefix, + new String(publishOptions.getMessage().array(), StandardCharsets.UTF_8), + publishMessage); + + final CompletableFuture publishFuture = delegate.toAsync().publish(publishMessage); + publishFutures.add(publishFuture); + publishFuture.whenComplete((publishResult, throwable) -> { + if (throwable != null) { + Logger.error(throwable, + "{} failed PUBLISH to topic '{}': {}", + clientLogPrefix, + topic, + Throwables.getRootCause(throwable).getMessage()); + } else { + Logger.debug("{} received PUBLISH acknowledgement {}", clientLogPrefix, publishResult); + } + }); + } + + CompletableFuture.allOf(publishFutures.toArray(new CompletableFuture[]{})).join(); + } + + @Override + public void subscribe(final @NotNull SubscribeOptions subscribeOptions) { + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + + final String[] topics = subscribeOptions.getTopics(); + final MqttQos[] qos = subscribeOptions.getQos(); + if (topics.length != qos.length) { + throw new IllegalArgumentException(String.format("Topics size (%d) does not match QoS size (%d)", + topics.length, + qos.length)); + } + final ArrayList subscriptions = new ArrayList<>(); + for (int i = 0; i < topics.length; i++) { + final Mqtt3Subscription subscription = + Mqtt3Subscription.builder().topicFilter(topics[i]).qos(qos[i]).build(); + subscriptions.add(subscription); + } + + final Mqtt3Subscribe subscribeMessage = Mqtt3Subscribe.builder().addSubscriptions(subscriptions).build(); + + Logger.debug("{} sending SUBSCRIBE {}", clientLogPrefix, subscribeMessage); + + delegate.toAsync() + .subscribe(subscribeMessage, new SubscribeMqtt3PublishCallback(subscribeOptions, delegate)) + .whenComplete((subAck, throwable) -> { + if (throwable != null) { + Logger.error(throwable, + "{} failed SUBSCRIBE to topic(s) '{}': {}", + clientLogPrefix, + Arrays.toString(topics), + Throwables.getRootCause(throwable).getMessage()); + } else { + this.subscriptions.addAll(subscriptions); + Logger.debug("{} received SUBACK {}", clientLogPrefix, subAck); + } + }) + .join(); + } + + @Override + public void unsubscribe(final @NotNull UnsubscribeOptions unsubscribeOptions) { + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + + final List topicFilters = + Arrays.stream(unsubscribeOptions.getTopics()).map(MqttTopicFilter::of).collect(Collectors.toList()); + + final Mqtt3Unsubscribe unsubscribeMessage = Mqtt3Unsubscribe.builder().addTopicFilters(topicFilters).build(); + + Logger.debug("{} sending UNSUBSCRIBE {}", clientLogPrefix, unsubscribeMessage); + + delegate.toAsync().unsubscribe(unsubscribeMessage).whenComplete((unsubAck, throwable) -> { + if (throwable != null) { + Logger.error(throwable, + "{} failed UNSUBSCRIBE from topic(s) '{}': {}", + clientLogPrefix, + Arrays.toString(unsubscribeOptions.getTopics()), + Throwables.getRootCause(throwable).getMessage()); + } else { + subscriptions.removeIf(sub -> topicFilters.contains(sub.getTopicFilter())); + Logger.debug("{} received UNSUBACK", clientLogPrefix); + } + }).join(); + } + + @Override + public void disconnect(final @NotNull DisconnectOptions disconnectOptions) { + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + Logger.debug("{} sending DISCONNECT", clientLogPrefix); + delegate.toAsync().disconnect().whenComplete((result, throwable) -> { + if (throwable != null) { + Logger.error(throwable, + "{} failed to DISCONNECT gracefully: {}", + clientLogPrefix, + Throwables.getRootCause(throwable).getMessage()); + } else { + Logger.debug("{} disconnected successfully", clientLogPrefix); + } + }).join(); + } + + @Override + public boolean isConnected() { + return delegate.getState().isConnected(); + } + + @Override + public @NotNull String getClientIdentifier() { + return delegate.getConfig().getClientIdentifier().map(Object::toString).orElse("UNKNOWN"); + } + + @Override + public @NotNull String getServerHost() { + return delegate.getConfig().getServerHost(); + } + + @Override + public @NotNull MqttVersion getMqttVersion() { + return delegate.getConfig().getMqttVersion(); + } + + @Override + public @NotNull LocalDateTime getConnectedAt() { + if (connectedAt == null) { + throw new IllegalStateException("connectedAt must not be null after a client has connected successfully"); + } + return connectedAt; + } + + @Override + public @NotNull MqttClientState getState() { + return delegate.getState(); + } + + @Override + public @NotNull String getSslProtocols() { + final Optional sslConfig = delegate.getConfig().getSslConfig(); + if (sslConfig.isPresent()) { + final Optional> protocols = sslConfig.get().getProtocols(); + return protocols.orElse(ImmutableList.of()).toString(); + } else { + return "NO_SSL"; + } + } + + @Override + public int getServerPort() { + return delegate.getConfig().getServerPort(); + } + + @Override + public @NotNull List getSubscribedTopics() { + return subscriptions.stream().map(Mqtt3Subscription::getTopicFilter).collect(Collectors.toList()); + } + + public @NotNull Mqtt3Client getDelegate() { + return delegate; + } + + private static @NotNull Mqtt3Connect buildConnect(final @NotNull ConnectOptions connectOptions) { + final Mqtt3ConnectBuilder connectBuilder = Mqtt3Connect.builder(); + + if (connectOptions.getCleanStart() != null) { + //noinspection ResultOfMethodCallIgnored + connectBuilder.cleanSession(connectOptions.getCleanStart()); + } + if (connectOptions.getKeepAlive() != null) { + //noinspection ResultOfMethodCallIgnored + connectBuilder.keepAlive(connectOptions.getKeepAlive()); + } + + //noinspection ResultOfMethodCallIgnored + connectBuilder.simpleAuth(buildMqtt3Authentication(connectOptions.getAuthenticationOptions())); + + return connectBuilder.build(); + } + + private static @Nullable Mqtt3SimpleAuth buildMqtt3Authentication(final @NotNull AuthenticationOptions authenticationOptions) { + if (authenticationOptions.getUser() != null && authenticationOptions.getPassword() != null) { + return Mqtt3SimpleAuth.builder() + .username(authenticationOptions.getUser()) + .password(authenticationOptions.getPassword()) + .build(); + } else if (authenticationOptions.getUser() != null) { + return Mqtt3SimpleAuth.builder().username(authenticationOptions.getUser()).build(); + } else if (authenticationOptions.getPassword() != null) { + throw new IllegalArgumentException("Password-Only Authentication is not allowed in MQTT 3"); + } + return null; + } +} diff --git a/src/main/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientFactory.java b/src/main/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientFactory.java new file mode 100644 index 000000000..b64562f04 --- /dev/null +++ b/src/main/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientFactory.java @@ -0,0 +1,109 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.mqtt3; + +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.WillOptions; +import com.hivemq.cli.mqtt.clients.listeners.SubscribeMqtt3PublishCallback; +import com.hivemq.client.mqtt.MqttGlobalPublishFilter; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener; +import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; +import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientBuilder; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3PublishBuilder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.tinylog.Logger; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +public class CliMqtt3ClientFactory { + + public static @NotNull CliMqtt3Client create( + final @NotNull ConnectOptions connectOptions, + final @Nullable SubscribeOptions subscribeOptions, + final @NotNull List disconnectedListeners) throws Exception { + final Mqtt3Client client = buildClient(connectOptions, disconnectedListeners); + + client.toAsync() + .publishes(MqttGlobalPublishFilter.REMAINING, + buildRemainingMqtt3PublishesCallback(subscribeOptions, client)); + + return new CliMqtt3Client(client); + } + + private static @NotNull Mqtt3Client buildClient( + final @NotNull ConnectOptions connectOptions, + final @NotNull List disconnectedListeners) throws Exception { + final Mqtt3ClientBuilder clientBuilder = Mqtt3Client.builder() + .webSocketConfig(connectOptions.getWebSocketConfig()) + .serverHost(connectOptions.getHost()) + .serverPort(connectOptions.getPort()) + .sslConfig(connectOptions.buildSslConfig()); + + for (final MqttClientDisconnectedListener disconnectedListener : disconnectedListeners) { + //noinspection ResultOfMethodCallIgnored + clientBuilder.addDisconnectedListener(disconnectedListener); + } + + if (connectOptions.getIdentifier() != null) { + //noinspection ResultOfMethodCallIgnored + clientBuilder.identifier(connectOptions.getIdentifier()); + } + + //noinspection ResultOfMethodCallIgnored + clientBuilder.willPublish(buildWill(connectOptions.getWillOptions())); + + return clientBuilder.build(); + } + + private static @Nullable Mqtt3Publish buildWill(final @NotNull WillOptions willOptions) { + if (willOptions.getWillTopic() != null) { + final ByteBuffer willPayload = willOptions.getWillMessage(); + final Mqtt3PublishBuilder.Complete builder = Mqtt3Publish.builder() + .topic(willOptions.getWillTopic()) + .payload(willPayload) + .qos(Objects.requireNonNull(willOptions.getWillQos())); + + if (willOptions.getWillRetain() != null) { + //noinspection ResultOfMethodCallIgnored + builder.retain(willOptions.getWillRetain()); + } + return builder.build(); + } else if (willOptions.getWillMessage() != null) { + throw new IllegalArgumentException(String.format( + "option -wt is missing if a will message is configured - will options were: %s", + willOptions)); + } + return null; + } + + private static @NotNull Consumer buildRemainingMqtt3PublishesCallback( + final @Nullable SubscribeOptions subscribeOptions, final @NotNull Mqtt3Client client) { + if (subscribeOptions != null) { + return new SubscribeMqtt3PublishCallback(subscribeOptions, client); + } else { + return mqtt3Publish -> Logger.debug("received PUBLISH: {}, MESSAGE: '{}'", + mqtt3Publish, + new String(mqtt3Publish.getPayloadAsBytes(), StandardCharsets.UTF_8)); + } + } +} diff --git a/src/main/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5Client.java b/src/main/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5Client.java new file mode 100644 index 000000000..f55710ca9 --- /dev/null +++ b/src/main/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5Client.java @@ -0,0 +1,392 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.mqtt5; + +import com.google.common.base.Throwables; +import com.hivemq.cli.commands.options.AuthenticationOptions; +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.ConnectRestrictionOptions; +import com.hivemq.cli.commands.options.DisconnectOptions; +import com.hivemq.cli.commands.options.PublishOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.UnsubscribeOptions; +import com.hivemq.cli.mqtt.clients.CliMqttClient; +import com.hivemq.cli.mqtt.clients.listeners.SubscribeMqtt5PublishCallback; +import com.hivemq.cli.utils.LoggerUtils; +import com.hivemq.client.internal.util.collections.ImmutableList; +import com.hivemq.client.mqtt.MqttClientSslConfig; +import com.hivemq.client.mqtt.MqttClientState; +import com.hivemq.client.mqtt.MqttVersion; +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5SimpleAuth; +import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect; +import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5ConnectBuilder; +import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5ConnectRestrictions; +import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5ConnectRestrictionsBuilder; +import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAck; +import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5Disconnect; +import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5DisconnectBuilder; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5PublishBuilder; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5PublishResult; +import com.hivemq.client.mqtt.mqtt5.message.subscribe.Mqtt5Subscribe; +import com.hivemq.client.mqtt.mqtt5.message.subscribe.Mqtt5SubscribeBuilder; +import com.hivemq.client.mqtt.mqtt5.message.subscribe.Mqtt5Subscription; +import com.hivemq.client.mqtt.mqtt5.message.unsubscribe.Mqtt5Unsubscribe; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.tinylog.Logger; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +public class CliMqtt5Client implements CliMqttClient { + + private final @NotNull Mqtt5Client delegate; + private final @NotNull List subscriptions = new CopyOnWriteArrayList<>(); + private @Nullable LocalDateTime connectedAt; + + public CliMqtt5Client(final @NotNull Mqtt5Client delegate) { + this.delegate = delegate; + } + + public void connect(final @NotNull ConnectOptions connectOptions) { + final Mqtt5Connect connect = buildConnect(connectOptions); + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + Logger.debug("{} sending CONNECT {}", clientLogPrefix, connect); + final Mqtt5ConnAck connAck = delegate.toBlocking().connect(connect); + this.connectedAt = LocalDateTime.now(); + Logger.debug("{} received CONNACK {} ", clientLogPrefix, connAck); + } + + @Override + public void publish(final @NotNull PublishOptions publishOptions) { + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + final ArrayList> publishFutures = new ArrayList<>(); + + final int topicsSize = publishOptions.getTopics().length; + final int qosSize = publishOptions.getQos().length; + if (topicsSize != qosSize) { + throw new IllegalArgumentException(String.format("Topics size (%d) does not match QoS size (%d)", + topicsSize, + qosSize)); + } + + for (int i = 0; i < topicsSize; i++) { + final String topic = publishOptions.getTopics()[i]; + final MqttQos qos = publishOptions.getQos()[i]; + + final Mqtt5PublishBuilder.Complete publishBuilder = Mqtt5Publish.builder() + .topic(topic) + .qos(qos) + .payload(publishOptions.getMessage()) + .payloadFormatIndicator(publishOptions.getPayloadFormatIndicator()) + .contentType(publishOptions.getContentType()) + .responseTopic(publishOptions.getResponseTopic()) + .correlationData(publishOptions.getCorrelationData()) + .userProperties(publishOptions.getUserProperties()); + + if (publishOptions.getRetain() != null) { + //noinspection ResultOfMethodCallIgnored + publishBuilder.retain(publishOptions.getRetain()); + } + if (publishOptions.getMessageExpiryInterval() != null) { + //noinspection ResultOfMethodCallIgnored + publishBuilder.messageExpiryInterval(publishOptions.getMessageExpiryInterval()); + } + + final Mqtt5Publish publishMessage = publishBuilder.build(); + + Logger.debug("{} sending PUBLISH ('{}') {}", + clientLogPrefix, + new String(publishOptions.getMessage().array(), StandardCharsets.UTF_8), + publishMessage); + + final CompletableFuture future = delegate.toAsync().publish(publishMessage); + publishFutures.add(future); + future.whenComplete((publishResult, throwable) -> { + if (throwable != null) { + Logger.error(throwable, + "{} failed PUBLISH to topic '{}': {}", + clientLogPrefix, + topic, + Throwables.getRootCause(throwable).getMessage()); + } else { + Logger.debug("{} received PUBLISH acknowledgement {}", clientLogPrefix, publishResult); + } + }); + + CompletableFuture.allOf(publishFutures.toArray(new CompletableFuture[]{})).join(); + } + } + + @Override + public void subscribe(final @NotNull SubscribeOptions subscribeOptions) { + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + + final String[] topics = subscribeOptions.getTopics(); + final MqttQos[] qos = subscribeOptions.getQos(); + if (topics.length != qos.length) { + throw new IllegalArgumentException(String.format("Topics size (%d) does not match QoS size (%d)", + topics.length, + qos.length)); + } + final ArrayList subscriptions = new ArrayList<>(topics.length); + for (int i = 0; i < topics.length; i++) { + final Mqtt5Subscription subscription = + Mqtt5Subscription.builder().topicFilter(topics[i]).qos(qos[i]).build(); + subscriptions.add(subscription); + } + + final Mqtt5SubscribeBuilder.Complete builder = Mqtt5Subscribe.builder() + .addSubscriptions(subscriptions) + .userProperties(subscribeOptions.getUserProperties()); + final Mqtt5Subscribe subscribeMessage = builder.build(); + + Logger.debug("{} sending SUBSCRIBE {}", clientLogPrefix, subscribeMessage); + + delegate.toAsync() + .subscribe(subscribeMessage, new SubscribeMqtt5PublishCallback(subscribeOptions, delegate)) + .whenComplete((subAck, throwable) -> { + if (throwable != null) { + Logger.error(throwable, + "{} failed SUBSCRIBE to topic(s) '{}': {}", + clientLogPrefix, + Arrays.toString(topics), + Throwables.getRootCause(throwable).getMessage()); + } else { + this.subscriptions.addAll(subscriptions); + Logger.debug("{} received SUBACK {}", clientLogPrefix, subAck); + } + }) + .join(); + } + + @Override + public void unsubscribe(final @NotNull UnsubscribeOptions unsubscribeOptions) { + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + + final List topicFilters = + Arrays.stream(unsubscribeOptions.getTopics()).map(MqttTopicFilter::of).collect(Collectors.toList()); + + final Mqtt5Unsubscribe unsubscribeMessage = Mqtt5Unsubscribe.builder() + .addTopicFilters(topicFilters) + .userProperties(unsubscribeOptions.getUserProperties()) + .build(); + + Logger.debug("{} sending UNSUBSCRIBE {}", clientLogPrefix, unsubscribeMessage); + + delegate.toAsync().unsubscribe(unsubscribeMessage).whenComplete((unsubAck, throwable) -> { + if (throwable != null) { + Logger.error(throwable, + "{} failed UNSUBSCRIBE from topic(s) '{}': {}", + clientLogPrefix, + Arrays.toString(unsubscribeOptions.getTopics()), + Throwables.getRootCause(throwable).getMessage()); + } else { + subscriptions.removeIf(sub -> topicFilters.contains(sub.getTopicFilter())); + Logger.debug("{} received UNSUBACK {}", clientLogPrefix, unsubAck); + } + }).join(); + } + + @Override + public void disconnect(final @NotNull DisconnectOptions disconnectOptions) { + final String clientLogPrefix = LoggerUtils.getClientPrefix(delegate.getConfig()); + final Mqtt5DisconnectBuilder disconnectBuilder = Mqtt5Disconnect.builder().userProperties(disconnectOptions.getUserProperties()); + + if (disconnectOptions.getReasonString() != null) { + //noinspection ResultOfMethodCallIgnored + disconnectBuilder.reasonString(disconnectOptions.getReasonString()); + } + if (disconnectOptions.getSessionExpiryInterval() != null) { + //noinspection ResultOfMethodCallIgnored + disconnectBuilder.sessionExpiryInterval(disconnectOptions.getSessionExpiryInterval()); + } + + final Mqtt5Disconnect disconnectMessage = disconnectBuilder.build(); + + Logger.debug("{} sending DISCONNECT {}", clientLogPrefix, disconnectMessage); + + delegate.toAsync().disconnect(disconnectMessage).whenComplete((result, throwable) -> { + if (throwable != null) { + Logger.error(throwable, + "{} failed to DISCONNECT gracefully: {}", + clientLogPrefix, + Throwables.getRootCause(throwable).getMessage()); + } else { + Logger.debug("{} disconnected successfully", clientLogPrefix); + } + }).join(); + } + + @Override + public boolean isConnected() { + return delegate.getState().isConnected(); + } + + @Override + public @NotNull String getClientIdentifier() { + return delegate.getConfig().getClientIdentifier().map(Object::toString).orElse("UNKNOWN"); + } + + @Override + public @NotNull String getServerHost() { + return delegate.getConfig().getServerHost(); + } + + @Override + public @NotNull MqttVersion getMqttVersion() { + return delegate.getConfig().getMqttVersion(); + } + + @Override + public @NotNull LocalDateTime getConnectedAt() { + if (connectedAt == null) { + throw new IllegalStateException("connectedAt must not be null after a client has connected successfully"); + } + return connectedAt; + } + + @Override + public @NotNull MqttClientState getState() { + return delegate.getState(); + } + + @Override + public @NotNull String getSslProtocols() { + final Optional sslConfig = delegate.getConfig().getSslConfig(); + if (sslConfig.isPresent()) { + final Optional> protocols = sslConfig.get().getProtocols(); + return protocols.orElse(ImmutableList.of()).toString(); + } else { + return "NO_SSL"; + } + } + + @Override + public int getServerPort() { + return delegate.getConfig().getServerPort(); + } + + @Override + public @NotNull List getSubscribedTopics() { + return subscriptions.stream().map(Mqtt5Subscription::getTopicFilter).collect(Collectors.toList()); + } + + public @NotNull Mqtt5Client getDelegate() { + return delegate; + } + + private static @NotNull Mqtt5Connect buildConnect(final @NotNull ConnectOptions connectOptions) { + final Mqtt5ConnectBuilder connectBuilder = Mqtt5Connect.builder().userProperties(connectOptions.getUserProperties()); + + final Mqtt5ConnectRestrictions restrictions = buildRestrictions(connectOptions.getConnectRestrictionOptions()); + if (restrictions != null) { + //noinspection ResultOfMethodCallIgnored + connectBuilder.restrictions(restrictions); + } + + if (connectOptions.getCleanStart() != null) { + //noinspection ResultOfMethodCallIgnored + connectBuilder.cleanStart(connectOptions.getCleanStart()); + } + if (connectOptions.getKeepAlive() != null) { + //noinspection ResultOfMethodCallIgnored + connectBuilder.keepAlive(connectOptions.getKeepAlive()); + } + if (connectOptions.getSessionExpiryInterval() != null) { + //noinspection ResultOfMethodCallIgnored + connectBuilder.sessionExpiryInterval(connectOptions.getSessionExpiryInterval()); + } + + //noinspection ResultOfMethodCallIgnored + connectBuilder.simpleAuth(buildMqtt5Authentication(connectOptions.getAuthenticationOptions())); + + return connectBuilder.build(); + } + + private static @Nullable Mqtt5ConnectRestrictions buildRestrictions(final @NotNull ConnectRestrictionOptions connectRestrictionOptions) { + final Mqtt5ConnectRestrictionsBuilder restrictionsBuilder = Mqtt5ConnectRestrictions.builder(); + + if (connectRestrictionOptions.getReceiveMaximum() != null) { + //noinspection ResultOfMethodCallIgnored + restrictionsBuilder.receiveMaximum(connectRestrictionOptions.getReceiveMaximum()); + } + if (connectRestrictionOptions.getSendMaximum() != null) { + //noinspection ResultOfMethodCallIgnored + restrictionsBuilder.sendMaximum(connectRestrictionOptions.getSendMaximum()); + } + if (connectRestrictionOptions.getMaximumPacketSize() != null) { + //noinspection ResultOfMethodCallIgnored + restrictionsBuilder.maximumPacketSize(connectRestrictionOptions.getMaximumPacketSize()); + } + if (connectRestrictionOptions.getSendMaximumPacketSize() != null) { + //noinspection ResultOfMethodCallIgnored + restrictionsBuilder.sendMaximumPacketSize(connectRestrictionOptions.getSendMaximumPacketSize()); + } + if (connectRestrictionOptions.getTopicAliasMaximum() != null) { + //noinspection ResultOfMethodCallIgnored + restrictionsBuilder.topicAliasMaximum(connectRestrictionOptions.getTopicAliasMaximum()); + } + if (connectRestrictionOptions.getSendTopicAliasMaximum() != null) { + //noinspection ResultOfMethodCallIgnored + restrictionsBuilder.sendTopicAliasMaximum(connectRestrictionOptions.getSendTopicAliasMaximum()); + } + if (connectRestrictionOptions.getRequestProblemInformation() != null) { + //noinspection ResultOfMethodCallIgnored + restrictionsBuilder.requestProblemInformation(connectRestrictionOptions.getRequestProblemInformation()); + } + if (connectRestrictionOptions.getRequestResponseInformation() != null) { + //noinspection ResultOfMethodCallIgnored + restrictionsBuilder.requestResponseInformation(connectRestrictionOptions.getRequestResponseInformation()); + } + + final Mqtt5ConnectRestrictions restrictions = restrictionsBuilder.build(); + + // Workaround : if the built connect restrictions are the default ones do not append them to the connect builder + // -> Else the connectMessage.toString() method will flood the logging output + if (!restrictions.equals(Mqtt5ConnectRestrictions.builder().build())) { + return restrictions; + } else { + return null; + } + } + + private static @Nullable Mqtt5SimpleAuth buildMqtt5Authentication(final @NotNull AuthenticationOptions authenticationOptions) { + if (authenticationOptions.getUser() != null && authenticationOptions.getPassword() != null) { + return Mqtt5SimpleAuth.builder() + .username(authenticationOptions.getUser()) + .password(authenticationOptions.getPassword()) + .build(); + } else if (authenticationOptions.getPassword() != null) { + return Mqtt5SimpleAuth.builder().password(authenticationOptions.getPassword()).build(); + } else if (authenticationOptions.getUser() != null) { + return Mqtt5SimpleAuth.builder().username(authenticationOptions.getUser()).build(); + } + return null; + } + +} diff --git a/src/main/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientFactory.java b/src/main/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientFactory.java new file mode 100644 index 000000000..7f89b85fb --- /dev/null +++ b/src/main/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientFactory.java @@ -0,0 +1,126 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.mqtt5; + +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.WillOptions; +import com.hivemq.cli.mqtt.clients.listeners.SubscribeMqtt5PublishCallback; +import com.hivemq.client.mqtt.MqttGlobalPublishFilter; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientBuilder; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5WillPublish; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5WillPublishBuilder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.tinylog.Logger; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +public class CliMqtt5ClientFactory { + + public static @NotNull CliMqtt5Client create( + final @NotNull ConnectOptions connectOptions, + final @Nullable SubscribeOptions subscribeOptions, + final @NotNull List disconnectedListeners) throws Exception { + final Mqtt5Client client = buildClient(connectOptions, disconnectedListeners); + + client.toAsync() + .publishes(MqttGlobalPublishFilter.REMAINING, + buildRemainingMqtt5PublishesCallback(subscribeOptions, client)); + + return new CliMqtt5Client(client); + } + + private static @NotNull Mqtt5Client buildClient( + final @NotNull ConnectOptions connectOptions, + final @NotNull List disconnectedListeners) throws Exception { + final Mqtt5ClientBuilder clientBuilder = Mqtt5Client.builder() + .webSocketConfig(connectOptions.getWebSocketConfig()) + .serverHost(connectOptions.getHost()) + .serverPort(connectOptions.getPort()) + .sslConfig(connectOptions.buildSslConfig()); + + for (final MqttClientDisconnectedListener disconnectedListener : disconnectedListeners) { + //noinspection ResultOfMethodCallIgnored + clientBuilder.addDisconnectedListener(disconnectedListener); + } + + if (connectOptions.getIdentifier() != null) { + //noinspection ResultOfMethodCallIgnored + clientBuilder.identifier(connectOptions.getIdentifier()); + } + + //noinspection ResultOfMethodCallIgnored + clientBuilder.willPublish(buildWill(connectOptions.getWillOptions())); + + return clientBuilder.build(); + } + + private static @Nullable Mqtt5WillPublish buildWill(final @NotNull WillOptions willOptions) { + // only topic is mandatory for will message creation + if (willOptions.getWillTopic() != null) { + final ByteBuffer willPayload = willOptions.getWillMessage(); + final Mqtt5WillPublishBuilder.Complete builder = Mqtt5WillPublish.builder() + .topic(willOptions.getWillTopic()) + .payload(willPayload) + .qos(Objects.requireNonNull(willOptions.getWillQos())) + .payloadFormatIndicator(willOptions.getWillPayloadFormatIndicator()) + .contentType(willOptions.getWillContentType()) + .responseTopic(willOptions.getWillResponseTopic()) + .correlationData(willOptions.getWillCorrelationData()) + .userProperties(willOptions.getUserProperties()); + + if (willOptions.getWillRetain() != null) { + //noinspection ResultOfMethodCallIgnored + builder.retain(willOptions.getWillRetain()); + } + if (willOptions.getWillMessageExpiryInterval() != null) { + //noinspection ResultOfMethodCallIgnored + builder.messageExpiryInterval(willOptions.getWillMessageExpiryInterval()); + } + if (willOptions.getWillDelayInterval() != null) { + //noinspection ResultOfMethodCallIgnored + builder.delayInterval(willOptions.getWillDelayInterval()); + } + return builder.build().asWill(); + } else if (willOptions.getWillMessage() != null) { + throw new IllegalArgumentException(String.format( + "option -wt is missing if a will message is configured - will options were: %s", + willOptions)); + } else { + return null; + } + } + + private static @NotNull Consumer buildRemainingMqtt5PublishesCallback( + final @Nullable SubscribeOptions subscribeOptions, final @NotNull Mqtt5Client client) { + if (subscribeOptions != null) { + return new SubscribeMqtt5PublishCallback(subscribeOptions, client); + } else { + return mqtt5Publish -> Logger.debug("received PUBLISH: {}, MESSAGE: '{}'", + mqtt5Publish, + new String(mqtt5Publish.getPayloadAsBytes(), StandardCharsets.UTF_8)); + } + } + +} diff --git a/src/main/java/com/hivemq/cli/utils/MqttUtils.java b/src/main/java/com/hivemq/cli/utils/MqttUtils.java index fd1b0d8a2..cbd82a289 100644 --- a/src/main/java/com/hivemq/cli/utils/MqttUtils.java +++ b/src/main/java/com/hivemq/cli/utils/MqttUtils.java @@ -61,9 +61,9 @@ public enum IdentifierWarning { "}"); } - public static @Nullable Mqtt5UserProperties convertToMqtt5UserProperties(final @Nullable Mqtt5UserProperty @Nullable ... userProperties) { + public static @NotNull Mqtt5UserProperties convertToMqtt5UserProperties(final @Nullable Mqtt5UserProperty @Nullable ... userProperties) { if (userProperties == null) { - return null; + return Mqtt5UserProperties.of(); } else { final List nonNullUserProperties = Arrays.stream(userProperties).filter(Objects::nonNull).collect(Collectors.toList()); diff --git a/src/systemTest/java/com/hivemq/cli/commands/cli/subscribe/SubscribeST.java b/src/systemTest/java/com/hivemq/cli/commands/cli/subscribe/SubscribeST.java index 1468f5657..45882a021 100644 --- a/src/systemTest/java/com/hivemq/cli/commands/cli/subscribe/SubscribeST.java +++ b/src/systemTest/java/com/hivemq/cli/commands/cli/subscribe/SubscribeST.java @@ -116,10 +116,16 @@ void test_multipleTopics(final char mqttVersion) throws Exception { subscribeCommand.add("-t"); subscribeCommand.add("topic3"); - final ExecutionResultAsync executionResult = mqttCli.executeAsync(subscribeCommand) - .awaitStdOut("received SUBACK") - .awaitStdOut("received SUBACK") - .awaitStdOut("received SUBACK"); + final ExecutionResultAsync executionResult = mqttCli.executeAsync(subscribeCommand); + + if (mqttVersion == '3') { + executionResult.awaitStdOut( + "received SUBACK MqttSubAck{returnCodes=[SUCCESS_MAXIMUM_QOS_2, SUCCESS_MAXIMUM_QOS_2, SUCCESS_MAXIMUM_QOS_2]}"); + + } else { + executionResult.awaitStdOut( + "received SUBACK MqttSubAck{reasonCodes=[GRANTED_QOS_2, GRANTED_QOS_2, GRANTED_QOS_2], packetIdentifier=65526}"); + } publishMessage("topic1", "message1"); executionResult.awaitStdOut("message1"); @@ -130,21 +136,13 @@ void test_multipleTopics(final char mqttVersion) throws Exception { publishMessage("topic3", "message3"); executionResult.awaitStdOut("message3"); - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(0), subscribeAssertion -> { - final List expectedSubscriptions = List.of(createSubscription("topic1", Qos.EXACTLY_ONCE)); + final List expectedSubscriptions = List.of(createSubscription("topic1", Qos.EXACTLY_ONCE), + createSubscription("topic2", Qos.EXACTLY_ONCE), + createSubscription("topic3", Qos.EXACTLY_ONCE)); subscribeAssertion.setSubscriptions(expectedSubscriptions); }); - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(1), subscribeAssertion -> { - final List expectedSubscriptions = List.of(createSubscription("topic2", Qos.EXACTLY_ONCE)); - subscribeAssertion.setSubscriptions(expectedSubscriptions); - }); - - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(2), subscribeAssertion -> { - final List expectedSubscriptions = List.of(createSubscription("topic3", Qos.EXACTLY_ONCE)); - subscribeAssertion.setSubscriptions(expectedSubscriptions); - }); } @ParameterizedTest @@ -167,10 +165,15 @@ void test_multipleTopicsMultipleQoS(final char mqttVersion) throws Exception { subscribeCommand.add("-q"); subscribeCommand.add("2"); - final ExecutionResultAsync executionResult = mqttCli.executeAsync(subscribeCommand) - .awaitStdOut("received SUBACK") - .awaitStdOut("received SUBACK") - .awaitStdOut("received SUBACK"); + final ExecutionResultAsync executionResult = mqttCli.executeAsync(subscribeCommand); + + if (mqttVersion == '3') { + executionResult.awaitStdOut( + "received SUBACK MqttSubAck{returnCodes=[SUCCESS_MAXIMUM_QOS_0, SUCCESS_MAXIMUM_QOS_1, SUCCESS_MAXIMUM_QOS_2]}"); + } else { + executionResult.awaitStdOut( + "received SUBACK MqttSubAck{reasonCodes=[GRANTED_QOS_0, GRANTED_QOS_1, GRANTED_QOS_2], packetIdentifier=65526}"); + } publishMessage("topic1", "message1"); executionResult.awaitStdOut("message1"); @@ -183,19 +186,12 @@ void test_multipleTopicsMultipleQoS(final char mqttVersion) throws Exception { assertSubscribePacket(HIVEMQ.getSubscribePackets().get(0), subscribeAssertion -> { - final List expectedSubscriptions = List.of(createSubscription("topic1", Qos.AT_MOST_ONCE)); + final List expectedSubscriptions = List.of(createSubscription("topic1", Qos.AT_MOST_ONCE), + createSubscription("topic2", Qos.AT_LEAST_ONCE), + createSubscription("topic3", Qos.EXACTLY_ONCE)); subscribeAssertion.setSubscriptions(expectedSubscriptions); }); - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(1), subscribeAssertion -> { - final List expectedSubscriptions = List.of(createSubscription("topic2", Qos.AT_LEAST_ONCE)); - subscribeAssertion.setSubscriptions(expectedSubscriptions); - }); - - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(2), subscribeAssertion -> { - final List expectedSubscriptions = List.of(createSubscription("topic3", Qos.EXACTLY_ONCE)); - subscribeAssertion.setSubscriptions(expectedSubscriptions); - }); } @ParameterizedTest @@ -317,9 +313,13 @@ void test_showJson(final char mqttVersion) throws Exception { jsonObject.addProperty("property3", "value3"); publishMessage("topic", jsonObject.toString()); - executionResult.awaitStdOut( - "{\n" + " \"topic\": \"topic\",\n" + " \"payload\": {\n" + " \"property1\": \"value1\",\n" + - " \"property2\": \"value2\",\n" + " \"property3\": \"value3\"\n" + " },\n"); + executionResult.awaitStdOut("{\n" + + " \"topic\": \"topic\",\n" + + " \"payload\": {\n" + + " \"property1\": \"value1\",\n" + + " \"property2\": \"value2\",\n" + + " \"property3\": \"value3\"\n" + + " },\n"); executionResult.awaitStdOut("\"qos\": \"EXACTLY_ONCE\","); executionResult.awaitStdOut("\"receivedAt\":"); executionResult.awaitStdOut("\"retain\": false"); diff --git a/src/systemTest/java/com/hivemq/cli/commands/shell/ShellPublishST.java b/src/systemTest/java/com/hivemq/cli/commands/shell/ShellPublishST.java index 7b5ededbf..2051d3cab 100644 --- a/src/systemTest/java/com/hivemq/cli/commands/shell/ShellPublishST.java +++ b/src/systemTest/java/com/hivemq/cli/commands/shell/ShellPublishST.java @@ -392,7 +392,7 @@ void test_publishMissingTopic(final char mqttVersion) throws Exception { final List publishCommand = List.of("pub"); mqttCliShell.connectClient(HIVEMQ, mqttVersion); mqttCliShell.executeAsync(publishCommand) - .awaitStdErr("Missing required option: '--topic '") + .awaitStdErr("Missing required option: '--topic='") .awaitStdOut("cliTest@" + HIVEMQ.getHost() + ">"); } diff --git a/src/systemTest/java/com/hivemq/cli/commands/shell/ShellSubscribeST.java b/src/systemTest/java/com/hivemq/cli/commands/shell/ShellSubscribeST.java index f79b5d1e3..518ed203b 100644 --- a/src/systemTest/java/com/hivemq/cli/commands/shell/ShellSubscribeST.java +++ b/src/systemTest/java/com/hivemq/cli/commands/shell/ShellSubscribeST.java @@ -78,69 +78,45 @@ void test_successfulSubscribe(final char mqttVersion) throws Exception { @Timeout(value = 3, unit = TimeUnit.MINUTES) @ValueSource(chars = {'3', '5'}) void test_multipleTopics(final char mqttVersion) throws Exception { - //FIXME Subscribe command should subscribe to all topics in one packet and not send separate subscribes final List subscribeCommand = List.of("sub", "-t", "test1", "-t", "test2", "-t", "test3"); mqttCliShell.connectClient(HIVEMQ, mqttVersion); mqttCliShell.executeAsync(subscribeCommand) .awaitStdOut(String.format("cliTest@%s>", HIVEMQ.getHost())) .awaitLog("sending SUBSCRIBE") - .awaitLog("received SUBACK") - .awaitLog("sending SUBSCRIBE") - .awaitLog("received SUBACK") - .awaitLog("sending SUBSCRIBE") .awaitLog("received SUBACK"); assertSubscribePacket(HIVEMQ.getSubscribePackets().get(0), subscribeAssertion -> { final List expectedSubscriptions = - List.of(new SubscriptionImpl("test1", Qos.EXACTLY_ONCE, RetainHandling.SEND, false, false)); + List.of(new SubscriptionImpl("test1", Qos.EXACTLY_ONCE, RetainHandling.SEND, false, false), + new SubscriptionImpl("test2", Qos.EXACTLY_ONCE, RetainHandling.SEND, false, false), + new SubscriptionImpl("test3", Qos.EXACTLY_ONCE, RetainHandling.SEND, false, false)); subscribeAssertion.setSubscriptions(expectedSubscriptions); }); - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(1), subscribeAssertion -> { - final List expectedSubscriptions = - List.of(new SubscriptionImpl("test2", Qos.EXACTLY_ONCE, RetainHandling.SEND, false, false)); - subscribeAssertion.setSubscriptions(expectedSubscriptions); - }); - - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(2), subscribeAssertion -> { - final List expectedSubscriptions = - List.of(new SubscriptionImpl("test3", Qos.EXACTLY_ONCE, RetainHandling.SEND, false, false)); - subscribeAssertion.setSubscriptions(expectedSubscriptions); - }); } @ParameterizedTest @Timeout(value = 3, unit = TimeUnit.MINUTES) @ValueSource(chars = {'3', '5'}) void test_multipleTopicsMultipleQos(final char mqttVersion) throws Exception { - //FIXME Subscribe command should subscribe to all topics in one packet and not send separate subscribes final List subscribeCommand = List.of("sub", "-t", "test1", "-t", "test2", "-t", "test3", "-q", "0", "-q", "1", "-q", "2"); mqttCliShell.connectClient(HIVEMQ, mqttVersion); - mqttCliShell.executeAsync(subscribeCommand) + final AwaitOutput awaitOutput = mqttCliShell.executeAsync(subscribeCommand) .awaitStdOut(String.format("cliTest@%s>", HIVEMQ.getHost())) - .awaitLog("sending SUBSCRIBE") - .awaitLog("received SUBACK") - .awaitLog("sending SUBSCRIBE") - .awaitLog("received SUBACK") - .awaitLog("sending SUBSCRIBE") - .awaitLog("received SUBACK"); - - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(0), subscribeAssertion -> { - final List expectedSubscriptions = - List.of(new SubscriptionImpl("test1", Qos.AT_MOST_ONCE, RetainHandling.SEND, false, false)); - subscribeAssertion.setSubscriptions(expectedSubscriptions); - }); + .awaitLog("sending SUBSCRIBE"); - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(1), subscribeAssertion -> { - final List expectedSubscriptions = - List.of(new SubscriptionImpl("test2", Qos.AT_LEAST_ONCE, RetainHandling.SEND, false, false)); - subscribeAssertion.setSubscriptions(expectedSubscriptions); - }); + if (mqttVersion == '3') { + awaitOutput.awaitLog("received SUBACK MqttSubAck{returnCodes=[SUCCESS_MAXIMUM_QOS_0, SUCCESS_MAXIMUM_QOS_1, SUCCESS_MAXIMUM_QOS_2]}"); + } else { + awaitOutput.awaitLog("received SUBACK MqttSubAck{reasonCodes=[GRANTED_QOS_0, GRANTED_QOS_1, GRANTED_QOS_2], packetIdentifier=65526}"); + } - assertSubscribePacket(HIVEMQ.getSubscribePackets().get(2), subscribeAssertion -> { + assertSubscribePacket(HIVEMQ.getSubscribePackets().get(0), subscribeAssertion -> { final List expectedSubscriptions = - List.of(new SubscriptionImpl("test3", Qos.EXACTLY_ONCE, RetainHandling.SEND, false, false)); + List.of(new SubscriptionImpl("test1", Qos.AT_MOST_ONCE, RetainHandling.SEND, false, false), + new SubscriptionImpl("test2", Qos.AT_LEAST_ONCE, RetainHandling.SEND, false, false), + new SubscriptionImpl("test3", Qos.EXACTLY_ONCE, RetainHandling.SEND, false, false)); subscribeAssertion.setSubscriptions(expectedSubscriptions); }); } @@ -353,9 +329,13 @@ void test_Json(final char mqttVersion) throws Exception { publisher.publishWith().topic("test").payload(jsonObject.toString().getBytes(StandardCharsets.UTF_8)).send(); - awaitOutput.awaitStdOut( - "{\n" + " \"topic\": \"test\",\n" + " \"payload\": {\n" + " \"property1\": \"value1\",\n" + - " \"property2\": \"value2\",\n" + " \"property3\": \"value3\"\n" + " },\n"); + awaitOutput.awaitStdOut("{\n" + + " \"topic\": \"test\",\n" + + " \"payload\": {\n" + + " \"property1\": \"value1\",\n" + + " \"property2\": \"value2\",\n" + + " \"property3\": \"value3\"\n" + + " },\n"); awaitOutput.awaitStdOut("\"qos\": \"AT_MOST_ONCE\","); awaitOutput.awaitStdOut("\"receivedAt\":"); awaitOutput.awaitStdOut("\"retain\": false"); @@ -370,7 +350,7 @@ void test_subscribeMissingTopic(final char mqttVersion) throws Exception { final List subscribeCommand = List.of("sub"); mqttCliShell.connectClient(HIVEMQ, mqttVersion); mqttCliShell.executeAsync(subscribeCommand) - .awaitStdErr("Missing required option: '--topic '") + .awaitStdErr("Missing required option: '--topic='") .awaitStdOut("cliTest@" + HIVEMQ.getHost() + ">"); } diff --git a/src/systemTest/java/com/hivemq/cli/commands/shell/ShellUnsubscribeST.java b/src/systemTest/java/com/hivemq/cli/commands/shell/ShellUnsubscribeST.java index 8786c6695..ca91ccffc 100644 --- a/src/systemTest/java/com/hivemq/cli/commands/shell/ShellUnsubscribeST.java +++ b/src/systemTest/java/com/hivemq/cli/commands/shell/ShellUnsubscribeST.java @@ -66,27 +66,15 @@ void test_successfulUnsubscribe(final char mqttVersion) throws Exception { void test_multipleTopics(final char mqttVersion) throws Exception { final List subscribeCommand = List.of("unsub", "-t", "test1", "-t", "test2", "-t", "test3"); mqttCliShell.connectClient(HIVEMQ, mqttVersion); - mqttCliShell.executeAsync(subscribeCommand) + final AwaitOutput awaitOutput = mqttCliShell.executeAsync(subscribeCommand) .awaitStdOut(String.format("cliTest@%s>", HIVEMQ.getHost())) - .awaitLog("sending UNSUBSCRIBE") - .awaitLog("received UNSUBACK") - .awaitLog("sending UNSUBSCRIBE") - .awaitLog("received UNSUBACK") - .awaitLog("sending UNSUBSCRIBE") + .awaitLog("sending UNSUBSCRIBE MqttUnsubscribe{topicFilters=[test1, test2, test3]}") .awaitLog("received UNSUBACK"); assertUnsubscribePacket( HIVEMQ.getUnsubscribePackets().get(0), - unsubscribeAssertion -> unsubscribeAssertion.setTopicFilters(List.of("test1"))); - - assertUnsubscribePacket( - HIVEMQ.getUnsubscribePackets().get(1), - unsubscribeAssertion -> unsubscribeAssertion.setTopicFilters(List.of("test2"))); - - assertUnsubscribePacket( - HIVEMQ.getUnsubscribePackets().get(2), - unsubscribeAssertion -> unsubscribeAssertion.setTopicFilters(List.of("test3"))); + unsubscribeAssertion -> unsubscribeAssertion.setTopicFilters(List.of("test1", "test2", "test3"))); } @ParameterizedTest @@ -128,7 +116,7 @@ void test_missingTopic(final char mqttVersion) throws Exception { mqttCliShell.connectClient(HIVEMQ, mqttVersion); mqttCliShell.executeAsync(subscribeCommand) .awaitStdOut(String.format("cliTest@%s>", HIVEMQ.getHost())) - .awaitStdErr("Missing required option: '--topic '") + .awaitStdErr("Missing required option: '--topic='") .awaitStdErr("Try 'help unsub' for more information."); assertEquals(0, HIVEMQ.getUnsubscribePackets().size()); diff --git a/src/test/java/com/hivemq/cli/MqttCLIMainTest.java b/src/test/java/com/hivemq/cli/MqttCLIMainTest.java index 8ca6ea714..f09ff4a7c 100644 --- a/src/test/java/com/hivemq/cli/MqttCLIMainTest.java +++ b/src/test/java/com/hivemq/cli/MqttCLIMainTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; + class MqttCLIMainTest { @BeforeEach @@ -56,4 +57,4 @@ void hivemq_export_command() { void hivemq_export_clients_help_command() { MqttCLIMain.main("hivemq", "export", "clients", "-h"); } -} \ No newline at end of file +} diff --git a/src/test/java/com/hivemq/cli/mqtt/AbstractMqttClientExecutorTest.java b/src/test/java/com/hivemq/cli/mqtt/AbstractMqttClientExecutorTest.java deleted file mode 100644 index 5c56850ae..000000000 --- a/src/test/java/com/hivemq/cli/mqtt/AbstractMqttClientExecutorTest.java +++ /dev/null @@ -1,364 +0,0 @@ -/* - * Copyright 2019-present HiveMQ and the HiveMQ Community - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.hivemq.cli.mqtt; - -import com.hivemq.cli.commands.options.*; -import com.hivemq.client.mqtt.MqttVersion; -import com.hivemq.client.mqtt.datatypes.MqttQos; -import com.hivemq.client.mqtt.datatypes.MqttSharedTopicFilter; -import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; -import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; -import com.hivemq.client.mqtt.mqtt3.message.auth.Mqtt3SimpleAuth; -import com.hivemq.client.mqtt.mqtt3.message.connect.Mqtt3Connect; -import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; -import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5SimpleAuth; -import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class AbstractMqttClientExecutorTest { - - private final @NotNull MqttClientExecutor mqttClientExecutor = new MqttClientExecutor(); - - private @NotNull ConnectOptions connectOptions; - private @NotNull ConnectRestrictionOptions connectRestrictionOptions; - private @NotNull AuthenticationOptions authenticationOptions; - private @NotNull WillOptions willOptions; - - @BeforeEach - void setUp() { - connectOptions = mock(ConnectOptions.class); - connectRestrictionOptions = mock(ConnectRestrictionOptions.class); - authenticationOptions = mock(AuthenticationOptions.class); - willOptions = mock(WillOptions.class); - when(connectOptions.getConnectRestrictionOptions()).thenReturn(connectRestrictionOptions); - when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); - when(connectOptions.getWillOptions()).thenReturn(willOptions); - when(connectOptions.getHost()).thenReturn("localhost"); - when(connectOptions.getIdentifier()).thenReturn("client"); - when(connectRestrictionOptions.getReceiveMaximum()).thenReturn(null); - when(connectRestrictionOptions.getSendMaximum()).thenReturn(null); - when(connectRestrictionOptions.getMaximumPacketSize()).thenReturn(null); - when(connectRestrictionOptions.getSendMaximumPacketSize()).thenReturn(null); - when(connectRestrictionOptions.getTopicAliasMaximum()).thenReturn(null); - when(connectRestrictionOptions.getSendTopicAliasMaximum()).thenReturn(null); - when(connectRestrictionOptions.getRequestProblemInformation()).thenReturn(null); - when(connectRestrictionOptions.getRequestResponseInformation()).thenReturn(null); - } - - @Test - void checkForSharedTopicDuplicate_noExisting_normalNew() { - final Set existingFilter = new HashSet<>(); - - final String newTopic = "a"; - final List duplicateList = - mqttClientExecutor.checkForSharedTopicDuplicate(existingFilter, newTopic); - assertTrue(duplicateList.isEmpty()); - } - - @Test - void checkForSharedTopicDuplicate_normalExisting_normalNew() { - final Set existingFilter = new HashSet<>(); - existingFilter.add(MqttTopicFilter.of("a")); - existingFilter.add(MqttTopicFilter.of("b")); - - final String newTopic = "a"; - final List duplicateList = - mqttClientExecutor.checkForSharedTopicDuplicate(existingFilter, newTopic); - assertFalse(duplicateList.isEmpty()); - assertEquals(MqttTopicFilter.of("a"), duplicateList.get(0)); - } - - @Test - void checkForSharedTopicDuplicate_normalExisting_sharedNew() { - final Set existingFilter = new HashSet<>(); - existingFilter.add(MqttTopicFilter.of("a")); - existingFilter.add(MqttTopicFilter.of("b")); - - final String newTopic = "$share/group/a"; - final List duplicateList = - mqttClientExecutor.checkForSharedTopicDuplicate(existingFilter, newTopic); - assertFalse(duplicateList.isEmpty()); - assertEquals(MqttTopicFilter.of("a"), duplicateList.get(0)); - //assertEquals(MqttSharedTopicFilter.of("group", "a"), duplicateList.get(0).getValue()); - } - - @Test - void checkForSharedTopicDuplicate_sharedExisting_normalNew() { - final Set existingFilter = new HashSet<>(); - existingFilter.add(MqttSharedTopicFilter.of("group", "a")); - existingFilter.add(MqttTopicFilter.of("b")); - - final String newTopic = "a"; - final List duplicateList = - mqttClientExecutor.checkForSharedTopicDuplicate(existingFilter, newTopic); - assertFalse(duplicateList.isEmpty()); - assertEquals(MqttSharedTopicFilter.of("group", "a"), duplicateList.get(0)); - //assertEquals(MqttTopicFilter.of("a"), duplicateList.get(0).getValue()); - } - - @Test - void checkForSharedTopicDuplicate_sharedExisting_sharedNew() { - final Set existingFilter = new HashSet<>(); - existingFilter.add(MqttSharedTopicFilter.of("group", "a")); - existingFilter.add(MqttTopicFilter.of("b")); - - final String newTopic = "$share/group/a"; - final List duplicateList = - mqttClientExecutor.checkForSharedTopicDuplicate(existingFilter, newTopic); - assertFalse(duplicateList.isEmpty()); - assertEquals(MqttSharedTopicFilter.of("group", "a"), duplicateList.get(0)); - //assertEquals(MqttSharedTopicFilter.of("group", "a"), duplicateList.get(0).getValue()); - } - - @Test - void checkForSharedTopicDuplicate_multipleDisjointSharedExisting_normalNew() { - final Set existingFilter = new HashSet<>(); - existingFilter.add(MqttSharedTopicFilter.of("group", "a")); - existingFilter.add(MqttSharedTopicFilter.of("group", "b")); - existingFilter.add(MqttTopicFilter.of("b")); - - final String newTopic = "a"; - final List duplicateList = - mqttClientExecutor.checkForSharedTopicDuplicate(existingFilter, newTopic); - assertEquals(1, duplicateList.size()); - assertEquals(MqttSharedTopicFilter.of("group", "a"), duplicateList.get(0)); - //assertEquals(MqttTopicFilter.of("a"), duplicateList.get(0).getValue()); - } - - @Test - void checkForSharedTopicDuplicate_multipleIntersectingSharedExisting_normalNew() { - final Set existingFilter = new HashSet<>(); - existingFilter.add(MqttSharedTopicFilter.of("group", "a")); - existingFilter.add(MqttSharedTopicFilter.of("group", "+")); - existingFilter.add(MqttTopicFilter.of("b")); - - final String newTopic = "a"; - final List duplicateList = - mqttClientExecutor.checkForSharedTopicDuplicate(existingFilter, newTopic); - assertEquals(2, duplicateList.size()); - assertTrue(duplicateList.stream().anyMatch(topicFilter -> { - //assertNotNull(topicFilter.getValue()); - return topicFilter.equals(MqttSharedTopicFilter.of("group", "+")); - })); - assertTrue(duplicateList.stream().anyMatch(topicFilter -> { - //assertNotNull(topicFilter.getValue()); - return topicFilter.equals(MqttSharedTopicFilter.of("group", "a")); - })); - //assertEquals(MqttTopicFilter.of("a"), duplicateList.get(0).getValue()); - //assertEquals(MqttTopicFilter.of("a"), duplicateList.get(1).getValue()); - } - - @Test - void checkForSharedTopicDuplicate_multipleIntersectingNormalExisting_SharedNew() { - final Set existingFilter = new HashSet<>(); - existingFilter.add(MqttTopicFilter.of("a")); - existingFilter.add(MqttTopicFilter.of("+")); - existingFilter.add(MqttTopicFilter.of("b")); - - final String newTopic = "$share/group/a"; - final List duplicateList = - mqttClientExecutor.checkForSharedTopicDuplicate(existingFilter, newTopic); - assertEquals(2, duplicateList.size()); - //assertEquals(MqttSharedTopicFilter.of("group", "a"), duplicateList.get(0).getValue()); - //assertEquals(MqttSharedTopicFilter.of("group", "a"), duplicateList.get(1).getValue()); - assertTrue(duplicateList.stream().anyMatch(topicFilter -> topicFilter.equals(MqttTopicFilter.of("a")))); - assertTrue(duplicateList.stream().anyMatch(topicFilter -> topicFilter.equals(MqttTopicFilter.of("+")))); - } - - @Test - void simpleAuth_whenNoAuthIsConfigured_thenNoAuthIsSet_Mqtt5() throws Exception { - when(connectOptions.getVersion()).thenReturn(MqttVersion.MQTT_5_0); - - final Mqtt5Client mqtt5Client = (Mqtt5Client) mqttClientExecutor.connect(connectOptions); - assertFalse(mqtt5Client.getConfig().getSimpleAuth().isPresent()); - } - - @Test - void simpleAuth_whenNoAuthIsConfigured_thenNoAuthIsSet_Mqtt3() throws Exception { - when(connectOptions.getVersion()).thenReturn(MqttVersion.MQTT_3_1_1); - - final Mqtt3Client mqtt5Client = (Mqtt3Client) mqttClientExecutor.connect(connectOptions); - assertFalse(mqtt5Client.getConfig().getSimpleAuth().isPresent()); - } - - @Test - void simpleAuth_whenUsernameIsConfigured_setUsername_Mqtt5() throws Exception { - when(connectOptions.getVersion()).thenReturn(MqttVersion.MQTT_5_0); - when(authenticationOptions.getUser()).thenReturn("Test"); - - mqttClientExecutor.connect(connectOptions); - - assertNotNull(mqttClientExecutor.getMqtt5ConnectMessage()); - final Optional simpleAuth = mqttClientExecutor.getMqtt5ConnectMessage().getSimpleAuth(); - assertTrue(simpleAuth.isPresent()); - assertTrue(simpleAuth.get().getUsername().isPresent()); - assertEquals("Test", simpleAuth.get().getUsername().get().toString()); - } - - @Test - void simpleAuth_whenUsernameIsConfigured_setUsername_Mqtt3() throws Exception { - when(connectOptions.getVersion()).thenReturn(MqttVersion.MQTT_3_1_1); - when(authenticationOptions.getUser()).thenReturn("Test"); - - mqttClientExecutor.connect(connectOptions); - - assertNotNull(mqttClientExecutor.getMqtt3ConnectMessage()); - final Optional simpleAuth = mqttClientExecutor.getMqtt3ConnectMessage().getSimpleAuth(); - assertTrue(simpleAuth.isPresent()); - assertEquals("Test", simpleAuth.get().getUsername().toString()); - } - - @Test - void simpleAuth_whenPasswordIsConfigured_setPassword_Mqtt5() throws Exception { - when(connectOptions.getVersion()).thenReturn(MqttVersion.MQTT_5_0); - when(authenticationOptions.getPassword()).thenReturn(ByteBuffer.wrap("Test".getBytes(StandardCharsets.UTF_8))); - - mqttClientExecutor.connect(connectOptions); - - assertNotNull(mqttClientExecutor.getMqtt5ConnectMessage()); - final Optional simpleAuth = mqttClientExecutor.getMqtt5ConnectMessage().getSimpleAuth(); - assertTrue(simpleAuth.isPresent()); - assertTrue(simpleAuth.get().getPassword().isPresent()); - assertEquals("Test", StandardCharsets.US_ASCII.decode(simpleAuth.get().getPassword().get()).toString()); - } - - @Test - void simpleAuth_whenPasswordIsConfigured_setPassword_Mqtt3() { - when(connectOptions.getVersion()).thenReturn(MqttVersion.MQTT_3_1_1); - when(authenticationOptions.getPassword()).thenReturn(ByteBuffer.wrap("Test".getBytes(StandardCharsets.UTF_8))); - assertThrows(IllegalArgumentException.class, () -> mqttClientExecutor.connect(connectOptions)); - } - - @Test - void simpleAuth_whenUserNameAndPasswordIsConfigured_setUsernameAndPassword_Mqtt5() throws Exception { - when(connectOptions.getVersion()).thenReturn(MqttVersion.MQTT_5_0); - when(authenticationOptions.getUser()).thenReturn("Test"); - when(authenticationOptions.getPassword()).thenReturn(ByteBuffer.wrap("Test".getBytes(StandardCharsets.UTF_8))); - - mqttClientExecutor.connect(connectOptions); - - assertNotNull(mqttClientExecutor.getMqtt5ConnectMessage()); - final Optional simpleAuth = mqttClientExecutor.getMqtt5ConnectMessage().getSimpleAuth(); - assertTrue(simpleAuth.isPresent()); - assertTrue(simpleAuth.get().getUsername().isPresent()); - assertEquals("Test", simpleAuth.get().getUsername().get().toString()); - assertTrue(simpleAuth.get().getPassword().isPresent()); - assertEquals("Test", StandardCharsets.US_ASCII.decode(simpleAuth.get().getPassword().get()).toString()); - } - - @Test - void simpleAuth_whenUserNameAndPasswordIsConfigured_setUsernameAndPassword_Mqtt3() throws Exception { - when(connectOptions.getVersion()).thenReturn(MqttVersion.MQTT_3_1_1); - when(authenticationOptions.getUser()).thenReturn("Test"); - when(authenticationOptions.getPassword()).thenReturn(ByteBuffer.wrap("Test".getBytes(StandardCharsets.UTF_8))); - - mqttClientExecutor.connect(connectOptions); - - assertNotNull(mqttClientExecutor.getMqtt3ConnectMessage()); - final Optional simpleAuth = mqttClientExecutor.getMqtt3ConnectMessage().getSimpleAuth(); - assertTrue(simpleAuth.isPresent()); - assertEquals("Test", simpleAuth.get().getUsername().toString()); - assertTrue(simpleAuth.get().getPassword().isPresent()); - assertEquals("Test", StandardCharsets.US_ASCII.decode(simpleAuth.get().getPassword().get()).toString()); - } - - static class MqttClientExecutor extends AbstractMqttClientExecutor { - - private @Nullable Mqtt5Connect mqtt5ConnectMessage = null; - private @Nullable Mqtt3Connect mqtt3ConnectMessage = null; - - @Override - void mqtt5Connect( - final @NotNull Mqtt5Client client, - final @NotNull Mqtt5Connect connectMessage) { - this.mqtt5ConnectMessage = connectMessage; - } - - @Override - void mqtt3Connect( - final @NotNull Mqtt3Client client, - final @NotNull Mqtt3Connect connectMessage) { - this.mqtt3ConnectMessage = connectMessage; - } - - @Override - void mqtt5Subscribe( - final @NotNull Mqtt5Client client, - final @NotNull SubscribeOptions subscribeOptions, - final @NotNull String topic, - final @NotNull MqttQos qos) {} - - @Override - void mqtt3Subscribe( - final @NotNull Mqtt3Client client, - final @NotNull SubscribeOptions subscribeOptions, - final @NotNull String topic, - final @NotNull MqttQos qos) {} - - @Override - void mqtt5Publish( - final @NotNull Mqtt5Client client, - final @NotNull PublishOptions publishOptions, - final @NotNull String topic, - final @NotNull MqttQos qos) {} - - @Override - void mqtt3Publish( - final @NotNull Mqtt3Client client, - final @NotNull PublishOptions publishOptions, - final @NotNull String topic, - final @NotNull MqttQos qos) {} - - @Override - void mqtt5Unsubscribe( - final @NotNull Mqtt5Client client, final @NotNull UnsubscribeOptions unsubscribeOptions) {} - - @Override - void mqtt3Unsubscribe( - final @NotNull Mqtt3Client client, final @NotNull UnsubscribeOptions unsubscribeOptions) {} - - @Override - void mqtt5Disconnect( - final @NotNull Mqtt5Client client, final @NotNull DisconnectOptions disconnectOptions) {} - - @Override - void mqtt3Disconnect( - final @NotNull Mqtt3Client client, final @NotNull DisconnectOptions disconnectOptions) {} - - public @Nullable Mqtt5Connect getMqtt5ConnectMessage() { - return mqtt5ConnectMessage; - } - - public @Nullable Mqtt3Connect getMqtt3ConnectMessage() { - return mqtt3ConnectMessage; - } - } -} \ No newline at end of file diff --git a/src/test/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientFactoryTest.java b/src/test/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientFactoryTest.java new file mode 100644 index 000000000..16464d395 --- /dev/null +++ b/src/test/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientFactoryTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.mqtt3; + +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.WillOptions; +import com.hivemq.client.internal.mqtt.datatypes.MqttTopicImpl; +import com.hivemq.client.internal.mqtt.message.publish.MqttPublish; +import com.hivemq.client.internal.mqtt.message.publish.mqtt3.Mqtt3PublishView; +import com.hivemq.client.mqtt.MqttVersion; +import com.hivemq.client.mqtt.datatypes.MqttClientIdentifier; +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener; +import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; +import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientConfig; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CliMqtt3ClientFactoryTest { + + private final ConnectOptions connectOptions = mock(ConnectOptions.class); + private final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + private final WillOptions willOptions = mock(WillOptions.class); + + @BeforeEach + void setUp() { + when(connectOptions.getHost()).thenReturn("test-host.com"); + when(connectOptions.getPort()).thenReturn(1883); + when(connectOptions.getWillOptions()).thenReturn(willOptions); + } + + @Test + void create_whenClientIdIsMissing_thenClientIdIsAssigned() throws Exception { + final CliMqtt3Client client = CliMqtt3ClientFactory.create(connectOptions, null, new ArrayList<>()); + + assertFalse(client.getDelegate().getConfig().getClientIdentifier().isPresent()); + assertClient(client); + } + + @Test + void create_whenClientIdIsPresent_thenClientIdIsUsed() throws Exception { + when(connectOptions.getIdentifier()).thenReturn("test-client"); + final CliMqtt3Client client = CliMqtt3ClientFactory.create(connectOptions, null, new ArrayList<>()); + + assertThat(client.getDelegate().getConfig().getClientIdentifier()).contains(MqttClientIdentifier.of( + "test-client")); + assertClient(client); + } + + @Test + void create_whenWillIsPresent_thenWillIsUsed() throws Exception { + when(willOptions.getWillTopic()).thenReturn("will-topic"); + when(willOptions.getWillMessage()).thenReturn(ByteBuffer.wrap("will-message".getBytes(StandardCharsets.UTF_8))); + when(willOptions.getWillRetain()).thenReturn(true); + when(willOptions.getWillQos()).thenReturn(MqttQos.AT_MOST_ONCE); + + final CliMqtt3Client client = CliMqtt3ClientFactory.create(connectOptions, null, new ArrayList<>()); + + assertClient(client); + + final Mqtt3Client delegate = client.getDelegate(); + final MqttPublish publishDelegate = Mqtt3PublishView.delegate(MqttTopicImpl.of("will-topic"), + ByteBuffer.wrap("will-message".getBytes(StandardCharsets.UTF_8)), + MqttQos.AT_MOST_ONCE, + true); + final Mqtt3PublishView expectedWillPublish = Mqtt3PublishView.of(publishDelegate.asWill()); + assertFalse(delegate.getConfig().getClientIdentifier().isPresent()); + + assertThat(delegate.getConfig().getWillPublish()).contains(expectedWillPublish); + } + + @Test + void create_whenWillMessageIsNotNull_thenThrowsIllegalArgumentException() { + when(willOptions.getWillMessage()).thenReturn(ByteBuffer.wrap("will-message".getBytes(StandardCharsets.UTF_8))); + + assertThrows(IllegalArgumentException.class, + () -> CliMqtt3ClientFactory.create(connectOptions, null, new ArrayList<>()), + "option -wt is missing if a will message is configured - will options were: " + willOptions); + } + + @Test + void create_whenDisconnectedListenerIsPresent_thenDisconnectedListenerIsUsed() throws Exception { + final ArrayList disconnectedListeners = new ArrayList<>(); + disconnectedListeners.add(context -> {}); + disconnectedListeners.add(context -> {}); + disconnectedListeners.add(context -> {}); + + final CliMqtt3Client client = CliMqtt3ClientFactory.create(connectOptions, null, disconnectedListeners); + assertClient(client); + + assertEquals(3, client.getDelegate().getConfig().getDisconnectedListeners().size()); + } + + @Test + void create_whenSubscribeOptionsArePresent_thenOptionsAreUsedForSubscribeCallback() throws Exception { + final CliMqtt3Client client = CliMqtt3ClientFactory.create(connectOptions, subscribeOptions, new ArrayList<>()); + assertClient(client); + } + + private static void assertClient(final @NotNull CliMqtt3Client client) { + final Mqtt3Client delegate = client.getDelegate(); + final Mqtt3ClientConfig config = delegate.getConfig(); + + assertEquals("test-host.com", config.getServerHost()); + assertEquals(1883, config.getServerPort()); + assertEquals(MqttVersion.MQTT_3_1_1, config.getMqttVersion()); + assertFalse(config.getSslConfig().isPresent()); + } +} diff --git a/src/test/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientTest.java b/src/test/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientTest.java new file mode 100644 index 000000000..2266adaa7 --- /dev/null +++ b/src/test/java/com/hivemq/cli/mqtt/clients/mqtt3/CliMqtt3ClientTest.java @@ -0,0 +1,552 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.mqtt3; + +import com.hivemq.cli.commands.options.AuthenticationOptions; +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.DisconnectOptions; +import com.hivemq.cli.commands.options.PublishOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.UnsubscribeOptions; +import com.hivemq.cli.mqtt.clients.listeners.SubscribeMqtt3PublishCallback; +import com.hivemq.cli.utils.LoggerUtils; +import com.hivemq.client.internal.mqtt.message.connect.connack.mqtt3.Mqtt3ConnAckView; +import com.hivemq.client.internal.mqtt.message.subscribe.suback.mqtt3.Mqtt3SubAckView; +import com.hivemq.client.internal.util.collections.ImmutableList; +import com.hivemq.client.mqtt.MqttClientSslConfig; +import com.hivemq.client.mqtt.MqttClientState; +import com.hivemq.client.mqtt.MqttVersion; +import com.hivemq.client.mqtt.datatypes.MqttClientIdentifier; +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3BlockingClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; +import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientConfig; +import com.hivemq.client.mqtt.mqtt3.message.connect.Mqtt3Connect; +import com.hivemq.client.mqtt.mqtt3.message.connect.connack.Mqtt3ConnAck; +import com.hivemq.client.mqtt.mqtt3.message.connect.connack.Mqtt3ConnAckReturnCode; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; +import com.hivemq.client.mqtt.mqtt3.message.subscribe.Mqtt3Subscribe; +import com.hivemq.client.mqtt.mqtt3.message.subscribe.suback.Mqtt3SubAck; +import com.hivemq.client.mqtt.mqtt3.message.subscribe.suback.Mqtt3SubAckReturnCode; +import com.hivemq.client.mqtt.mqtt3.message.unsubscribe.Mqtt3Unsubscribe; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemErrAndOut; +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemOut; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CliMqtt3ClientTest { + + private static final @NotNull String CLIENT_ID = "test-client"; + private static final @NotNull String HOSTNAME = "test-broker.com"; + + private final @NotNull Mqtt3Client delegate = mock(Mqtt3Client.class); + private final @NotNull Mqtt3BlockingClient blockingDelegate = mock(Mqtt3BlockingClient.class); + private final @NotNull Mqtt3AsyncClient asyncDelegate = mock(Mqtt3AsyncClient.class); + private final @NotNull Mqtt3ClientConfig config = mock(Mqtt3ClientConfig.class); + private final @NotNull CliMqtt3Client client = new CliMqtt3Client(delegate); + + private String clientLogPreamble; + + + @BeforeEach + void setUp() { + when(delegate.getConfig()).thenReturn(config); + when(delegate.toBlocking()).thenReturn(blockingDelegate); + when(delegate.toAsync()).thenReturn(asyncDelegate); + when(config.getClientIdentifier()).thenReturn(Optional.of(MqttClientIdentifier.of(CLIENT_ID))); + when(config.getServerHost()).thenReturn(HOSTNAME); + when(config.getMqttVersion()).thenReturn(MqttVersion.MQTT_3_1_1); + when(config.getServerPort()).thenReturn(1883); + this.clientLogPreamble = LoggerUtils.getClientPrefix(config); + } + + @Test + void connect_success() throws Exception { + final Mqtt3ConnAck connack = + Mqtt3ConnAckView.of(Mqtt3ConnAckView.delegate(Mqtt3ConnAckReturnCode.SUCCESS, false)); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + when(blockingDelegate.connect(any(Mqtt3Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + + final String output = tapSystemOut(() -> client.connect(connectOptions)); + + verify(blockingDelegate).connect(any(Mqtt3Connect.class)); + assertThat(output).contains(clientLogPreamble + + " sending CONNECT MqttConnect{keepAlive=0, cleanSession=false, restrictions=MqttConnectRestrictions{receiveMaximum=65535, sendMaximum=65535, maximumPacketSize=268435460, sendMaximumPacketSize=268435460, topicAliasMaximum=0, sendTopicAliasMaximum=16, requestProblemInformation=true, requestResponseInformation=false}}"); + assertThat(output).contains(clientLogPreamble + + " received CONNACK MqttConnAck{returnCode=SUCCESS, sessionPresent=false} "); + } + + @Test + void connect_whenUsernameAndPasswordArePresent_thenConnectIsSentWithUsernameAndPassword() throws Exception { + final Mqtt3ConnAck connack = + Mqtt3ConnAckView.of(Mqtt3ConnAckView.delegate(Mqtt3ConnAckReturnCode.SUCCESS, false)); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + when(blockingDelegate.connect(any(Mqtt3Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + when(authenticationOptions.getUser()).thenReturn("user"); + when(authenticationOptions.getPassword()).thenReturn(ByteBuffer.wrap("password".getBytes(StandardCharsets.UTF_8))); + + final String output = tapSystemOut(() -> client.connect(connectOptions)); + + verify(blockingDelegate).connect(any(Mqtt3Connect.class)); + assertThat(output).contains(clientLogPreamble + + " sending CONNECT MqttConnect{keepAlive=0, cleanSession=false, restrictions=MqttConnectRestrictions{receiveMaximum=65535, sendMaximum=65535, maximumPacketSize=268435460, sendMaximumPacketSize=268435460, topicAliasMaximum=0, sendTopicAliasMaximum=16, requestProblemInformation=true, requestResponseInformation=false}, simpleAuth=MqttSimpleAuth{username and password}}"); + assertThat(output).contains(clientLogPreamble + + " received CONNACK MqttConnAck{returnCode=SUCCESS, sessionPresent=false}"); + } + + @Test + void connect_whenUsernameIsPresent_thenConnectIsSentWithUsername() throws Exception { + final Mqtt3ConnAck connack = + Mqtt3ConnAckView.of(Mqtt3ConnAckView.delegate(Mqtt3ConnAckReturnCode.SUCCESS, false)); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + when(blockingDelegate.connect(any(Mqtt3Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + when(authenticationOptions.getUser()).thenReturn("user"); + + final String output = tapSystemOut(() -> client.connect(connectOptions)); + + verify(blockingDelegate).connect(any(Mqtt3Connect.class)); + assertThat(output).contains(clientLogPreamble + + " sending CONNECT MqttConnect{keepAlive=0, cleanSession=false, restrictions=MqttConnectRestrictions{receiveMaximum=65535, sendMaximum=65535, maximumPacketSize=268435460, sendMaximumPacketSize=268435460, topicAliasMaximum=0, sendTopicAliasMaximum=16, requestProblemInformation=true, requestResponseInformation=false}, simpleAuth=MqttSimpleAuth{username}}"); + assertThat(output).contains(clientLogPreamble + + " received CONNACK MqttConnAck{returnCode=SUCCESS, sessionPresent=false}"); + } + + @Test + void connect_whenOnlyPasswordIsPresent_thenThrowsIllegalArgumentException() { + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + when(authenticationOptions.getPassword()).thenReturn(ByteBuffer.wrap("password".getBytes(StandardCharsets.UTF_8))); + + assertThrows(IllegalArgumentException.class, + () -> client.connect(connectOptions), + "Password-Only Authentication is not allowed in MQTT 3"); + + verify(blockingDelegate, times(0)).connect(any(Mqtt3Connect.class)); + } + + @Test + void publish_whenOneTopic_thenOnePublishIsSent() throws Exception { + final PublishOptions publishOptions = mock(PublishOptions.class); + when(publishOptions.getMessage()).thenReturn(ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8))); + when(publishOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(publishOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + when(asyncDelegate.publish(any(Mqtt3Publish.class))).thenReturn(CompletableFuture.completedFuture(Mqtt3Publish.builder() + .topic("topic") + .qos(MqttQos.EXACTLY_ONCE) + .build())); + + final String output = tapSystemOut(() -> client.publish(publishOptions)); + + verify(asyncDelegate).publish(any(Mqtt3Publish.class)); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic, payload=4byte, qos=EXACTLY_ONCE, retain=false}"); + assertThat(output).contains(clientLogPreamble + + " received PUBLISH acknowledgement MqttPublish{topic=topic, qos=EXACTLY_ONCE, retain=false}"); + } + + @Test + void publish_whenMultipleTopicsAndMultipleQos_thenMultiplePublishesAreSent() throws Exception { + final PublishOptions publishOptions = mock(PublishOptions.class); + when(publishOptions.getMessage()).thenReturn(ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8))); + when(publishOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(publishOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(asyncDelegate.publish(any(Mqtt3Publish.class))).thenReturn(CompletableFuture.completedFuture(Mqtt3Publish.builder() + .topic("topic1") + .qos(MqttQos.AT_MOST_ONCE) + .build())) + .thenReturn(CompletableFuture.completedFuture(Mqtt3Publish.builder() + .topic("topic2") + .qos(MqttQos.AT_LEAST_ONCE) + .build())) + .thenReturn(CompletableFuture.completedFuture(Mqtt3Publish.builder() + .topic("topic3") + .qos(MqttQos.EXACTLY_ONCE) + .build())); + + final String output = tapSystemOut(() -> client.publish(publishOptions)); + + verify(asyncDelegate, times(3)).publish(any(Mqtt3Publish.class)); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic1, payload=4byte, qos=AT_MOST_ONCE, retain=false}"); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic2, payload=4byte, qos=AT_LEAST_ONCE, retain=false}"); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic3, payload=4byte, qos=EXACTLY_ONCE, retain=false}"); + assertThat(output).contains(clientLogPreamble + + " received PUBLISH acknowledgement MqttPublish{topic=topic1, qos=AT_MOST_ONCE, retain=false}"); + assertThat(output).contains(clientLogPreamble + + " received PUBLISH acknowledgement MqttPublish{topic=topic2, qos=AT_LEAST_ONCE, retain=false}"); + assertThat(output).contains(clientLogPreamble + + " received PUBLISH acknowledgement MqttPublish{topic=topic3, qos=EXACTLY_ONCE, retain=false}"); + } + + @Test + void publish_whenTopicsSizeDoesNotMatchQosSize_thenThrowsIllegalArgumentException() { + final PublishOptions publishOptions = mock(PublishOptions.class); + when(publishOptions.getMessage()).thenReturn(ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8))); + when(publishOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2"}); + when(publishOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + when(asyncDelegate.publish(any(Mqtt3Publish.class))).thenReturn(CompletableFuture.completedFuture(Mqtt3Publish.builder() + .topic("topic") + .qos(MqttQos.EXACTLY_ONCE) + .build())); + + assertThrows(IllegalArgumentException.class, + () -> client.publish(publishOptions), + "Topics size (2) does not match QoS size (1)"); + verify(asyncDelegate, times(0)).publish(any(Mqtt3Publish.class)); + } + + @Test + void publish_whenPublishFails_thenThrowsCompletionException() throws Exception { + final PublishOptions publishOptions = mock(PublishOptions.class); + when(publishOptions.getMessage()).thenReturn(ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8))); + when(publishOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(publishOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new NullPointerException("failed")); + when(asyncDelegate.publish(any(Mqtt3Publish.class))).thenReturn(future); + + final String output = + tapSystemErrAndOut(() -> assertThrows(CompletionException.class, () -> client.publish(publishOptions))); + + verify(asyncDelegate).publish(any(Mqtt3Publish.class)); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic, payload=4byte, qos=EXACTLY_ONCE, retain=false}"); + assertThat(output).contains(clientLogPreamble + + " failed PUBLISH to topic 'topic': failed: java.lang.NullPointerException: failed"); + } + + @Test + void subscribe_whenOneTopic_thenOneSubscribeIsSent() throws Exception { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final Mqtt3SubAck suback = Mqtt3SubAckView.of(Mqtt3SubAckView.delegate(1, + ImmutableList.of(Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_2))); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + when(asyncDelegate.subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + final String output = tapSystemOut(() -> client.subscribe(subscribeOptions)); + + verify(asyncDelegate, times(1)).subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class)); + assertThat(output).contains(clientLogPreamble + + " sending SUBSCRIBE MqttSubscribe{subscriptions=[MqttSubscription{topicFilter=topic, qos=EXACTLY_ONCE}]}"); + assertThat(output).contains(clientLogPreamble + + " received SUBACK MqttSubAck{returnCodes=[SUCCESS_MAXIMUM_QOS_2]}"); + } + + @Test + void subscribe_whenMultipleTopicsAndQos_thenOneSubscribeIsSent() throws Exception { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final Mqtt3SubAck suback = Mqtt3SubAckView.of(Mqtt3SubAckView.delegate(1, + ImmutableList.of(Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_0, + Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_1, + Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_2))); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(asyncDelegate.subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + final String output = tapSystemOut(() -> client.subscribe(subscribeOptions)); + + verify(asyncDelegate, times(1)).subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class)); + assertThat(output).contains(clientLogPreamble + + " sending SUBSCRIBE MqttSubscribe{subscriptions=[MqttSubscription{topicFilter=topic1, qos=AT_MOST_ONCE}, MqttSubscription{topicFilter=topic2, qos=AT_LEAST_ONCE}, MqttSubscription{topicFilter=topic3, qos=EXACTLY_ONCE}]}"); + assertThat(output).contains(clientLogPreamble + + " received SUBACK MqttSubAck{returnCodes=[SUCCESS_MAXIMUM_QOS_0, SUCCESS_MAXIMUM_QOS_1, SUCCESS_MAXIMUM_QOS_2]}"); + } + + @Test + void subscribe_whenTopicsSizeDoesNotMatchQosSize_thenThrowsIllegalArgumentException() { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.AT_MOST_ONCE}); + + assertThrows(IllegalArgumentException.class, + () -> client.subscribe(subscribeOptions), + "Topics size (2) does not match QoS size (1)"); + verify(asyncDelegate, times(0)).subscribe(any(Mqtt3Subscribe.class)); + } + + @Test + void subscribe_whenSubscribeFails_thenThrowsCompletionsException() throws Exception { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new NullPointerException("failed")); + when(asyncDelegate.subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class))).thenReturn( + future); + + final String output = tapSystemErrAndOut(() -> assertThrows(CompletionException.class, + () -> client.subscribe(subscribeOptions), + "failed")); + + verify(asyncDelegate, times(1)).subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class)); + assertThat(output).contains(clientLogPreamble + + " sending SUBSCRIBE MqttSubscribe{subscriptions=[MqttSubscription{topicFilter=topic, qos=EXACTLY_ONCE}]}"); + assertThat(output).contains(clientLogPreamble + + " failed SUBSCRIBE to topic(s) '[topic]': failed: java.lang.NullPointerException: failed"); + } + + @Test + void unsubscribe_whenOneTopic_thenOneUnsubscribeIsSent() throws Exception { + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(asyncDelegate.unsubscribe(any(Mqtt3Unsubscribe.class))).thenReturn(CompletableFuture.completedFuture(null)); + + final String output = tapSystemOut(() -> client.unsubscribe(unsubscribeOptions)); + + verify(asyncDelegate, times(1)).unsubscribe(any(Mqtt3Unsubscribe.class)); + assertThat(output).contains(clientLogPreamble + " sending UNSUBSCRIBE MqttUnsubscribe{topicFilters=[topic]}"); + assertThat(output).contains(clientLogPreamble + " received UNSUBACK"); + } + + @Test + void unsubscribe_whenMultipleTopics_thenOneUnsubscribeIsSent() throws Exception { + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(asyncDelegate.unsubscribe(any(Mqtt3Unsubscribe.class))).thenReturn(CompletableFuture.completedFuture(null)); + + final String output = tapSystemOut(() -> client.unsubscribe(unsubscribeOptions)); + + verify(asyncDelegate, times(1)).unsubscribe(any(Mqtt3Unsubscribe.class)); + assertThat(output).contains(clientLogPreamble + + " sending UNSUBSCRIBE MqttUnsubscribe{topicFilters=[topic1, topic2, topic3]}"); + assertThat(output).contains(clientLogPreamble + " received UNSUBACK"); + } + + @Test + void unsubscribe_whenUnsubscribeFails_thenThrowsCompletionException() throws Exception { + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic"}); + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new NullPointerException("failed")); + when(asyncDelegate.unsubscribe(any(Mqtt3Unsubscribe.class))).thenReturn(future); + + final String output = tapSystemErrAndOut(() -> assertThrows(CompletionException.class, + () -> client.unsubscribe(unsubscribeOptions), + "failed")); + + verify(asyncDelegate, times(1)).unsubscribe(any(Mqtt3Unsubscribe.class)); + assertThat(output).contains(clientLogPreamble + " sending UNSUBSCRIBE MqttUnsubscribe{topicFilters=[topic]}"); + assertThat(output).contains(clientLogPreamble + + " failed UNSUBSCRIBE from topic(s) '[topic]': failed: java.lang.NullPointerException: failed"); + } + + @Test + void disconnect_success() throws Exception { + final DisconnectOptions disconnectOptions = mock(DisconnectOptions.class); + when(asyncDelegate.disconnect()).thenReturn(CompletableFuture.completedFuture(null)); + + final String output = tapSystemOut(() -> client.disconnect(disconnectOptions)); + + verify(asyncDelegate, times(1)).disconnect(); + assertThat(output).contains(clientLogPreamble + " sending DISCONNECT"); + assertThat(output).contains(clientLogPreamble + " disconnected successfully"); + } + + @Test + void disconnect_whenDisconnectFails_thenThrowsCompletionException() throws Exception { + final DisconnectOptions disconnectOptions = mock(DisconnectOptions.class); + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new NullPointerException("failed")); + when(asyncDelegate.disconnect()).thenReturn(future); + + final String output = tapSystemErrAndOut(() -> assertThrows(CompletionException.class, + () -> client.disconnect(disconnectOptions), + "failed")); + + verify(asyncDelegate, times(1)).disconnect(); + assertThat(output).contains(clientLogPreamble + " sending DISCONNECT"); + assertThat(output).contains(clientLogPreamble + + " failed to DISCONNECT gracefully: failed: java.lang.NullPointerException: failed"); + } + + @Test + void isConnected_success() { + when(delegate.getState()).thenReturn(MqttClientState.CONNECTED); + assertTrue(client.isConnected()); + } + + @Test + void getClientIdentifier_success() { + assertEquals(CLIENT_ID, client.getClientIdentifier()); + } + + @Test + void getServerHost_success() { + assertEquals(HOSTNAME, client.getServerHost()); + } + + @Test + void getMqttVersion_success() { + assertEquals(MqttVersion.MQTT_3_1_1, client.getMqttVersion()); + } + + @Test + void getConnectedAt_success() { + final Mqtt3ConnAck connack = + Mqtt3ConnAckView.of(Mqtt3ConnAckView.delegate(Mqtt3ConnAckReturnCode.SUCCESS, false)); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + when(blockingDelegate.connect(any(Mqtt3Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + + client.connect(connectOptions); + assertNotNull(client.getConnectedAt()); + } + + @Test + void getConnectedAt_whenNotConnected_thenThrowsIllegalStateException() { + assertThrows(IllegalStateException.class, + client::getConnectedAt, + "connectedAt must not be null after a client has connected successfully"); + } + + @Test + void getState_success() { + when(delegate.getState()).thenReturn(MqttClientState.DISCONNECTED); + assertEquals(MqttClientState.DISCONNECTED, client.getState()); + } + + @Test + void getSslProtocols_whenProtocolsArePresent_thenListStringIsReturned() { + final MqttClientSslConfig sslConfig = mock(MqttClientSslConfig.class); + when(sslConfig.getProtocols()).thenReturn(Optional.of(ImmutableList.of("TLS_1_2", "TLS_1_3"))); + when(config.getSslConfig()).thenReturn(Optional.of(sslConfig)); + + assertEquals("[TLS_1_2, TLS_1_3]", client.getSslProtocols()); + } + + @Test + void getSslProtocols_whenNoSslConfigIsPresent_thenNoSslStringIsReturned() { + assertEquals("NO_SSL", client.getSslProtocols()); + } + + @Test + void getServerPort_success() { + assertEquals(1883, client.getServerPort()); + } + + @Test + void getSubscribedTopics_whenSuccessfullySubscribed_thenSubscriptionsArePresent() { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final Mqtt3SubAck suback = Mqtt3SubAckView.of(Mqtt3SubAckView.delegate(1, + ImmutableList.of(Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_0, + Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_1, + Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_2))); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(asyncDelegate.subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + client.subscribe(subscribeOptions); + final List subscribedTopics = client.getSubscribedTopics(); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic1")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic2")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic3")); + } + + @Test + void getSubscribedTopics_whenSuccessfullySubscribedAndUnsubscribed_thenNoSubscriptionIsPresent() { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final Mqtt3SubAck suback = Mqtt3SubAckView.of(Mqtt3SubAckView.delegate(1, + ImmutableList.of(Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_0, + Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_1, + Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_2))); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(asyncDelegate.subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(asyncDelegate.unsubscribe(any(Mqtt3Unsubscribe.class))).thenReturn(CompletableFuture.completedFuture(null)); + + + client.subscribe(subscribeOptions); + final List subscribedTopics = client.getSubscribedTopics(); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic1")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic2")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic3")); + + client.unsubscribe(unsubscribeOptions); + assertThat(client.getSubscribedTopics()).isEmpty(); + } + + @Test + void getSubscribedTopics_whenSuccessfullySubscribedAndPartiallyUnsubscribed_thenNotUnsubscribedSubscriptionsArePresent() { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final Mqtt3SubAck suback = Mqtt3SubAckView.of(Mqtt3SubAckView.delegate(1, + ImmutableList.of(Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_0, + Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_1, + Mqtt3SubAckReturnCode.SUCCESS_MAXIMUM_QOS_2))); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(asyncDelegate.subscribe(any(Mqtt3Subscribe.class), any(SubscribeMqtt3PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic3"}); + when(asyncDelegate.unsubscribe(any(Mqtt3Unsubscribe.class))).thenReturn(CompletableFuture.completedFuture(null)); + + + client.subscribe(subscribeOptions); + final List subscribedTopics = client.getSubscribedTopics(); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic1")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic2")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic3")); + + client.unsubscribe(unsubscribeOptions); + assertThat(client.getSubscribedTopics()).containsExactly(MqttTopicFilter.of("topic2")); + } + +} diff --git a/src/test/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientFactoryTest.java b/src/test/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientFactoryTest.java new file mode 100644 index 000000000..0b3a405c0 --- /dev/null +++ b/src/test/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientFactoryTest.java @@ -0,0 +1,144 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.mqtt5; + +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.WillOptions; +import com.hivemq.client.internal.mqtt.datatypes.MqttTopicImpl; +import com.hivemq.client.internal.mqtt.datatypes.MqttUserPropertiesImpl; +import com.hivemq.client.internal.mqtt.message.publish.MqttWillPublish; +import com.hivemq.client.mqtt.MqttVersion; +import com.hivemq.client.mqtt.datatypes.MqttClientIdentifier; +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientConfig; +import com.hivemq.client.mqtt.mqtt5.datatypes.Mqtt5UserProperties; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CliMqtt5ClientFactoryTest { + + private final ConnectOptions connectOptions = mock(ConnectOptions.class); + private final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + private final WillOptions willOptions = mock(WillOptions.class); + + @BeforeEach + void setUp() { + when(connectOptions.getHost()).thenReturn("test-host.com"); + when(connectOptions.getPort()).thenReturn(1883); + when(connectOptions.getWillOptions()).thenReturn(willOptions); + } + + @Test + void create_whenClientIdIsMissing_thenClientIdIsAssigned() throws Exception { + final CliMqtt5Client client = CliMqtt5ClientFactory.create(connectOptions, null, new ArrayList<>()); + + assertFalse(client.getDelegate().getConfig().getClientIdentifier().isPresent()); + assertClient(client); + } + + @Test + void create_whenClientIdIsPresent_thenClientIdIsUsed() throws Exception { + when(connectOptions.getIdentifier()).thenReturn("test-client"); + final CliMqtt5Client client = CliMqtt5ClientFactory.create(connectOptions, null, new ArrayList<>()); + + assertThat(client.getDelegate().getConfig().getClientIdentifier()).contains(MqttClientIdentifier.of( + "test-client")); + assertClient(client); + } + + @Test + void create_whenWillIsPresent_thenWillIsUsed() throws Exception { + when(willOptions.getWillTopic()).thenReturn("will-topic"); + when(willOptions.getWillMessage()).thenReturn(ByteBuffer.wrap("will-message".getBytes(StandardCharsets.UTF_8))); + when(willOptions.getWillRetain()).thenReturn(true); + when(willOptions.getWillQos()).thenReturn(MqttQos.AT_MOST_ONCE); + when(willOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + + final CliMqtt5Client client = CliMqtt5ClientFactory.create(connectOptions, null, new ArrayList<>()); + + assertClient(client); + + final Mqtt5Client delegate = client.getDelegate(); + final MqttWillPublish expectedWill = new MqttWillPublish(MqttTopicImpl.of("will-topic"), + ByteBuffer.wrap("will-message".getBytes(StandardCharsets.UTF_8)), + MqttQos.AT_MOST_ONCE, + true, + 0L, + null, + null, + null, + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES, + 0L); + + assertFalse(delegate.getConfig().getClientIdentifier().isPresent()); + + assertThat(delegate.getConfig().getWillPublish()).contains(expectedWill); + } + + @Test + void create_whenWillMessageIsNotNull_thenThrowsIllegalArgumentException() { + when(willOptions.getWillMessage()).thenReturn(ByteBuffer.wrap("will-message".getBytes(StandardCharsets.UTF_8))); + + assertThrows(IllegalArgumentException.class, + () -> CliMqtt5ClientFactory.create(connectOptions, null, new ArrayList<>()), + "option -wt is missing if a will message is configured - will options were: " + willOptions); + } + + @Test + void create_whenDisconnectedListenerIsPresent_thenDisconnectedListenerIsUsed() throws Exception { + final ArrayList disconnectedListeners = new ArrayList<>(); + disconnectedListeners.add(context -> {}); + disconnectedListeners.add(context -> {}); + disconnectedListeners.add(context -> {}); + + final CliMqtt5Client client = CliMqtt5ClientFactory.create(connectOptions, null, disconnectedListeners); + assertClient(client); + + assertEquals(3, client.getDelegate().getConfig().getDisconnectedListeners().size()); + } + + @Test + void create_whenSubscribeOptionsArePresent_thenOptionsAreUsedForSubscribeCallback() throws Exception { + final CliMqtt5Client client = CliMqtt5ClientFactory.create(connectOptions, subscribeOptions, new ArrayList<>()); + assertClient(client); + } + + private static void assertClient(final @NotNull CliMqtt5Client client) { + final Mqtt5Client delegate = client.getDelegate(); + final Mqtt5ClientConfig config = delegate.getConfig(); + + assertEquals("test-host.com", config.getServerHost()); + assertEquals(1883, config.getServerPort()); + assertEquals(MqttVersion.MQTT_5_0, config.getMqttVersion()); + assertFalse(config.getSslConfig().isPresent()); + } +} diff --git a/src/test/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientTest.java b/src/test/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientTest.java new file mode 100644 index 000000000..a7ba9d0cb --- /dev/null +++ b/src/test/java/com/hivemq/cli/mqtt/clients/mqtt5/CliMqtt5ClientTest.java @@ -0,0 +1,674 @@ +/* + * Copyright 2019-present HiveMQ and the HiveMQ Community + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.cli.mqtt.clients.mqtt5; + +import com.hivemq.cli.commands.options.AuthenticationOptions; +import com.hivemq.cli.commands.options.ConnectOptions; +import com.hivemq.cli.commands.options.ConnectRestrictionOptions; +import com.hivemq.cli.commands.options.DisconnectOptions; +import com.hivemq.cli.commands.options.PublishOptions; +import com.hivemq.cli.commands.options.SubscribeOptions; +import com.hivemq.cli.commands.options.UnsubscribeOptions; +import com.hivemq.cli.mqtt.clients.listeners.SubscribeMqtt5PublishCallback; +import com.hivemq.cli.utils.LoggerUtils; +import com.hivemq.client.internal.mqtt.datatypes.MqttClientIdentifierImpl; +import com.hivemq.client.internal.mqtt.datatypes.MqttTopicImpl; +import com.hivemq.client.internal.mqtt.datatypes.MqttUserPropertiesImpl; +import com.hivemq.client.internal.mqtt.message.connect.connack.MqttConnAck; +import com.hivemq.client.internal.mqtt.message.connect.connack.MqttConnAckRestrictions; +import com.hivemq.client.internal.mqtt.message.publish.MqttPublish; +import com.hivemq.client.internal.mqtt.message.publish.MqttPublishResult; +import com.hivemq.client.internal.mqtt.message.subscribe.suback.MqttSubAck; +import com.hivemq.client.internal.mqtt.message.unsubscribe.unsuback.MqttUnsubAck; +import com.hivemq.client.internal.util.collections.ImmutableList; +import com.hivemq.client.mqtt.MqttClientSslConfig; +import com.hivemq.client.mqtt.MqttClientState; +import com.hivemq.client.mqtt.MqttVersion; +import com.hivemq.client.mqtt.datatypes.MqttClientIdentifier; +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.datatypes.MqttTopicFilter; +import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient; +import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientConfig; +import com.hivemq.client.mqtt.mqtt5.datatypes.Mqtt5UserProperties; +import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect; +import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAck; +import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAckReasonCode; +import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5Disconnect; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5PublishResult; +import com.hivemq.client.mqtt.mqtt5.message.subscribe.Mqtt5Subscribe; +import com.hivemq.client.mqtt.mqtt5.message.subscribe.suback.Mqtt5SubAck; +import com.hivemq.client.mqtt.mqtt5.message.subscribe.suback.Mqtt5SubAckReasonCode; +import com.hivemq.client.mqtt.mqtt5.message.unsubscribe.Mqtt5Unsubscribe; +import com.hivemq.client.mqtt.mqtt5.message.unsubscribe.unsuback.Mqtt5UnsubAck; +import com.hivemq.client.mqtt.mqtt5.message.unsubscribe.unsuback.Mqtt5UnsubAckReasonCode; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemErrAndOut; +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemOut; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CliMqtt5ClientTest { + + private static final @NotNull String CLIENT_ID = "test-client"; + private static final @NotNull String HOSTNAME = "test-broker.com"; + + private final @NotNull Mqtt5Client delegate = mock(Mqtt5Client.class); + private final @NotNull Mqtt5BlockingClient blockingDelegate = mock(Mqtt5BlockingClient.class); + private final @NotNull Mqtt5AsyncClient asyncDelegate = mock(Mqtt5AsyncClient.class); + private final @NotNull Mqtt5ClientConfig config = mock(Mqtt5ClientConfig.class); + private final @NotNull CliMqtt5Client client = new CliMqtt5Client(delegate); + + private String clientLogPreamble; + + + @BeforeEach + void setUp() { + when(delegate.getConfig()).thenReturn(config); + when(delegate.toBlocking()).thenReturn(blockingDelegate); + when(delegate.toAsync()).thenReturn(asyncDelegate); + when(config.getClientIdentifier()).thenReturn(Optional.of(MqttClientIdentifier.of(CLIENT_ID))); + when(config.getServerHost()).thenReturn(HOSTNAME); + when(config.getMqttVersion()).thenReturn(MqttVersion.MQTT_3_1_1); + when(config.getServerPort()).thenReturn(1883); + this.clientLogPreamble = LoggerUtils.getClientPrefix(config); + } + + @Test + void connect_success() throws Exception { + final Mqtt5ConnAck connack = new MqttConnAck(Mqtt5ConnAckReasonCode.SUCCESS, + false, + 100L, + 100, + MqttClientIdentifierImpl.of(CLIENT_ID), + null, + MqttConnAckRestrictions.DEFAULT, + null, + null, + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + final ConnectRestrictionOptions connectRestrictionOptions = new ConnectRestrictionOptions(); + when(blockingDelegate.connect(any(Mqtt5Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + when(connectOptions.getConnectRestrictionOptions()).thenReturn(connectRestrictionOptions); + when(connectOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + + final String output = tapSystemOut(() -> client.connect(connectOptions)); + + verify(blockingDelegate).connect(any(Mqtt5Connect.class)); + assertThat(output).contains(clientLogPreamble + + " sending CONNECT MqttConnect{keepAlive=0, cleanStart=false, sessionExpiryInterval=0, restrictions=MqttConnectRestrictions{receiveMaximum=65535, sendMaximum=65535, maximumPacketSize=268435460, sendMaximumPacketSize=268435460, topicAliasMaximum=0, sendTopicAliasMaximum=16, requestProblemInformation=false, requestResponseInformation=false}}"); + assertThat(output).contains(clientLogPreamble + + " received CONNACK MqttConnAck{reasonCode=SUCCESS, sessionPresent=false, sessionExpiryInterval=100, serverKeepAlive=100, assignedClientIdentifier=test-client}"); + } + + @Test + void connect_whenUsernameAndPasswordArePresent_thenConnectIsSentWithUsernameAndPassword() throws Exception { + final Mqtt5ConnAck connack = new MqttConnAck(Mqtt5ConnAckReasonCode.SUCCESS, + false, + 100L, + 100, + MqttClientIdentifierImpl.of(CLIENT_ID), + null, + MqttConnAckRestrictions.DEFAULT, + null, + null, + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + final ConnectRestrictionOptions connectRestrictionOptions = new ConnectRestrictionOptions(); + when(blockingDelegate.connect(any(Mqtt5Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + when(authenticationOptions.getUser()).thenReturn("user"); + when(authenticationOptions.getPassword()).thenReturn(ByteBuffer.wrap("password".getBytes(StandardCharsets.UTF_8))); + when(connectOptions.getConnectRestrictionOptions()).thenReturn(connectRestrictionOptions); + when(connectOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + + final String output = tapSystemOut(() -> client.connect(connectOptions)); + + verify(blockingDelegate).connect(any(Mqtt5Connect.class)); + assertThat(output).contains(clientLogPreamble + + " sending CONNECT MqttConnect{keepAlive=0, cleanStart=false, sessionExpiryInterval=0, restrictions=MqttConnectRestrictions{receiveMaximum=65535, sendMaximum=65535, maximumPacketSize=268435460, sendMaximumPacketSize=268435460, topicAliasMaximum=0, sendTopicAliasMaximum=16, requestProblemInformation=false, requestResponseInformation=false}, simpleAuth=MqttSimpleAuth{username and password}}"); + assertThat(output).contains(clientLogPreamble + + " received CONNACK MqttConnAck{reasonCode=SUCCESS, sessionPresent=false, sessionExpiryInterval=100, serverKeepAlive=100, assignedClientIdentifier=test-client}"); + } + + @Test + void connect_whenUsernameIsPresent_thenConnectIsSentWithUsername() throws Exception { + final Mqtt5ConnAck connack = new MqttConnAck(Mqtt5ConnAckReasonCode.SUCCESS, + false, + 100L, + 100, + MqttClientIdentifierImpl.of(CLIENT_ID), + null, + MqttConnAckRestrictions.DEFAULT, + null, + null, + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + final ConnectRestrictionOptions connectRestrictionOptions = new ConnectRestrictionOptions(); + when(blockingDelegate.connect(any(Mqtt5Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + when(connectOptions.getConnectRestrictionOptions()).thenReturn(connectRestrictionOptions); + when(authenticationOptions.getUser()).thenReturn("user"); + when(connectOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + + final String output = tapSystemOut(() -> client.connect(connectOptions)); + + verify(blockingDelegate).connect(any(Mqtt5Connect.class)); + assertThat(output).contains(clientLogPreamble + + " sending CONNECT MqttConnect{keepAlive=0, cleanStart=false, sessionExpiryInterval=0, restrictions=MqttConnectRestrictions{receiveMaximum=65535, sendMaximum=65535, maximumPacketSize=268435460, sendMaximumPacketSize=268435460, topicAliasMaximum=0, sendTopicAliasMaximum=16, requestProblemInformation=false, requestResponseInformation=false}, simpleAuth=MqttSimpleAuth{username}}"); + assertThat(output).contains(clientLogPreamble + + " received CONNACK MqttConnAck{reasonCode=SUCCESS, sessionPresent=false, sessionExpiryInterval=100, serverKeepAlive=100, assignedClientIdentifier=test-client}"); + } + + @Test + void connect_whenPasswordIsPresent_thenConnectIsSentWithPassword() throws Exception { + final Mqtt5ConnAck connack = new MqttConnAck(Mqtt5ConnAckReasonCode.SUCCESS, + false, + 100L, + 100, + MqttClientIdentifierImpl.of(CLIENT_ID), + null, + MqttConnAckRestrictions.DEFAULT, + null, + null, + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + final ConnectRestrictionOptions connectRestrictionOptions = new ConnectRestrictionOptions(); + when(blockingDelegate.connect(any(Mqtt5Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + when(connectOptions.getConnectRestrictionOptions()).thenReturn(connectRestrictionOptions); + when(connectOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(authenticationOptions.getPassword()).thenReturn(ByteBuffer.wrap("password".getBytes(StandardCharsets.UTF_8))); + + final String output = tapSystemOut(() -> client.connect(connectOptions)); + + verify(blockingDelegate).connect(any(Mqtt5Connect.class)); + assertThat(output).contains(clientLogPreamble + + " sending CONNECT MqttConnect{keepAlive=0, cleanStart=false, sessionExpiryInterval=0, restrictions=MqttConnectRestrictions{receiveMaximum=65535, sendMaximum=65535, maximumPacketSize=268435460, sendMaximumPacketSize=268435460, topicAliasMaximum=0, sendTopicAliasMaximum=16, requestProblemInformation=false, requestResponseInformation=false}, simpleAuth=MqttSimpleAuth{password}}"); + assertThat(output).contains(clientLogPreamble + + " received CONNACK MqttConnAck{reasonCode=SUCCESS, sessionPresent=false, sessionExpiryInterval=100, serverKeepAlive=100, assignedClientIdentifier=test-client}"); + } + + @Test + void publish_whenOneTopic_thenOnePublishIsSent() throws Exception { + final PublishOptions publishOptions = mock(PublishOptions.class); + final MqttPublishResult publishResult = + new MqttPublishResult(createPublish("test", MqttQos.EXACTLY_ONCE, "test"), null); + when(publishOptions.getMessage()).thenReturn(ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8))); + when(publishOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(publishOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + when(publishOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.publish(any(Mqtt5Publish.class))).thenReturn(CompletableFuture.completedFuture(publishResult)); + + final String output = tapSystemOut(() -> client.publish(publishOptions)); + + verify(asyncDelegate).publish(any(Mqtt5Publish.class)); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic, payload=4byte, qos=EXACTLY_ONCE, retain=false, messageExpiryInterval=0}"); + assertThat(output).contains(clientLogPreamble + + " received PUBLISH acknowledgement MqttPublishResult{publish=MqttPublish{topic=test, payload=4byte, qos=EXACTLY_ONCE, retain=false, messageExpiryInterval=100}}"); + } + + @Test + void publish_whenMultipleTopicsAndMultipleQos_thenMultiplePublishesAreSent() throws Exception { + final PublishOptions publishOptions = mock(PublishOptions.class); + when(publishOptions.getMessage()).thenReturn(ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8))); + when(publishOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(publishOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(publishOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.publish(any(Mqtt5Publish.class))).thenReturn(CompletableFuture.completedFuture(new MqttPublishResult( + createPublish("topic1", MqttQos.AT_MOST_ONCE, "test"), + null))) + .thenReturn(CompletableFuture.completedFuture(new MqttPublishResult(createPublish("topic2", + MqttQos.AT_LEAST_ONCE, + "test"), null))) + .thenReturn(CompletableFuture.completedFuture(new MqttPublishResult(createPublish("topic3", + MqttQos.EXACTLY_ONCE, + "test"), null))); + + final String output = tapSystemOut(() -> client.publish(publishOptions)); + + verify(asyncDelegate, times(3)).publish(any(Mqtt5Publish.class)); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic1, payload=4byte, qos=AT_MOST_ONCE, retain=false, messageExpiryInterval=0}"); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic2, payload=4byte, qos=AT_LEAST_ONCE, retain=false, messageExpiryInterval=0}"); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic3, payload=4byte, qos=EXACTLY_ONCE, retain=false, messageExpiryInterval=0}"); + assertThat(output).contains(clientLogPreamble + + " received PUBLISH acknowledgement MqttPublishResult{publish=MqttPublish{topic=topic1, payload=4byte, qos=AT_MOST_ONCE, retain=false, messageExpiryInterval=100}}"); + assertThat(output).contains(clientLogPreamble + + " received PUBLISH acknowledgement MqttPublishResult{publish=MqttPublish{topic=topic2, payload=4byte, qos=AT_LEAST_ONCE, retain=false, messageExpiryInterval=100}}"); + assertThat(output).contains(clientLogPreamble + + " received PUBLISH acknowledgement MqttPublishResult{publish=MqttPublish{topic=topic3, payload=4byte, qos=EXACTLY_ONCE, retain=false, messageExpiryInterval=100}}"); + } + + @Test + void publish_whenTopicsSizeDoesNotMatchQosSize_thenThrowsIllegalArgumentException() { + final PublishOptions publishOptions = mock(PublishOptions.class); + when(publishOptions.getMessage()).thenReturn(ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8))); + when(publishOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2"}); + when(publishOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + + assertThrows(IllegalArgumentException.class, + () -> client.publish(publishOptions), + "Topics size (2) does not match QoS size (1)"); + + verify(asyncDelegate, times(0)).publish(any(Mqtt5Publish.class)); + } + + @Test + void publish_whenPublishFails_thenThrowsCompletionException() throws Exception { + final PublishOptions publishOptions = mock(PublishOptions.class); + when(publishOptions.getMessage()).thenReturn(ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8))); + when(publishOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(publishOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + when(publishOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new NullPointerException("failed")); + when(asyncDelegate.publish(any(Mqtt5Publish.class))).thenReturn(future); + + final String output = + tapSystemErrAndOut(() -> assertThrows(CompletionException.class, () -> client.publish(publishOptions))); + + verify(asyncDelegate).publish(any(Mqtt5Publish.class)); + assertThat(output).contains(clientLogPreamble + + " sending PUBLISH ('test') MqttPublish{topic=topic, payload=4byte, qos=EXACTLY_ONCE, retain=false, messageExpiryInterval=0}"); + assertThat(output).contains(clientLogPreamble + + " failed PUBLISH to topic 'topic': failed: java.lang.NullPointerException: failed"); + } + + @Test + void subscribe_whenOneTopic_thenOneSubscribeIsSent() throws Exception { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final MqttSubAck suback = new MqttSubAck(1, + ImmutableList.of(Mqtt5SubAckReasonCode.GRANTED_QOS_2), + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + when(subscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + final String output = tapSystemOut(() -> client.subscribe(subscribeOptions)); + + verify(asyncDelegate, times(1)).subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class)); + assertThat(output).contains(clientLogPreamble + + " sending SUBSCRIBE MqttSubscribe{subscriptions=[MqttSubscription{topicFilter=topic, qos=EXACTLY_ONCE, noLocal=false, retainHandling=SEND, retainAsPublished=false}]}"); + assertThat(output).contains(clientLogPreamble + + " received SUBACK MqttSubAck{reasonCodes=[GRANTED_QOS_2], packetIdentifier=1}"); + } + + @Test + void subscribe_whenMultipleTopicsAndQos_thenOneSubscribeIsSent() throws Exception { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final MqttSubAck suback = new MqttSubAck(1, + ImmutableList.of(Mqtt5SubAckReasonCode.GRANTED_QOS_0, + Mqtt5SubAckReasonCode.GRANTED_QOS_1, + Mqtt5SubAckReasonCode.GRANTED_QOS_2), + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(subscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + final String output = tapSystemOut(() -> client.subscribe(subscribeOptions)); + + verify(asyncDelegate, times(1)).subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class)); + assertThat(output).contains(clientLogPreamble + + " sending SUBSCRIBE MqttSubscribe{subscriptions=[MqttSubscription{topicFilter=topic1, qos=AT_MOST_ONCE, noLocal=false, retainHandling=SEND, retainAsPublished=false}, MqttSubscription{topicFilter=topic2, qos=AT_LEAST_ONCE, noLocal=false, retainHandling=SEND, retainAsPublished=false}, MqttSubscription{topicFilter=topic3, qos=EXACTLY_ONCE, noLocal=false, retainHandling=SEND, retainAsPublished=false}]}"); + assertThat(output).contains(clientLogPreamble + + " received SUBACK MqttSubAck{reasonCodes=[GRANTED_QOS_0, GRANTED_QOS_1, GRANTED_QOS_2], packetIdentifier=1}"); + } + + @Test + void subscribe_whenTopicsSizeDoesNotMatchQosSize_thenThrowsIllegalArgumentException() { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.AT_MOST_ONCE}); + + assertThrows(IllegalArgumentException.class, + () -> client.subscribe(subscribeOptions), + "Topics size (2) does not match QoS size (1)"); + + verify(asyncDelegate, times(0)).subscribe(any(Mqtt5Subscribe.class)); + } + + @Test + void subscribe_whenSubscribeFails_thenThrowsCompletionsException() throws Exception { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{MqttQos.EXACTLY_ONCE}); + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new NullPointerException("failed")); + when(asyncDelegate.subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class))).thenReturn( + future); + when(subscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + + final String output = tapSystemErrAndOut(() -> assertThrows(CompletionException.class, + () -> client.subscribe(subscribeOptions), + "failed")); + + verify(asyncDelegate, times(1)).subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class)); + assertThat(output).contains(clientLogPreamble + + " sending SUBSCRIBE MqttSubscribe{subscriptions=[MqttSubscription{topicFilter=topic, qos=EXACTLY_ONCE, noLocal=false, retainHandling=SEND, retainAsPublished=false}]}"); + assertThat(output).contains(clientLogPreamble + + " failed SUBSCRIBE to topic(s) '[topic]': failed: java.lang.NullPointerException: failed"); + } + + @Test + void unsubscribe_whenOneTopic_thenOneUnsubscribeIsSent() throws Exception { + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + final MqttUnsubAck unsuback = new MqttUnsubAck(1, + ImmutableList.of(Mqtt5UnsubAckReasonCode.SUCCESS), + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(unsubscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.unsubscribe(any(Mqtt5Unsubscribe.class))).thenReturn(CompletableFuture.completedFuture( + unsuback)); + + final String output = tapSystemOut(() -> client.unsubscribe(unsubscribeOptions)); + + verify(asyncDelegate, times(1)).unsubscribe(any(Mqtt5Unsubscribe.class)); + assertThat(output).contains(clientLogPreamble + " sending UNSUBSCRIBE MqttUnsubscribe{topicFilters=[topic]}"); + assertThat(output).contains(clientLogPreamble + + " received UNSUBACK MqttUnsubAck{reasonCodes=[SUCCESS], packetIdentifier=1}"); + } + + @Test + void unsubscribe_whenMultipleTopics_thenOneUnsubscribeIsSent() throws Exception { + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + final MqttUnsubAck unsuback = new MqttUnsubAck(1, + ImmutableList.of(Mqtt5UnsubAckReasonCode.SUCCESS, + Mqtt5UnsubAckReasonCode.SUCCESS, + Mqtt5UnsubAckReasonCode.SUCCESS), + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(unsubscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.unsubscribe(any(Mqtt5Unsubscribe.class))).thenReturn(CompletableFuture.completedFuture(unsuback)); + + final String output = tapSystemOut(() -> client.unsubscribe(unsubscribeOptions)); + + verify(asyncDelegate, times(1)).unsubscribe(any(Mqtt5Unsubscribe.class)); + assertThat(output).contains(clientLogPreamble + + " sending UNSUBSCRIBE MqttUnsubscribe{topicFilters=[topic1, topic2, topic3]}"); + assertThat(output).contains(clientLogPreamble + " received UNSUBACK MqttUnsubAck{reasonCodes=[SUCCESS, SUCCESS, SUCCESS], packetIdentifier=1}"); + } + + @Test + void unsubscribe_whenUnsubscribeFails_thenThrowsCompletionException() throws Exception { + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + final CompletableFuture future = new CompletableFuture<>(); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic"}); + when(unsubscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + future.completeExceptionally(new NullPointerException("failed")); + when(asyncDelegate.unsubscribe(any(Mqtt5Unsubscribe.class))).thenReturn(future); + + final String output = tapSystemErrAndOut(() -> assertThrows(CompletionException.class, + () -> client.unsubscribe(unsubscribeOptions), + "failed")); + + verify(asyncDelegate, times(1)).unsubscribe(any(Mqtt5Unsubscribe.class)); + assertThat(output).contains(clientLogPreamble + " sending UNSUBSCRIBE MqttUnsubscribe{topicFilters=[topic]}"); + assertThat(output).contains(clientLogPreamble + + " failed UNSUBSCRIBE from topic(s) '[topic]': failed: java.lang.NullPointerException: failed"); + } + + @Test + void disconnect_success() throws Exception { + final DisconnectOptions disconnectOptions = mock(DisconnectOptions.class); + when(asyncDelegate.disconnect(any(Mqtt5Disconnect.class))).thenReturn(CompletableFuture.completedFuture(null)); + when(disconnectOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + + final String output = tapSystemOut(() -> client.disconnect(disconnectOptions)); + + verify(asyncDelegate, times(1)).disconnect(any(Mqtt5Disconnect.class)); + assertThat(output).contains(clientLogPreamble + " sending DISCONNECT MqttDisconnect{reasonCode=NORMAL_DISCONNECTION, sessionExpiryInterval=0}"); + assertThat(output).contains(clientLogPreamble + " disconnected successfully"); + } + + @Test + void disconnect_whenDisconnectFails_thenThrowsCompletionException() throws Exception { + final DisconnectOptions disconnectOptions = mock(DisconnectOptions.class); + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new NullPointerException("failed")); + when(disconnectOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.disconnect(any(Mqtt5Disconnect.class))).thenReturn(future); + + final String output = tapSystemErrAndOut(() -> assertThrows(CompletionException.class, + () -> client.disconnect(disconnectOptions), + "failed")); + + verify(asyncDelegate, times(1)).disconnect(any(Mqtt5Disconnect.class)); + assertThat(output).contains(clientLogPreamble + " sending DISCONNECT MqttDisconnect{reasonCode=NORMAL_DISCONNECTION, sessionExpiryInterval=0}"); + assertThat(output).contains(clientLogPreamble + + " failed to DISCONNECT gracefully: failed: java.lang.NullPointerException: failed"); + } + + @Test + void isConnected_success() { + when(delegate.getState()).thenReturn(MqttClientState.CONNECTED); + assertTrue(client.isConnected()); + } + + @Test + void getClientIdentifier_success() { + assertEquals(CLIENT_ID, client.getClientIdentifier()); + } + + @Test + void getServerHost_success() { + assertEquals(HOSTNAME, client.getServerHost()); + } + + @Test + void getMqttVersion_success() { + assertEquals(MqttVersion.MQTT_3_1_1, client.getMqttVersion()); + } + + @Test + void getConnectedAt_success() { + final Mqtt5ConnAck connack = new MqttConnAck(Mqtt5ConnAckReasonCode.SUCCESS, + false, + 100L, + 100, + MqttClientIdentifierImpl.of(CLIENT_ID), + null, + MqttConnAckRestrictions.DEFAULT, + null, + null, + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + final ConnectOptions connectOptions = mock(ConnectOptions.class); + final AuthenticationOptions authenticationOptions = mock(AuthenticationOptions.class); + when(blockingDelegate.connect(any(Mqtt5Connect.class))).thenReturn(connack); + when(connectOptions.getAuthenticationOptions()).thenReturn(authenticationOptions); + when(connectOptions.getConnectRestrictionOptions()).thenReturn(new ConnectRestrictionOptions()); + when(connectOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + + client.connect(connectOptions); + assertNotNull(client.getConnectedAt()); + } + + @Test + void getConnectedAt_whenNotConnected_thenThrowsIllegalStateException() { + assertThrows(IllegalStateException.class, + client::getConnectedAt, + "connectedAt must not be null after a client has connected successfully"); + } + + @Test + void getState_success() { + when(delegate.getState()).thenReturn(MqttClientState.DISCONNECTED); + assertEquals(MqttClientState.DISCONNECTED, client.getState()); + } + + @Test + void getSslProtocols_whenProtocolsArePresent_thenListStringIsReturned() { + final MqttClientSslConfig sslConfig = mock(MqttClientSslConfig.class); + when(sslConfig.getProtocols()).thenReturn(Optional.of(ImmutableList.of("TLS_1_2", "TLS_1_3"))); + when(config.getSslConfig()).thenReturn(Optional.of(sslConfig)); + + assertEquals("[TLS_1_2, TLS_1_3]", client.getSslProtocols()); + } + + @Test + void getSslProtocols_whenNoSslConfigIsPresent_thenNoSslStringIsReturned() { + assertEquals("NO_SSL", client.getSslProtocols()); + } + + @Test + void getServerPort_success() { + assertEquals(1883, client.getServerPort()); + } + + @Test + void getSubscribedTopics_whenSuccessfullySubscribed_thenSubscriptionsArePresent() { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final MqttSubAck suback = new MqttSubAck(1, + ImmutableList.of(Mqtt5SubAckReasonCode.GRANTED_QOS_0, + Mqtt5SubAckReasonCode.GRANTED_QOS_1, + Mqtt5SubAckReasonCode.GRANTED_QOS_2), + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(subscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + client.subscribe(subscribeOptions); + final List subscribedTopics = client.getSubscribedTopics(); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic1")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic2")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic3")); + } + + @Test + void getSubscribedTopics_whenSuccessfullySubscribedAndUnsubscribed_thenNoSubscriptionIsPresent() { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final MqttSubAck suback = new MqttSubAck(1, + ImmutableList.of(Mqtt5SubAckReasonCode.GRANTED_QOS_0, + Mqtt5SubAckReasonCode.GRANTED_QOS_1, + Mqtt5SubAckReasonCode.GRANTED_QOS_2), + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(subscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(unsubscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.unsubscribe(any(Mqtt5Unsubscribe.class))).thenReturn(CompletableFuture.completedFuture(null)); + + client.subscribe(subscribeOptions); + final List subscribedTopics = client.getSubscribedTopics(); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic1")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic2")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic3")); + + client.unsubscribe(unsubscribeOptions); + assertThat(client.getSubscribedTopics()).isEmpty(); + } + + @Test + void getSubscribedTopics_whenSuccessfullySubscribedAndPartiallyUnsubscribed_thenNotUnsubscribedSubscriptionsArePresent() { + final SubscribeOptions subscribeOptions = mock(SubscribeOptions.class); + final MqttSubAck suback = new MqttSubAck(1, + ImmutableList.of(Mqtt5SubAckReasonCode.GRANTED_QOS_0, + Mqtt5SubAckReasonCode.GRANTED_QOS_1, + Mqtt5SubAckReasonCode.GRANTED_QOS_2), + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES); + when(subscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic2", "topic3"}); + when(subscribeOptions.getQos()).thenReturn(new MqttQos[]{ + MqttQos.AT_MOST_ONCE, MqttQos.AT_LEAST_ONCE, MqttQos.EXACTLY_ONCE}); + when(subscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.subscribe(any(Mqtt5Subscribe.class), any(SubscribeMqtt5PublishCallback.class))).thenReturn( + CompletableFuture.completedFuture(suback)); + + final UnsubscribeOptions unsubscribeOptions = mock(UnsubscribeOptions.class); + when(unsubscribeOptions.getTopics()).thenReturn(new String[]{"topic1", "topic3"}); + when(unsubscribeOptions.getUserProperties()).thenReturn(Mqtt5UserProperties.of()); + when(asyncDelegate.unsubscribe(any(Mqtt5Unsubscribe.class))).thenReturn(CompletableFuture.completedFuture(null)); + + client.subscribe(subscribeOptions); + final List subscribedTopics = client.getSubscribedTopics(); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic1")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic2")); + assertThat(subscribedTopics).contains(MqttTopicFilter.of("topic3")); + + client.unsubscribe(unsubscribeOptions); + assertThat(client.getSubscribedTopics()).containsExactly(MqttTopicFilter.of("topic2")); + } + + private static @NotNull MqttPublish createPublish( + final @NotNull String topic, final @NotNull MqttQos qos, final @NotNull String message) { + return new MqttPublish(MqttTopicImpl.of(topic), + ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8)), + qos, + false, + 100L, + null, + null, + null, + null, + MqttUserPropertiesImpl.NO_USER_PROPERTIES, + null); + } + +} diff --git a/src/test/java/com/hivemq/cli/utils/TestLoggerUtils.java b/src/test/java/com/hivemq/cli/utils/TestLoggerUtils.java index 0f5b41e72..65ccfe959 100644 --- a/src/test/java/com/hivemq/cli/utils/TestLoggerUtils.java +++ b/src/test/java/com/hivemq/cli/utils/TestLoggerUtils.java @@ -19,6 +19,7 @@ import org.tinylog.configuration.Configuration; import java.lang.reflect.Field; +import java.util.HashMap; public class TestLoggerUtils { @@ -31,6 +32,7 @@ public static void resetLogger() { frozen = Configuration.class.getDeclaredField("frozen"); frozen.setAccessible(true); frozen.set(null, false); + Configuration.replace(new HashMap<>()); } catch (final NoSuchFieldException | IllegalAccessException e) { e.printStackTrace(); }