org.eclipse.hono.adapter.mqtt.AbstractVertxBasedMqttProtocolAdapter.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.hono.adapter.mqtt.AbstractVertxBasedMqttProtocolAdapter.java

Source

/**
 * Copyright (c) 2017, 2018 Contributors to the Eclipse Foundation
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 1.0 which is available at
 * https://www.eclipse.org/legal/epl-v10.html
 *
 * SPDX-License-Identifier: EPL-1.0
 */

package org.eclipse.hono.adapter.mqtt;

import java.net.HttpURLConnection;
import java.util.Objects;

import org.apache.qpid.proton.message.Message;
import org.eclipse.hono.client.ClientErrorException;
import org.eclipse.hono.client.MessageSender;
import org.eclipse.hono.client.ServerErrorException;
import org.eclipse.hono.client.ServiceInvocationException;
import org.eclipse.hono.config.ProtocolAdapterProperties;
import org.eclipse.hono.service.AbstractProtocolAdapterBase;
import org.eclipse.hono.service.auth.device.Device;
import org.eclipse.hono.service.auth.device.DeviceCredentials;
import org.eclipse.hono.service.auth.device.HonoClientBasedAuthProvider;
import org.eclipse.hono.service.auth.device.UsernamePasswordAuthProvider;
import org.eclipse.hono.service.auth.device.UsernamePasswordCredentials;
import org.eclipse.hono.util.Constants;
import org.eclipse.hono.util.EndpointType;
import org.eclipse.hono.util.EventConstants;
import org.eclipse.hono.util.ResourceIdentifier;
import org.eclipse.hono.util.TelemetryConstants;
import org.eclipse.hono.util.TenantObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.vertx.core.AsyncResult;
import io.vertx.core.CompositeFuture;
import io.vertx.core.Future;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import io.vertx.mqtt.MqttAuth;
import io.vertx.mqtt.MqttConnectionException;
import io.vertx.mqtt.MqttEndpoint;
import io.vertx.mqtt.MqttServer;
import io.vertx.mqtt.MqttServerOptions;

/**
 * A base class for implementing Vert.x based Hono protocol adapters for publishing events & telemetry data using
 * MQTT.
 * 
 * @param <T> The type of configuration properties this adapter supports/requires.
 */
public abstract class AbstractVertxBasedMqttProtocolAdapter<T extends ProtocolAdapterProperties>
        extends AbstractProtocolAdapterBase<T> {

    private static final int IANA_MQTT_PORT = 1883;
    private static final int IANA_SECURE_MQTT_PORT = 8883;

    /**
     * A logger to be used by concrete subclasses.
     */
    protected final Logger LOG = LoggerFactory.getLogger(getClass());

    private MqttAdapterMetrics metrics;

    private MqttServer server;
    private MqttServer insecureServer;
    private HonoClientBasedAuthProvider usernamePasswordAuthProvider;

    /**
     * Sets the provider to use for authenticating devices based on a username and password.
     * 
     * @param provider The provider to use.
     * @throws NullPointerException if provider is {@code null}.
     */
    public final void setUsernamePasswordAuthProvider(final HonoClientBasedAuthProvider provider) {
        this.usernamePasswordAuthProvider = Objects.requireNonNull(provider);
    }

    @Override
    public int getPortDefaultValue() {
        return IANA_SECURE_MQTT_PORT;
    }

    @Override
    public int getInsecurePortDefaultValue() {
        return IANA_MQTT_PORT;
    }

    @Override
    protected final int getActualPort() {
        return server != null ? server.actualPort() : Constants.PORT_UNCONFIGURED;
    }

    @Override
    protected final int getActualInsecurePort() {
        return insecureServer != null ? insecureServer.actualPort() : Constants.PORT_UNCONFIGURED;
    }

    /**
     * Sets the metrics for this service.
     *
     * @param metrics The metrics
     */
    @Autowired
    public final void setMetrics(final MqttAdapterMetrics metrics) {
        this.metrics = metrics;
    }

    /**
     * Sets the MQTT server to use for handling secure MQTT connections.
     * 
     * @param server The server.
     * @throws NullPointerException if the server is {@code null}.
     */
    public void setMqttSecureServer(final MqttServer server) {
        Objects.requireNonNull(server);
        if (server.actualPort() > 0) {
            throw new IllegalArgumentException("MQTT server must not be started already");
        } else {
            this.server = server;
        }
    }

    /**
     * Sets the MQTT server to use for handling non-secure MQTT connections.
     * 
     * @param server The server.
     * @throws NullPointerException if the server is {@code null}.
     */
    public void setMqttInsecureServer(final MqttServer server) {
        Objects.requireNonNull(server);
        if (server.actualPort() > 0) {
            throw new IllegalArgumentException("MQTT server must not be started already");
        } else {
            this.insecureServer = server;
        }
    }

    private Future<Void> bindSecureMqttServer() {

        if (isSecurePortEnabled()) {
            final MqttServerOptions options = new MqttServerOptions();
            options.setHost(getConfig().getBindAddress()).setPort(determineSecurePort())
                    .setMaxMessageSize(getConfig().getMaxPayloadSize());
            addTlsKeyCertOptions(options);
            addTlsTrustOptions(options);

            return bindMqttServer(options, server).map(s -> {
                server = s;
                return (Void) null;
            }).recover(t -> {
                return Future.failedFuture(t);
            });
        } else {
            return Future.succeededFuture();
        }
    }

    private Future<Void> bindInsecureMqttServer() {

        if (isInsecurePortEnabled()) {
            final MqttServerOptions options = new MqttServerOptions();
            options.setHost(getConfig().getInsecurePortBindAddress()).setPort(determineInsecurePort())
                    .setMaxMessageSize(getConfig().getMaxPayloadSize());

            return bindMqttServer(options, insecureServer).map(server -> {
                insecureServer = server;
                return (Void) null;
            }).recover(t -> {
                return Future.failedFuture(t);
            });
        } else {
            return Future.succeededFuture();
        }
    }

    private Future<MqttServer> bindMqttServer(final MqttServerOptions options, final MqttServer mqttServer) {

        final Future<MqttServer> result = Future.future();
        final MqttServer createdMqttServer = mqttServer == null ? MqttServer.create(this.vertx, options)
                : mqttServer;

        createdMqttServer.endpointHandler(this::handleEndpointConnection).listen(done -> {

            if (done.succeeded()) {
                LOG.info("MQTT server running on {}:{}", getConfig().getBindAddress(),
                        createdMqttServer.actualPort());
                result.complete(createdMqttServer);
            } else {
                LOG.error("error while starting up MQTT server", done.cause());
                result.fail(done.cause());
            }
        });
        return result;
    }

    @Override
    public void doStart(final Future<Void> startFuture) {

        LOG.info("limiting size of inbound message payload to {} bytes", getConfig().getMaxPayloadSize());
        if (!getConfig().isAuthenticationRequired()) {
            LOG.warn("authentication of devices turned off");
        }
        checkPortConfiguration().compose(ok -> {
            if (metrics == null) {
                // use default implementation
                // which simply discards all reported metrics
                metrics = new MqttAdapterMetrics();
            }
            return CompositeFuture.all(bindSecureMqttServer(), bindInsecureMqttServer());
        }).compose(t -> {
            if (usernamePasswordAuthProvider == null) {
                usernamePasswordAuthProvider = new UsernamePasswordAuthProvider(getCredentialsServiceClient(),
                        getConfig());
            }
            startFuture.complete();
        }, startFuture);
    }

    @Override
    public void doStop(final Future<Void> stopFuture) {

        final Future<Void> serverTracker = Future.future();
        if (this.server != null) {
            this.server.close(serverTracker.completer());
        } else {
            serverTracker.complete();
        }

        final Future<Void> insecureServerTracker = Future.future();
        if (this.insecureServer != null) {
            this.insecureServer.close(insecureServerTracker.completer());
        } else {
            insecureServerTracker.complete();
        }

        CompositeFuture.all(serverTracker, insecureServerTracker).compose(d -> stopFuture.complete(), stopFuture);
    }

    /**
     * Create a future indicating a rejected connection.
     * 
     * @param returnCode The error code to return.
     * @param <T> The type of the returned future.
     * @return A future indicating a rejected connection.
     */
    protected static <T> Future<T> rejected(final MqttConnectReturnCode returnCode) {
        return Future.failedFuture(new MqttConnectionException(
                returnCode != null ? returnCode : MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE));
    }

    /**
     * Create a future indicating an accepted connection with an authenticated device.
     * 
     * @param authenticatedDevice The device that was authenticated, may be {@code null}
     * @return A future indicating an accepted connection.
     */
    protected static Future<Device> accepted(final Device authenticatedDevice) {
        return Future.succeededFuture(authenticatedDevice);
    }

    /**
     * Create a future indicating an accepted connection without an authenticated device.
     * 
     * @return A future indicating an accepted connection.
     */
    protected static Future<Device> accepted() {
        return Future.succeededFuture(null);
    }

    /**
     * Invoked when a client sends its <em>CONNECT</em> packet.
     * <p>
     * Authenticates the client (if required) and registers handlers for processing messages published by the client.
     * 
     * @param endpoint The MQTT endpoint representing the client.
     */
    final void handleEndpointConnection(final MqttEndpoint endpoint) {

        LOG.debug("connection request from client [clientId: {}]", endpoint.clientIdentifier());

        isConnected().compose(v -> handleConnectionRequest(endpoint))
                .setHandler(result -> handleConnectionRequestResult(endpoint, result));

    }

    private Future<Device> handleConnectionRequest(final MqttEndpoint endpoint) {
        if (getConfig().isAuthenticationRequired()) {
            return handleEndpointConnectionWithAuthentication(endpoint);
        } else {
            return handleEndpointConnectionWithoutAuthentication(endpoint);
        }
    }

    private void handleConnectionRequestResult(final MqttEndpoint endpoint,
            final AsyncResult<Device> authenticationAttempt) {

        if (authenticationAttempt.succeeded()) {

            sendConnectedEvent(endpoint.clientIdentifier(), authenticationAttempt.result())
                    .setHandler(sendAttempt -> {
                        if (sendAttempt.succeeded()) {
                            endpoint.accept(false);
                        } else {
                            LOG.warn(
                                    "connection request from client [clientId: {}] rejected due to connection event "
                                            + "failure: {}",
                                    endpoint.clientIdentifier(),
                                    MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE,
                                    sendAttempt.cause());
                            endpoint.reject(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE);
                        }
                    });

        } else {

            final Throwable t = authenticationAttempt.cause();
            if (t instanceof MqttConnectionException) {
                final MqttConnectReturnCode code = ((MqttConnectionException) t).code();
                LOG.debug("connection request from client [clientId: {}] rejected with code: {}",
                        endpoint.clientIdentifier(), code);
                endpoint.reject(code);
            } else {
                LOG.debug("connection request from client [clientId: {}] rejected: {}", endpoint.clientIdentifier(),
                        MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE);
                endpoint.reject(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE);
            }

        }
    }

    /**
     * Invoked when a client sends its <em>CONNECT</em> packet and client authentication has been disabled by setting
     * the {@linkplain ProtocolAdapterProperties#isAuthenticationRequired() authentication required} configuration
     * property to {@code false}.
     * <p>
     * Registers a close handler on the endpoint which invokes {@link #close(MqttEndpoint, Device)}. Registers a publish
     * handler on the endpoint which invokes {@link #onPublishedMessage(MqttContext)} for each message being published
     * by the client. Accepts the connection request.
     * 
     * @param endpoint The MQTT endpoint representing the client.
     */
    private Future<Device> handleEndpointConnectionWithoutAuthentication(final MqttEndpoint endpoint) {

        endpoint.closeHandler(v -> {
            close(endpoint, null);
            LOG.debug("connection to unauthenticated device [clientId: {}] closed", endpoint.clientIdentifier());
            metrics.decrementUnauthenticatedMqttConnections();
        });
        endpoint.publishHandler(message -> onPublishedMessage(new MqttContext(message, endpoint)));

        LOG.debug("unauthenticated device [clientId: {}] connected", endpoint.clientIdentifier());
        metrics.incrementUnauthenticatedMqttConnections();
        return accepted();
    }

    private Future<Device> handleEndpointConnectionWithAuthentication(final MqttEndpoint endpoint) {

        if (endpoint.auth() == null) {

            LOG.debug("connection request from device [clientId: {}] rejected: {}", endpoint.clientIdentifier(),
                    "device did not provide credentials in CONNECT packet");

            return rejected(MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD);

        } else {

            final DeviceCredentials credentials = getCredentials(endpoint.auth());

            if (credentials == null) {

                LOG.debug("connection request from device [clientId: {}] rejected: {}", endpoint.clientIdentifier(),
                        "device provided malformed credentials in CONNECT packet");
                return rejected(MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD);

            } else {

                return getTenantConfiguration(credentials.getTenantId()).compose(tenantConfig -> {
                    if (tenantConfig.isAdapterEnabled(getTypeName())) {
                        LOG.debug("protocol adapter [{}] is enabled for tenant [{}]", getTypeName(),
                                credentials.getTenantId());
                        return Future.succeededFuture(tenantConfig);
                    } else {
                        LOG.debug("protocol adapter [{}] is disabled for tenant [{}]", getTypeName(),
                                credentials.getTenantId());
                        return Future.failedFuture(new ClientErrorException(HttpURLConnection.HTTP_FORBIDDEN,
                                "adapter disabled for tenant"));
                    }
                }).compose(tenantConfig -> {
                    final Future<Device> result = Future.future();
                    usernamePasswordAuthProvider.authenticate(credentials, result.completer());
                    return result;
                }).compose(authenticatedDevice -> {
                    LOG.debug("successfully authenticated device [tenant-id: {}, auth-id: {}, device-id: {}]",
                            authenticatedDevice.getTenantId(), credentials.getAuthId(),
                            authenticatedDevice.getDeviceId());
                    return triggerLinkCreation(authenticatedDevice.getTenantId()).map(done -> {
                        onAuthenticationSuccess(endpoint, authenticatedDevice);
                        return null;
                    }).compose(ok -> accepted(authenticatedDevice));
                }).recover(t -> {
                    LOG.debug("cannot establish connection with device [tenant-id: {}, auth-id: {}]",
                            credentials.getTenantId(), credentials.getAuthId(), t);
                    if (t instanceof ServerErrorException) {
                        // one of the services we depend on might not be available (yet)
                        return rejected(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE);
                    } else {
                        // validation of credentials has failed
                        return rejected(MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED);
                    }
                });

            }
        }
    }

    private void onAuthenticationSuccess(final MqttEndpoint endpoint, final Device authenticatedDevice) {

        endpoint.closeHandler(v -> {
            close(endpoint, authenticatedDevice);
            LOG.debug("connection to device [tenant-id: {}, device-id: {}] closed",
                    authenticatedDevice.getTenantId(), authenticatedDevice.getDeviceId());
            metrics.decrementMqttConnections(authenticatedDevice.getTenantId());
        });

        endpoint.publishHandler(
                message -> onPublishedMessage(new MqttContext(message, endpoint, authenticatedDevice)));
        metrics.incrementMqttConnections(authenticatedDevice.getTenantId());
    }

    private Future<Void> triggerLinkCreation(final String tenantId) {

        final Future<Void> result = Future.future();
        LOG.debug("providently trying to open downstream links for tenant [{}]", tenantId);
        CompositeFuture
                .join(getRegistrationClient(tenantId), getTelemetrySender(tenantId), getEventSender(tenantId))
                .setHandler(attempt -> {
                    result.complete();
                });
        return result;
    }

    /**
     * Uploads a message to Hono Messaging.
     * 
     * @param ctx The context in which the MQTT message has been published.
     * @param resource The resource that the message should be uploaded to.
     * @param payload The message payload to send.
     * @return A future indicating the outcome of the operation.
     *         <p>
     *         The future will succeed if the message has been uploaded successfully. Otherwise the future will fail
     *         with a {@link ServiceInvocationException}.
     * @throws NullPointerException if any of context, resource or payload is {@code null}.
     * @throws IllegalArgumentException if the payload is empty.
     */
    public final Future<Void> uploadMessage(final MqttContext ctx, final ResourceIdentifier resource,
            final Buffer payload) {

        Objects.requireNonNull(ctx);
        Objects.requireNonNull(resource);
        Objects.requireNonNull(payload);

        switch (EndpointType.fromString(resource.getEndpoint())) {
        case TELEMETRY:
            return uploadTelemetryMessage(ctx, resource.getTenantId(), resource.getResourceId(), payload);
        case EVENT:
            return uploadEventMessage(ctx, resource.getTenantId(), resource.getResourceId(), payload);
        default:
            return Future.failedFuture(
                    new ClientErrorException(HttpURLConnection.HTTP_BAD_REQUEST, "unsupported endpoint"));
        }

    }

    /**
     * Uploads a telemetry message to Hono Messaging.
     * 
     * @param ctx The context in which the MQTT message has been published.
     * @param tenant The tenant of the device that has produced the data.
     * @param deviceId The id of the device that has produced the data.
     * @param payload The message payload to send.
     * @return A future indicating the outcome of the operation.
     *         <p>
     *         The future will succeed if the message has been uploaded successfully. Otherwise the future will fail
     *         with a {@link ServiceInvocationException}.
     * @throws NullPointerException if any of context, tenant, device ID or payload is {@code null}.
     * @throws IllegalArgumentException if the payload is empty.
     */
    public final Future<Void> uploadTelemetryMessage(final MqttContext ctx, final String tenant,
            final String deviceId, final Buffer payload) {

        return uploadMessage(Objects.requireNonNull(ctx), Objects.requireNonNull(tenant),
                Objects.requireNonNull(deviceId), Objects.requireNonNull(payload), getTelemetrySender(tenant),
                TelemetryConstants.TELEMETRY_ENDPOINT);
    }

    /**
     * Uploads an event message to Hono Messaging.
     * 
     * @param ctx The context in which the MQTT message has been published.
     * @param tenant The tenant of the device that has produced the data.
     * @param deviceId The id of the device that has produced the data.
     * @param payload The message payload to send.
     * @return A future indicating the outcome of the operation.
     *         <p>
     *         The future will succeed if the message has been uploaded successfully. Otherwise the future will fail
     *         with a {@link ServiceInvocationException}.
     * @throws NullPointerException if any of context, tenant, device ID or payload is {@code null}.
     * @throws IllegalArgumentException if the payload is empty.
     */
    public final Future<Void> uploadEventMessage(final MqttContext ctx, final String tenant, final String deviceId,
            final Buffer payload) {

        return uploadMessage(Objects.requireNonNull(ctx), Objects.requireNonNull(tenant),
                Objects.requireNonNull(deviceId), Objects.requireNonNull(payload), getEventSender(tenant),
                EventConstants.EVENT_ENDPOINT);
    }

    private Future<Void> uploadMessage(final MqttContext ctx, final String tenant, final String deviceId,
            final Buffer payload, final Future<MessageSender> senderTracker, final String endpointName) {

        if (!isPayloadOfIndicatedType(payload, ctx.contentType())) {
            return Future.failedFuture(new ClientErrorException(HttpURLConnection.HTTP_BAD_REQUEST,
                    String.format("Content-Type %s does not match with the payload", ctx.contentType())));
        } else {

            final Future<JsonObject> tokenTracker = getRegistrationAssertion(tenant, deviceId,
                    ctx.authenticatedDevice());
            final Future<TenantObject> tenantConfigTracker = getTenantConfiguration(tenant);

            return CompositeFuture.all(tokenTracker, tenantConfigTracker, senderTracker).compose(ok -> {

                if (tenantConfigTracker.result().isAdapterEnabled(getTypeName())) {

                    final MessageSender sender = senderTracker.result();
                    final Message downstreamMessage = newMessage(
                            ResourceIdentifier.from(endpointName, tenant, deviceId),
                            sender.isRegistrationAssertionRequired(), ctx.message().topicName(), ctx.contentType(),
                            payload, tokenTracker.result(), null);
                    customizeDownstreamMessage(downstreamMessage, ctx);

                    if (ctx.message().qosLevel() == MqttQoS.AT_LEAST_ONCE) {
                        return sender.sendAndWaitForOutcome(downstreamMessage);
                    } else {
                        return sender.send(downstreamMessage);
                    }
                } else {
                    // this adapter is not enabled for the tenant
                    return Future.failedFuture(new ClientErrorException(HttpURLConnection.HTTP_FORBIDDEN));
                }

            }).compose(delivery -> {

                LOG.trace(
                        "successfully processed message [topic: {}, QoS: {}] for device [tenantId: {}, deviceId: {}]",
                        ctx.message().topicName(), ctx.message().qosLevel(), tenant, deviceId);
                metrics.incrementProcessedMqttMessages(endpointName, tenant);
                onMessageSent(ctx);
                // check that the remote MQTT client is still connected before sending PUBACK
                if (ctx.deviceEndpoint().isConnected() && ctx.message().qosLevel() == MqttQoS.AT_LEAST_ONCE) {
                    ctx.deviceEndpoint().publishAcknowledge(ctx.message().messageId());
                }
                return Future.<Void>succeededFuture();

            }).recover(t -> {

                if (ClientErrorException.class.isInstance(t)) {
                    final ClientErrorException e = (ClientErrorException) t;
                    LOG.debug(
                            "cannot process message for device [tenantId: {}, deviceId: {}, endpoint: {}]: {} - {}",
                            tenant, deviceId, endpointName, e.getErrorCode(), e.getMessage());
                } else {
                    LOG.debug("cannot process message for device [tenantId: {}, deviceId: {}, endpoint: {}]",
                            tenant, deviceId, endpointName, t);
                    metrics.incrementUndeliverableMqttMessages(endpointName, tenant);
                    onMessageUndeliverable(ctx);
                }
                return Future.failedFuture(t);
            });
        }
    }

    /**
     * Closes a connection to a client.
     * 
     * @param endpoint The connection to close.
     * @param authenticatedDevice Optional authenticated device information, may be {@code null}.
     */
    protected final void close(final MqttEndpoint endpoint, final Device authenticatedDevice) {
        onClose(endpoint);
        sendDisconnectedEvent(endpoint.clientIdentifier(), authenticatedDevice);
        if (endpoint.isConnected()) {
            LOG.debug("closing connection with client [client ID: {}]", endpoint.clientIdentifier());
            endpoint.close();
        } else {
            LOG.trace("client has already closed connection");
        }
    }

    /**
     * Invoked before the connection with a device is closed.
     * <p>
     * Subclasses should override this method in order to release any device specific resources.
     * <p>
     * This default implementation does nothing.
     * 
     * @param endpoint The connection to be closed.
     */
    protected void onClose(final MqttEndpoint endpoint) {
    }

    /**
     * Extracts credentials from a client's MQTT <em>CONNECT</em> packet.
     * <p>
     * This default implementation returns {@link UsernamePasswordCredentials} created from the <em>username</em> and
     * <em>password</em> fields of the <em>CONNECT</em> packet.
     * <p>
     * Subclasses should override this method if the device uses credentials that do not comply with the format expected
     * by {@link UsernamePasswordCredentials}.
     * 
     * @param authInfo The authentication info provided by the device.
     * @return The credentials or {@code null} if the information provided by the device can not be processed.
     */
    protected DeviceCredentials getCredentials(final MqttAuth authInfo) {
        if (authInfo.userName() == null || authInfo.password() == null) {
            return null;
        } else {
            return UsernamePasswordCredentials.create(authInfo.userName(), authInfo.password(),
                    getConfig().isSingleTenant());
        }
    }

    /**
     * Processes an MQTT message that has been published by a device.
     * <p>
     * Subclasses should determine
     * <ul>
     * <li>the tenant and identifier of the device that has published the message</li>
     * <li>the payload to send downstream</li>
     * <li>the content type of the payload</li>
     * </ul>
     * and then invoke one of the <em>upload*</em> methods to send the message downstream.
     * 
     * @param ctx The context in which the MQTT message has been published.
     * @return A future indicating the outcome of the operation.
     *         <p>
     *         The future will succeed if the message has been successfully uploaded. Otherwise, the future will fail
     *         with a {@link ServiceInvocationException}.
     */
    protected abstract Future<Void> onPublishedMessage(MqttContext ctx);

    /**
     * Invoked before the message is sent to the downstream peer.
     * <p>
     * This default implementation does nothing.
     * <p>
     * Subclasses may override this method in order to customize the message before it is sent, e.g. adding custom
     * properties.
     * 
     * @param downstreamMessage The message that will be sent downstream.
     * @param ctx The context in which the MQTT message has been published.
     */
    protected void customizeDownstreamMessage(final Message downstreamMessage, final MqttContext ctx) {
    }

    /**
     * Invoked when a message has been forwarded downstream successfully.
     * <p>
     * This default implementation does nothing.
     * <p>
     * Subclasses should override this method in order to e.g. update metrics counters.
     * 
     * @param ctx The context in which the MQTT message has been published.
     */
    protected void onMessageSent(final MqttContext ctx) {
    }

    /**
     * Invoked when a message could not be forwarded downstream.
     * <p>
     * This method will only be invoked if the failure to forward the message has not been caused by the device that
     * published the message. In particular, this method will not be invoked for messages that cannot be authorized or
     * that are published to an unsupported/unrecognized topic. Such messages will be silently discarded.
     * <p>
     * This default implementation does nothing.
     * <p>
     * Subclasses should override this method in order to e.g. update metrics counters.
     * 
     * @param ctx The context in which the MQTT message has been published.
     */
    protected void onMessageUndeliverable(final MqttContext ctx) {
    }
}