diff --git a/connectivity/api/src/main/java/org/eclipse/ditto/connectivity/api/ConnectivityMappingStrategies.java b/connectivity/api/src/main/java/org/eclipse/ditto/connectivity/api/ConnectivityMappingStrategies.java index b4ba1d97d2..cbdec89cfc 100644 --- a/connectivity/api/src/main/java/org/eclipse/ditto/connectivity/api/ConnectivityMappingStrategies.java +++ b/connectivity/api/src/main/java/org/eclipse/ditto/connectivity/api/ConnectivityMappingStrategies.java @@ -78,6 +78,7 @@ private static MappingStrategies getConnectivityMappingStrategies() { .add(GlobalErrorRegistry.getInstance()) .add(Connection.class, ConnectivityModelFactory::connectionFromJson) .add("ImmutableConnection", ConnectivityModelFactory::connectionFromJson) + .add("HonoConnection", ConnectivityModelFactory::connectionFromJson) .add(ResourceStatus.class, ConnectivityModelFactory::resourceStatusFromJson) .add("ImmutableResourceStatus", ConnectivityModelFactory::resourceStatusFromJson) .add(ConnectivityStatus.class, ConnectivityStatus::fromJson) diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/AbstractConnection.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/AbstractConnection.java new file mode 100644 index 0000000000..eea09317d5 --- /dev/null +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/AbstractConnection.java @@ -0,0 +1,432 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonCollectors; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonParseException; +import org.eclipse.ditto.json.JsonValue; + +/** + * Abstract implementation for common aspects of {@link Connection}. + * + * @since 3.2.0 + */ +abstract class AbstractConnection implements Connection { + + private final ConnectionId id; + @Nullable private final String name; + private final ConnectionType connectionType; + private final ConnectivityStatus connectionStatus; + final ConnectionUri uri; + @Nullable private final Credentials credentials; + @Nullable private final String trustedCertificates; + @Nullable private final ConnectionLifecycle lifecycle; + private final List sources; + private final List targets; + private final int clientCount; + private final boolean failOverEnabled; + private final boolean validateCertificate; + private final int processorPoolSize; + private final Map specificConfig; + private final PayloadMappingDefinition payloadMappingDefinition; + private final Set tags; + @Nullable private final SshTunnel sshTunnel; + + AbstractConnection(final AbstractConnectionBuilder builder) { + id = checkNotNull(builder.id, "id"); + name = builder.name; + connectionType = builder.connectionType; + connectionStatus = checkNotNull(builder.connectionStatus, "connectionStatus"); + credentials = builder.credentials; + trustedCertificates = builder.trustedCertificates; + uri = getConnectionUri(builder.uri); + sources = Collections.unmodifiableList(new ArrayList<>(builder.sources)); + targets = Collections.unmodifiableList(new ArrayList<>(builder.targets)); + clientCount = builder.clientCount; + failOverEnabled = builder.failOverEnabled; + validateCertificate = builder.validateCertificate; + processorPoolSize = builder.processorPoolSize; + specificConfig = Collections.unmodifiableMap(new HashMap<>(builder.specificConfig)); + payloadMappingDefinition = builder.payloadMappingDefinition; + tags = Collections.unmodifiableSet(new LinkedHashSet<>(builder.tags)); + lifecycle = builder.lifecycle; + sshTunnel = builder.sshTunnel; + } + + abstract ConnectionUri getConnectionUri(@Nullable String builderConnectionUri); + + static void buildFromJson (final JsonObject jsonObject, final AbstractConnectionBuilder builder) { + final MappingContext mappingContext = jsonObject.getValue(JsonFields.MAPPING_CONTEXT) + .map(ConnectivityModelFactory::mappingContextFromJson) + .orElse(null); + + final PayloadMappingDefinition payloadMappingDefinition = + jsonObject.getValue(JsonFields.MAPPING_DEFINITIONS) + .map(ImmutablePayloadMappingDefinition::fromJson) + .orElse(ConnectivityModelFactory.emptyPayloadMappingDefinition()); + builder.id(ConnectionId.of(jsonObject.getValueOrThrow(JsonFields.ID))) + .connectionStatus(getConnectionStatusOrThrow(jsonObject)) + .uri(jsonObject.getValueOrThrow(JsonFields.URI)) + .sources(getSources(jsonObject)) + .targets(getTargets(jsonObject)) + .name(jsonObject.getValue(JsonFields.NAME).orElse(null)) + .mappingContext(mappingContext) + .payloadMappingDefinition(payloadMappingDefinition) + .specificConfig(getSpecificConfiguration(jsonObject)) + .tags(getTags(jsonObject)); + + jsonObject.getValue(JsonFields.LIFECYCLE) + .flatMap(ConnectionLifecycle::forName).ifPresent(builder::lifecycle); + jsonObject.getValue(JsonFields.CREDENTIALS).ifPresent(builder::credentialsFromJson); + jsonObject.getValue(JsonFields.CLIENT_COUNT).ifPresent(builder::clientCount); + jsonObject.getValue(JsonFields.FAILOVER_ENABLED).ifPresent(builder::failoverEnabled); + jsonObject.getValue(JsonFields.VALIDATE_CERTIFICATES).ifPresent(builder::validateCertificate); + jsonObject.getValue(JsonFields.PROCESSOR_POOL_SIZE).ifPresent(builder::processorPoolSize); + jsonObject.getValue(JsonFields.TRUSTED_CERTIFICATES).ifPresent(builder::trustedCertificates); + jsonObject.getValue(JsonFields.SSH_TUNNEL) + .ifPresent(jsonFields -> builder.sshTunnel(ImmutableSshTunnel.fromJson(jsonFields))); + } + + static ConnectivityStatus getConnectionStatusOrThrow(final JsonObject jsonObject) { + final String readConnectionStatus = jsonObject.getValueOrThrow(JsonFields.CONNECTION_STATUS); + return ConnectivityStatus.forName(readConnectionStatus) + .orElseThrow(() -> JsonParseException.newBuilder() + .message(MessageFormat.format("Connection status <{0}> is invalid!", readConnectionStatus)) + .build()); + } + + private static List getSources(final JsonObject jsonObject) { + final Optional sourcesArray = jsonObject.getValue(JsonFields.SOURCES); + if (sourcesArray.isPresent()) { + final JsonArray values = sourcesArray.get(); + return IntStream.range(0, values.getSize()) + .mapToObj(index -> values.get(index) + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .map(valueAsObject -> ConnectivityModelFactory.sourceFromJson(valueAsObject, index))) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } else { + return Collections.emptyList(); + } + } + + private static List getTargets(final JsonObject jsonObject) { + return jsonObject.getValue(JsonFields.TARGETS) + .map(array -> array.stream() + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .map(ConnectivityModelFactory::targetFromJson) + .collect(Collectors.toList())) + .orElse(Collections.emptyList()); + } + + private static Map getSpecificConfiguration(final JsonObject jsonObject) { + return jsonObject.getValue(JsonFields.SPECIFIC_CONFIG) + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .map(JsonObject::stream) + .map(jsonFields -> jsonFields.collect(Collectors.toMap(JsonField::getKeyName, + f -> f.getValue().isString() ? f.getValue().asString() : f.getValue().toString()))) + .orElse(Collections.emptyMap()); + } + + private static Set getTags(final JsonObject jsonObject) { + return jsonObject.getValue(JsonFields.TAGS) + .map(array -> array.stream() + .filter(JsonValue::isString) + .map(JsonValue::asString) + .collect(Collectors.toCollection(LinkedHashSet::new))) + .orElseGet(LinkedHashSet::new); + } + + @Override + public ConnectionId getId() { + return id; + } + + @Override + public Optional getName() { + return Optional.ofNullable(name); + } + + @Override + public ConnectionType getConnectionType() { + return connectionType; + } + + @Override + public ConnectivityStatus getConnectionStatus() { + return connectionStatus; + } + + @Override + public List getSources() { + return sources; + } + + @Override + public List getTargets() { + return targets; + } + + @Override + public Optional getSshTunnel() { + return Optional.ofNullable(sshTunnel); + } + + @Override + public int getClientCount() { + return clientCount; + } + + @Override + public boolean isFailoverEnabled() { + return failOverEnabled; + } + + @Override + public Optional getCredentials() { + return Optional.ofNullable(credentials); + } + + @Override + public Optional getTrustedCertificates() { + return Optional.ofNullable(trustedCertificates); + } + + @Override + public String getUri() { + return uri.toString(); + } + + @Override + public String getProtocol() { + return uri.getProtocol(); + } + + @Override + public Optional getUsername() { + return uri.getUserName(); + } + + @Override + public Optional getPassword() { + return uri.getPassword(); + } + + @Override + public String getHostname() { + return uri.getHostname(); + } + + @Override + public int getPort() { + return uri.getPort(); + } + + @Override + public Optional getPath() { + return uri.getPath(); + } + + @Override + public boolean isValidateCertificates() { + return validateCertificate; + } + + @Override + public int getProcessorPoolSize() { + return processorPoolSize; + } + + @Override + public Map getSpecificConfig() { + return specificConfig; + } + + @Override + public PayloadMappingDefinition getPayloadMappingDefinition() { + return payloadMappingDefinition; + } + + @Override + public Set getTags() { + return tags; + } + + @Override + public Optional getLifecycle() { + return Optional.ofNullable(lifecycle); + } + + static ConnectionBuilder fromConnection(final Connection connection, final AbstractConnectionBuilder builder) { + checkNotNull(connection, "Connection"); + + return builder + .id(connection.getId()) + .connectionStatus(connection.getConnectionStatus()) + .credentials(connection.getCredentials().orElse(null)) + .uri(connection.getUri()) + .trustedCertificates(connection.getTrustedCertificates().orElse(null)) + .failoverEnabled(connection.isFailoverEnabled()) + .validateCertificate(connection.isValidateCertificates()) + .processorPoolSize(connection.getProcessorPoolSize()) + .sources(connection.getSources()) + .targets(connection.getTargets()) + .clientCount(connection.getClientCount()) + .specificConfig(connection.getSpecificConfig()) + .payloadMappingDefinition(connection.getPayloadMappingDefinition()) + .name(connection.getName().orElse(null)) + .sshTunnel(connection.getSshTunnel().orElse(null)) + .tags(connection.getTags()) + .lifecycle(connection.getLifecycle().orElse(null)); + } + + @Override + public JsonObject toJson(final JsonSchemaVersion schemaVersion, final Predicate thePredicate) { + final Predicate predicate = schemaVersion.and(thePredicate); + final JsonObjectBuilder jsonObjectBuilder = JsonFactory.newObjectBuilder(); + + if (null != lifecycle) { + jsonObjectBuilder.set(JsonFields.LIFECYCLE, lifecycle.name(), predicate); + } + jsonObjectBuilder.set(JsonFields.ID, String.valueOf(id), predicate); + jsonObjectBuilder.set(JsonFields.NAME, name, predicate); + jsonObjectBuilder.set(JsonFields.CONNECTION_TYPE, connectionType.getName(), predicate); + jsonObjectBuilder.set(JsonFields.CONNECTION_STATUS, connectionStatus.getName(), predicate); + jsonObjectBuilder.set(JsonFields.URI, uri.toString(), predicate); + jsonObjectBuilder.set(JsonFields.SOURCES, sources.stream() + .sorted(Comparator.comparingInt(Source::getIndex)) + .map(source -> source.toJson(schemaVersion, thePredicate)) + .collect(JsonCollectors.valuesToArray()), predicate.and(Objects::nonNull)); + jsonObjectBuilder.set(JsonFields.TARGETS, targets.stream() + .map(target -> target.toJson(schemaVersion, thePredicate)) + .collect(JsonCollectors.valuesToArray()), predicate.and(Objects::nonNull)); + jsonObjectBuilder.set(JsonFields.CLIENT_COUNT, clientCount, predicate); + jsonObjectBuilder.set(JsonFields.FAILOVER_ENABLED, failOverEnabled, predicate); + jsonObjectBuilder.set(JsonFields.VALIDATE_CERTIFICATES, validateCertificate, predicate); + jsonObjectBuilder.set(JsonFields.PROCESSOR_POOL_SIZE, processorPoolSize, predicate); + if (!specificConfig.isEmpty()) { + jsonObjectBuilder.set(JsonFields.SPECIFIC_CONFIG, specificConfig.entrySet() + .stream() + .map(entry -> JsonField.newInstance(entry.getKey(), JsonValue.of(entry.getValue()))) + .collect(JsonCollectors.fieldsToObject()), predicate); + } + if (!payloadMappingDefinition.isEmpty()) { + jsonObjectBuilder.set(JsonFields.MAPPING_DEFINITIONS, + payloadMappingDefinition.toJson(schemaVersion, thePredicate)); + } + if (credentials != null) { + jsonObjectBuilder.set(JsonFields.CREDENTIALS, credentials.toJson()); + } + if (trustedCertificates != null) { + jsonObjectBuilder.set(JsonFields.TRUSTED_CERTIFICATES, trustedCertificates, predicate); + } + if (sshTunnel != null) { + jsonObjectBuilder.set(JsonFields.SSH_TUNNEL, sshTunnel.toJson(predicate), predicate); + } + jsonObjectBuilder.set(JsonFields.TAGS, tags.stream() + .map(JsonFactory::newValue) + .collect(JsonCollectors.valuesToArray()), predicate); + return jsonObjectBuilder.build(); + } + + @SuppressWarnings("OverlyComplexMethod") + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final AbstractConnection that = (AbstractConnection) o; + return failOverEnabled == that.failOverEnabled && + Objects.equals(id, that.id) && + Objects.equals(name, that.name) && + Objects.equals(connectionType, that.connectionType) && + Objects.equals(connectionStatus, that.connectionStatus) && + Objects.equals(sources, that.sources) && + Objects.equals(targets, that.targets) && + Objects.equals(clientCount, that.clientCount) && + Objects.equals(credentials, that.credentials) && + Objects.equals(trustedCertificates, that.trustedCertificates) && + Objects.equals(uri, that.uri) && + Objects.equals(processorPoolSize, that.processorPoolSize) && + Objects.equals(validateCertificate, that.validateCertificate) && + Objects.equals(specificConfig, that.specificConfig) && + Objects.equals(payloadMappingDefinition, that.payloadMappingDefinition) && + Objects.equals(lifecycle, that.lifecycle) && + Objects.equals(sshTunnel, that.sshTunnel) && + Objects.equals(tags, that.tags); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, connectionType, connectionStatus, sources, targets, clientCount, failOverEnabled, + credentials, trustedCertificates, uri, validateCertificate, processorPoolSize, specificConfig, + payloadMappingDefinition, sshTunnel, tags, lifecycle); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "id=" + id + + ", name=" + name + + ", connectionType=" + connectionType + + ", connectionStatus=" + connectionStatus + + ", failoverEnabled=" + failOverEnabled + + ", credentials=" + credentials + + ", trustedCertificates=hash:" + Objects.hash(trustedCertificates) + + ", uri=" + uri.getUriStringWithMaskedPassword() + + ", sources=" + sources + + ", targets=" + targets + + ", sshTunnel=" + sshTunnel + + ", clientCount=" + clientCount + + ", validateCertificate=" + validateCertificate + + ", processorPoolSize=" + processorPoolSize + + ", specificConfig=" + specificConfig + + ", payloadMappingDefinition=" + payloadMappingDefinition + + ", tags=" + tags + + ", lifecycle=" + lifecycle + + "]"; + } + +} \ No newline at end of file diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/AbstractConnectionBuilder.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/AbstractConnectionBuilder.java new file mode 100644 index 0000000000..5bf00d4797 --- /dev/null +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/AbstractConnectionBuilder.java @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkArgument; +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +/** + * Abstract implementation for common aspects of {@link ConnectionBuilder}. + * + * @since 3.2.0 + */ + +@Immutable +abstract class AbstractConnectionBuilder implements ConnectionBuilder { + + private static final String MIGRATED_MAPPER_ID = "javascript"; + + // required but changeable: + @Nullable ConnectionId id; + @Nullable ConnectivityStatus connectionStatus; + String uri; + // optional: + @Nullable String name = null; + @Nullable Credentials credentials; + @Nullable MappingContext mappingContext = null; + @Nullable String trustedCertificates; + @Nullable ConnectionLifecycle lifecycle = null; + @Nullable SshTunnel sshTunnel = null; + + // optional with default: + Set tags = new LinkedHashSet<>(); + boolean failOverEnabled = true; + boolean validateCertificate = true; + final List sources = new ArrayList<>(); + final List targets = new ArrayList<>(); + int clientCount = 1; + int processorPoolSize = 1; + PayloadMappingDefinition payloadMappingDefinition = + ConnectivityModelFactory.emptyPayloadMappingDefinition(); + final Map specificConfig = new HashMap<>(); + ConnectionType connectionType; + + AbstractConnectionBuilder(final ConnectionType connectionType) { + this.connectionType = connectionType; + } + + private static boolean isBlankOrNull(@Nullable final String toTest) { + return null == toTest || toTest.trim().isEmpty(); + } + + @Override + public ConnectionBuilder id(final ConnectionId id) { + this.id = checkNotNull(id, "id"); + return this; + } + + @Override + public ConnectionBuilder name(@Nullable final String name) { + this.name = name; + return this; + } + + @Override + public ConnectionBuilder credentials(@Nullable final Credentials credentials) { + this.credentials = credentials; + return this; + } + + @Override + public ConnectionBuilder trustedCertificates(@Nullable final String trustedCertificates) { + if (isBlankOrNull(trustedCertificates)) { + this.trustedCertificates = null; + } else { + this.trustedCertificates = trustedCertificates; + } + return this; + } + + @Override + public ConnectionBuilder uri(final String uri) { + this.uri = checkNotNull(uri, "uri"); + return this; + } + + @Override + public ConnectionBuilder connectionStatus(final ConnectivityStatus connectionStatus) { + this.connectionStatus = checkNotNull(connectionStatus, "connectionStatus"); + return this; + } + + @Override + public ConnectionBuilder failoverEnabled(final boolean failOverEnabled) { + this.failOverEnabled = failOverEnabled; + return this; + } + + @Override + public ConnectionBuilder validateCertificate(final boolean validateCertificate) { + this.validateCertificate = validateCertificate; + return this; + } + + @Override + public ConnectionBuilder processorPoolSize(final int processorPoolSize) { + checkArgument(processorPoolSize, ps -> ps > 0, () -> "The processor pool size must be positive!"); + this.processorPoolSize = processorPoolSize; + return this; + } + + @Override + public ConnectionBuilder sources(final List sources) { + this.sources.addAll(checkNotNull(sources, "sources")); + return this; + } + + @Override + public ConnectionBuilder targets(final List targets) { + this.targets.addAll(checkNotNull(targets, "targets")); + return this; + } + + @Override + public ConnectionBuilder setSources(final List sources) { + this.sources.clear(); + return sources(sources); + } + + @Override + public ConnectionBuilder setTargets(final List targets) { + this.targets.clear(); + return targets(targets); + } + + @Override + public ConnectionBuilder clientCount(final int clientCount) { + checkArgument(clientCount, ps -> ps > 0, () -> "The client count must be positive!"); + this.clientCount = clientCount; + return this; + } + + @Override + public ConnectionBuilder specificConfig(final Map specificConfig) { + this.specificConfig.putAll(checkNotNull(specificConfig, "specificConfig")); + return this; + } + + @Override + public ConnectionBuilder mappingContext(@Nullable final MappingContext mappingContext) { + this.mappingContext = mappingContext; + return this; + } + + @Override + public ConnectionBuilder tags(final Collection tags) { + this.tags = new LinkedHashSet<>(checkNotNull(tags, "tags to set")); + return this; + } + + @Override + public ConnectionBuilder tag(final String tag) { + tags.add(checkNotNull(tag, "tag to set")); + return this; + } + + @Override + public ConnectionBuilder lifecycle(@Nullable final ConnectionLifecycle lifecycle) { + this.lifecycle = lifecycle; + return this; + } + + @Override + public ConnectionBuilder sshTunnel(@Nullable final SshTunnel sshTunnel) { + this.sshTunnel = sshTunnel; + return this; + } + + @Override + public ConnectionBuilder payloadMappingDefinition(final PayloadMappingDefinition payloadMappingDefinition) { + this.payloadMappingDefinition = payloadMappingDefinition; + return this; + } + + private boolean shouldMigrateMappingContext() { + return mappingContext != null; + } + + void migrateLegacyConfigurationOnTheFly() { + if (shouldMigrateMappingContext()) { + this.payloadMappingDefinition = + payloadMappingDefinition.withDefinition(MIGRATED_MAPPER_ID, mappingContext); + } + setSources(sources.stream().map(this::migrateSource).collect(Collectors.toList())); + setTargets(targets.stream().map(this::migrateTarget).collect(Collectors.toList())); + } + + private Source migrateSource(final Source source) { + final Source sourceAfterReplyTargetMigration = ImmutableSource.migrateReplyTarget(source, connectionType); + if (shouldMigrateMappingContext()) { + return new ImmutableSource.Builder(sourceAfterReplyTargetMigration) + .payloadMapping(addMigratedPayloadMappings(source.getPayloadMapping())) + .build(); + } else { + return sourceAfterReplyTargetMigration; + } + } + + private Target migrateTarget(final Target target) { + final boolean shouldAddHeaderMapping = shouldAddDefaultHeaderMappingToTarget(connectionType); + final boolean shouldMigrateMappingContext = shouldMigrateMappingContext(); + if (shouldMigrateMappingContext || shouldAddHeaderMapping) { + final TargetBuilder builder = new ImmutableTarget.Builder(target); + if (shouldMigrateMappingContext) { + builder.payloadMapping(addMigratedPayloadMappings(target.getPayloadMapping())); + } + if (shouldAddHeaderMapping) { + builder.headerMapping(target.getHeaderMapping()); + } + return builder.build(); + } else { + return target; + } + } + + private boolean shouldAddDefaultHeaderMappingToTarget(final ConnectionType connectionType) { + switch (connectionType) { + case AMQP_091: + case AMQP_10: + case KAFKA: + case MQTT_5: + case HONO: + return true; + case MQTT: + case HTTP_PUSH: + default: + return false; + } + } + + + private PayloadMapping addMigratedPayloadMappings(final PayloadMapping payloadMapping) { + final ArrayList merged = new ArrayList<>(payloadMapping.getMappings()); + merged.add(MIGRATED_MAPPER_ID); + return ConnectivityModelFactory.newPayloadMapping(merged); + } + + void checkSourceAndTargetAreValid() { + if (sources.isEmpty() && targets.isEmpty()) { + throw ConnectionConfigurationInvalidException.newBuilder("Either a source or a target must be " + + "specified in the configuration of a connection!").build(); + } + } + + /** + * If no context is set on connection level each target and source must have its own context. + */ + void checkAuthorizationContextsAreValid() { + // if the auth context on connection level is empty, + // an auth context is required to be set on each source/target + final Set sourcesWithoutAuthContext = sources.stream() + .filter(source -> source.getAuthorizationContext().isEmpty()) + .flatMap(source -> source.getAddresses().stream()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + final Set targetsWithoutAuthContext = targets.stream() + .filter(target -> target.getAuthorizationContext().isEmpty()) + .map(Target::getAddress) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + if (!sourcesWithoutAuthContext.isEmpty() || !targetsWithoutAuthContext.isEmpty()) { + final StringBuilder message = new StringBuilder("The "); + if (!sourcesWithoutAuthContext.isEmpty()) { + message.append("Sources ").append(sourcesWithoutAuthContext); + } + if (!sourcesWithoutAuthContext.isEmpty() && !targetsWithoutAuthContext.isEmpty()) { + message.append(" and "); + } + if (!targetsWithoutAuthContext.isEmpty()) { + message.append("Targets ").append(targetsWithoutAuthContext); + } + message.append(" are missing an authorization context."); + throw ConnectionConfigurationInvalidException.newBuilder(message.toString()).build(); + } + } + + void checkConnectionAnnouncementsOnlySetIfClientCount1() { + if (clientCount > 1 && containsTargetWithConnectionAnnouncementsTopic()) { + final String message = MessageFormat.format("Connection announcements (topic {0}) can" + + " only be used with client count 1.", Topic.CONNECTION_ANNOUNCEMENTS.getName()); + throw ConnectionConfigurationInvalidException.newBuilder(message) + .build(); + } + } + + private boolean containsTargetWithConnectionAnnouncementsTopic() { + return targets.stream() + .map(Target::getTopics) + .flatMap(Set::stream) + .map(FilteredTopic::getTopic) + .anyMatch(Topic.CONNECTION_ANNOUNCEMENTS::equals); + } + +} \ No newline at end of file diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionConfigurationInvalidException.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionConfigurationInvalidException.java index 3578d459b9..13b40f0962 100644 --- a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionConfigurationInvalidException.java +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionConfigurationInvalidException.java @@ -26,7 +26,7 @@ import org.eclipse.ditto.base.model.json.JsonParsableException; /** - * Thrown if the the configuration of a {@link Connection} was invalid. + * Thrown if the configuration of a {@link Connection} was invalid. */ @Immutable @JsonParsableException(errorCode = ConnectionConfigurationInvalidException.ERROR_CODE) diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionType.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionType.java index 279a45bac0..a6d1a29983 100644 --- a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionType.java +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionType.java @@ -50,7 +50,13 @@ public enum ConnectionType implements CharSequence { /** * Indicates a MQTT 5 connection. */ - MQTT_5("mqtt-5"); + MQTT_5("mqtt-5"), + + /** + * Indicates a connection to Eclipse Hono. + * @since 3.2.0 + */ + HONO("hono"); private final String name; diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionUri.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionUri.java new file mode 100644 index 0000000000..ec75d683f2 --- /dev/null +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectionUri.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model; + +import java.net.URI; +import java.net.URISyntaxException; +import java.text.MessageFormat; +import java.util.Objects; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +/** + * Represents an uri within the Connectivity service. + * + * @since 3.2.0 + */ +@Immutable +final class ConnectionUri { + + private static final String MASKED_URI_PATTERN = "{0}://{1}{2}:{3,number,#}{4}"; + + @SuppressWarnings("squid:S2068") // S2068 tripped due to 'PASSWORD' in variable name + private static final String USERNAME_PASSWORD_SEPARATOR = ":"; + + private final String uriString; + private final String protocol; + private final String hostname; + private final int port; + private final String path; + @Nullable private final String userName; + @Nullable private final String password; + private final String uriStringWithMaskedPassword; + + private ConnectionUri(@Nullable final String theUriString) { + if (!isBlankOrNull(theUriString)) { + final URI uri; + try { + uri = new URI(theUriString).parseServerAuthority(); + } catch (final URISyntaxException e) { + throw ConnectionUriInvalidException.newBuilder(theUriString).build(); + } + // validate self + if (!isValid(uri)) { + throw ConnectionUriInvalidException.newBuilder(theUriString).build(); + } + uriString = uri.toASCIIString(); + protocol = uri.getScheme(); + hostname = uri.getHost(); + port = uri.getPort(); + path = uri.getPath(); + + // initialize nullable fields + final String userInfo = uri.getUserInfo(); + if (userInfo != null && userInfo.contains(USERNAME_PASSWORD_SEPARATOR)) { + final int separatorIndex = userInfo.indexOf(USERNAME_PASSWORD_SEPARATOR); + userName = userInfo.substring(0, separatorIndex); + password = userInfo.substring(separatorIndex + 1); + } else { + userName = null; + password = null; + } + + // must be initialized after all else + uriStringWithMaskedPassword = createUriStringWithMaskedPassword(); + } else { + uriString = ""; + protocol = ""; + hostname = ""; + port = 9999; + path = ""; + userName = null; + password = null; + uriStringWithMaskedPassword = ""; + } + } + + private String createUriStringWithMaskedPassword() { + return MessageFormat.format(MASKED_URI_PATTERN, protocol, getUserCredentialsOrEmptyString(), hostname, port, + getPathOrEmptyString()); + } + + private String getUserCredentialsOrEmptyString() { + if (null != userName && null != password) { + return userName + ":*****@"; + } + return ""; + } + + private String getPathOrEmptyString() { + return getPath().orElse(""); + } + + /** + * Test validity of a connection URI. A connection URI is valid if it has an explicit port number ,has no query + * parameters, and has a nonempty password whenever it has a nonempty username. + * + * @param uri the URI object with which the connection URI is created. + * @return whether the connection URI is valid. + */ + static boolean isValid(final URI uri) { + return uri.getPort() > 0 && uri.getQuery() == null; + } + + /** + * Returns a new instance of {@code ConnectionUri}. The is the reverse function of {@link #toString()}. + * + * @param uriString the string representation of the Connection URI. + * @return the instance. + * @throws NullPointerException if {@code uriString} is {@code null}. + * @throws ConnectionUriInvalidException if {@code uriString} is not a valid URI. + * @see #toString() + */ + static ConnectionUri of( @Nullable final String uriString) { + return new ConnectionUri(uriString); + } + + String getProtocol() { + return protocol; + } + + Optional getUserName() { + return Optional.ofNullable(userName); + } + + Optional getPassword() { + return Optional.ofNullable(password); + } + + String getHostname() { + return hostname; + } + + int getPort() { + return port; + } + + static boolean isBlankOrNull(@Nullable final String toTest) { + return null == toTest || toTest.trim().isEmpty(); + } + + /** + * Returns the path or an empty string. + * + * @return the path or an empty string. + */ + Optional getPath() { + return path.isEmpty() ? Optional.empty() : Optional.of(path); + } + + String getUriStringWithMaskedPassword() { + return uriStringWithMaskedPassword; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final ConnectionUri that = (ConnectionUri) o; + return Objects.equals(uriString, that.uriString); + } + + @Override + public int hashCode() { + return Objects.hash(uriString); + } + + /** + * @return the string representation of this ConnectionUri. This is the reverse function of {@link #of(String)}. + * @see #of(String) + */ + @Override + public String toString() { + return uriString; + } + +} \ No newline at end of file diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectivityModelFactory.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectivityModelFactory.java old mode 100644 new mode 100755 index 0ffcce3281..61ca499af9 --- a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectivityModelFactory.java +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ConnectivityModelFactory.java @@ -70,8 +70,13 @@ public static ConnectionBuilder newConnectionBuilder(final ConnectionId id, final ConnectionType connectionType, final ConnectivityStatus connectionStatus, final String uri) { - - return ImmutableConnection.getBuilder(id, connectionType, connectionStatus, uri); + final ConnectionBuilder builder; + if (connectionType == ConnectionType.HONO) { + builder = HonoConnection.getBuilder(id, connectionType, connectionStatus, uri); + } else { + builder = ImmutableConnection.getBuilder(id, connectionType, connectionStatus, uri); + } + return builder; } /** @@ -83,7 +88,17 @@ public static ConnectionBuilder newConnectionBuilder(final ConnectionId id, * @throws NullPointerException if {@code connection} is {@code null}. */ public static ConnectionBuilder newConnectionBuilder(final Connection connection) { - return ImmutableConnection.getBuilder(connection); + final ConnectionBuilder builder; + if (isHonoConnectionType(connection)) { + builder = HonoConnection.getBuilder(connection); + } else { + builder = ImmutableConnection.getBuilder(connection); + } + return builder; + } + + private static boolean isHonoConnectionType(final Connection connection) { + return connection.getConnectionType() == ConnectionType.HONO; } /** @@ -95,7 +110,20 @@ public static ConnectionBuilder newConnectionBuilder(final Connection connection * @throws org.eclipse.ditto.json.JsonParseException if {@code jsonObject} is not an appropriate JSON object. */ public static Connection connectionFromJson(final JsonObject jsonObject) { - return ImmutableConnection.fromJson(jsonObject); + final Connection connection; + if (isHonoConnectionType(jsonObject)) { + connection = HonoConnection.fromJson(jsonObject); + } else { + connection = ImmutableConnection.fromJson(jsonObject); + } + return connection; + } + + private static boolean isHonoConnectionType(final JsonObject connectionJsonObject) { + return connectionJsonObject.getValue(Connection.JsonFields.CONNECTION_TYPE) + .flatMap(ConnectionType::forName) + .filter(ConnectionType.HONO::equals) + .isPresent(); } /** diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/HonoAddressAlias.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/HonoAddressAlias.java new file mode 100644 index 0000000000..27c3ffe890 --- /dev/null +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/HonoAddressAlias.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.Nullable; + +/** + * Possible address aliases used by connections of type 'Hono'. + * + * @since 3.2.0 + */ +public enum HonoAddressAlias { + + /** + * telemetry address alias. + */ + TELEMETRY("telemetry"), + + /** + * event address alias. + */ + EVENT("event"), + + /** + * command&control address alias. + */ + COMMAND("command"), + + /** + * command response address alias. + */ + COMMAND_RESPONSE("command_response"); + + private static final Map HONO_ADDRESS_ALIASES_BY_ALIAS_VALUE = + Collections.unmodifiableMap( + Stream.of(HonoAddressAlias.values()) + .collect(Collectors.toMap(HonoAddressAlias::getAliasValue, Function.identity())) + ); + + private final String value; + + HonoAddressAlias(final String value) { + this.value = value; + } + + /** + * Returns the HonoAddressAlias to which the given alias value is mapped. + * + * @param aliasValue the aliasValue of the supposed HonoAddressAlias. + * @return an Optional containing the HonoAddressAlias which matches {@code aliasValue} or an empty Optional if none + * matches. + */ + public static Optional forAliasValue(@Nullable final String aliasValue) { + return Optional.ofNullable(HONO_ADDRESS_ALIASES_BY_ALIAS_VALUE.get(aliasValue)); + } + + /** + * Gets the value of the alias. + * + * @return the value of the alias. + */ + public String getAliasValue() { + return value; + } + +} diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/HonoConnection.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/HonoConnection.java new file mode 100755 index 0000000000..e92ff37a86 --- /dev/null +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/HonoConnection.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.text.MessageFormat; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import javax.annotation.concurrent.NotThreadSafe; + +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonParseException; + +/** + * Immutable implementation of {@link AbstractConnection} of {@link ConnectionType} HONO. + * + * @since 3.2.0 + */ +@Immutable +final class HonoConnection extends AbstractConnection { + + private HonoConnection(final Builder builder) { + super(builder); + } + + @Override + ConnectionUri getConnectionUri(@Nullable String builderConnectionUri) { + return ConnectionUri.of(builderConnectionUri); + } + + static ConnectionType getConnectionTypeOrThrow(final JsonObject jsonObject) { + final String readConnectionType = jsonObject.getValueOrThrow(JsonFields.CONNECTION_TYPE); + return ConnectionType.forName(readConnectionType).filter(type -> type == ConnectionType.HONO) + .orElseThrow(() -> JsonParseException.newBuilder() + .message(MessageFormat.format("Connection type <{0}> is invalid! Connection type must be of" + + " type <{1}>.", readConnectionType, ConnectionType.HONO)) + .build()); + } + + /** + * Returns a new {@code ConnectionBuilder} object. + * + * @param id the connection ID. + * @param connectionType the connection type. + * @param connectionStatus the connection status. + * @param uri the URI. + * @return new instance of {@code ConnectionBuilder}. + * @throws NullPointerException if any argument is {@code null}. + */ + public static ConnectionBuilder getBuilder(final ConnectionId id, + final ConnectionType connectionType, + final ConnectivityStatus connectionStatus, + final String uri) { + + return new HonoConnection.Builder(connectionType) + .id(id) + .connectionStatus(connectionStatus) + .uri(ConnectionUri.of(uri).toString()); + } + + /** + * Returns a new {@code ConnectionBuilder} object. + * + * @param connection the connection to use for initializing the builder. + * @return new instance of {@code ConnectionBuilder}. + * @throws NullPointerException if {@code connection} is {@code null}. + */ + public static ConnectionBuilder getBuilder(final Connection connection) { + checkNotNull(connection, "connection"); + return fromConnection(connection, + new HonoConnection.Builder(connection.getConnectionType())); + } + + /** + * Creates a new {@code Connection} object from the specified JSON object. + * + * @param jsonObject a JSON object which provides the data for the Connection to be created. + * @return a new Connection which is initialised with the extracted data from {@code jsonObject}. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonParseException if {@code jsonObject} is not an appropriate JSON object. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if {@code jsonObject} does not contain a value at the defined location. + */ + public static Connection fromJson(final JsonObject jsonObject) { + final ConnectionType type = getConnectionTypeOrThrow(jsonObject); + final HonoConnection.Builder builder = new HonoConnection.Builder(type); + buildFromJson(getJsonObjectWithEmptyUri(jsonObject), builder); + return builder.build(); + } + + private static JsonObject getJsonObjectWithEmptyUri(final JsonObject jsonObject) { + if (!jsonObject.contains(JsonFields.URI.getPointer())) { + return jsonObject.set(JsonFields.URI, ""); + } + if (!jsonObject.getValue(Connection.JsonFields.URI).isPresent()) { + return jsonObject.set(JsonFields.URI, ""); + } + return jsonObject; + } + + /** + * Builder for {@code AbstractConnectionBuilder}. + */ + @NotThreadSafe + private static final class Builder extends AbstractConnectionBuilder { + + Builder(final ConnectionType connectionType) { + super(connectionType); + } + + @Override + public Connection build() { + super.checkSourceAndTargetAreValid(); + super.checkAuthorizationContextsAreValid(); + super.checkConnectionAnnouncementsOnlySetIfClientCount1(); + super.migrateLegacyConfigurationOnTheFly(); + return new HonoConnection(this); + } + + } + +} + diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableConnection.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableConnection.java index f34c464de3..fc3df1d96f 100644 --- a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableConnection.java +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableConnection.java @@ -12,87 +12,25 @@ */ package org.eclipse.ditto.connectivity.model; -import static org.eclipse.ditto.base.model.common.ConditionChecker.checkArgument; import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; -import java.net.URI; -import java.net.URISyntaxException; import java.text.MessageFormat; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.IntStream; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import javax.annotation.concurrent.NotThreadSafe; -import org.eclipse.ditto.base.model.json.JsonSchemaVersion; -import org.eclipse.ditto.json.JsonArray; -import org.eclipse.ditto.json.JsonCollectors; -import org.eclipse.ditto.json.JsonFactory; -import org.eclipse.ditto.json.JsonField; import org.eclipse.ditto.json.JsonObject; -import org.eclipse.ditto.json.JsonObjectBuilder; import org.eclipse.ditto.json.JsonParseException; -import org.eclipse.ditto.json.JsonValue; /** * Immutable implementation of {@link Connection}. */ @Immutable -final class ImmutableConnection implements Connection { +final class ImmutableConnection extends AbstractConnection { - private final ConnectionId id; - @Nullable private final String name; - private final ConnectionType connectionType; - private final ConnectivityStatus connectionStatus; - private final ConnectionUri uri; - @Nullable private final Credentials credentials; - @Nullable private final String trustedCertificates; - @Nullable private final ConnectionLifecycle lifecycle; - - - private final List sources; - private final List targets; - private final int clientCount; - private final boolean failOverEnabled; - private final boolean validateCertificate; - private final int processorPoolSize; - private final Map specificConfig; - private final PayloadMappingDefinition payloadMappingDefinition; - private final Set tags; - @Nullable private final SshTunnel sshTunnel; - - private ImmutableConnection(final Builder builder) { - id = checkNotNull(builder.id, "id"); - name = builder.name; - connectionType = builder.connectionType; - connectionStatus = checkNotNull(builder.connectionStatus, "connectionStatus"); - credentials = builder.credentials; - trustedCertificates = builder.trustedCertificates; - uri = ConnectionUri.of(checkNotNull(builder.uri, "uri")); - sources = Collections.unmodifiableList(new ArrayList<>(builder.sources)); - targets = Collections.unmodifiableList(new ArrayList<>(builder.targets)); - clientCount = builder.clientCount; - failOverEnabled = builder.failOverEnabled; - validateCertificate = builder.validateCertificate; - processorPoolSize = builder.processorPoolSize; - specificConfig = Collections.unmodifiableMap(new HashMap<>(builder.specificConfig)); - payloadMappingDefinition = builder.payloadMappingDefinition; - tags = Collections.unmodifiableSet(new LinkedHashSet<>(builder.tags)); - lifecycle = builder.lifecycle; - sshTunnel = builder.sshTunnel; + private ImmutableConnection(final ImmutableConnection.Builder builder) { + super(builder); } /** @@ -124,28 +62,16 @@ public static ConnectionBuilder getBuilder(final ConnectionId id, * @throws NullPointerException if {@code connection} is {@code null}. */ public static ConnectionBuilder getBuilder(final Connection connection) { - checkNotNull(connection, "Connection"); + checkNotNull(connection, "connection"); + return fromConnection(connection, new Builder(connection.getConnectionType())); + } - return new Builder(connection.getConnectionType()) - .id(connection.getId()) - .connectionStatus(connection.getConnectionStatus()) - .credentials(connection.getCredentials().orElse(null)) - .uri(connection.getUri()) - .trustedCertificates(connection.getTrustedCertificates().orElse(null)) - .failoverEnabled(connection.isFailoverEnabled()) - .validateCertificate(connection.isValidateCertificates()) - .processorPoolSize(connection.getProcessorPoolSize()) - .sources(connection.getSources()) - .targets(connection.getTargets()) - .clientCount(connection.getClientCount()) - .specificConfig(connection.getSpecificConfig()) - .payloadMappingDefinition(connection.getPayloadMappingDefinition()) - .name(connection.getName().orElse(null)) - .sshTunnel(connection.getSshTunnel().orElse(null)) - .tags(connection.getTags()) - .lifecycle(connection.getLifecycle().orElse(null)); + @Override + ConnectionUri getConnectionUri(@Nullable String builderConnectionUri) { + return ConnectionUri.of(checkNotNull(builderConnectionUri, "uri")); } + /** * Creates a new {@code Connection} object from the specified JSON object. * @@ -156,42 +82,13 @@ public static ConnectionBuilder getBuilder(final Connection connection) { */ public static Connection fromJson(final JsonObject jsonObject) { final ConnectionType type = getConnectionTypeOrThrow(jsonObject); - final MappingContext mappingContext = jsonObject.getValue(JsonFields.MAPPING_CONTEXT) - .map(ConnectivityModelFactory::mappingContextFromJson) - .orElse(null); - - final PayloadMappingDefinition payloadMappingDefinition = - jsonObject.getValue(JsonFields.MAPPING_DEFINITIONS) - .map(ImmutablePayloadMappingDefinition::fromJson) - .orElse(ConnectivityModelFactory.emptyPayloadMappingDefinition()); - - final ConnectionBuilder builder = new Builder(type) - .id(ConnectionId.of(jsonObject.getValueOrThrow(JsonFields.ID))) - .connectionStatus(getConnectionStatusOrThrow(jsonObject)) - .uri(jsonObject.getValueOrThrow(JsonFields.URI)) - .sources(getSources(jsonObject)) - .targets(getTargets(jsonObject)) - .name(jsonObject.getValue(JsonFields.NAME).orElse(null)) - .mappingContext(mappingContext) - .payloadMappingDefinition(payloadMappingDefinition) - .specificConfig(getSpecificConfiguration(jsonObject)) - .tags(getTags(jsonObject)); - - jsonObject.getValue(JsonFields.LIFECYCLE) - .flatMap(ConnectionLifecycle::forName).ifPresent(builder::lifecycle); - jsonObject.getValue(JsonFields.CREDENTIALS).ifPresent(builder::credentialsFromJson); - jsonObject.getValue(JsonFields.CLIENT_COUNT).ifPresent(builder::clientCount); - jsonObject.getValue(JsonFields.FAILOVER_ENABLED).ifPresent(builder::failoverEnabled); - jsonObject.getValue(JsonFields.VALIDATE_CERTIFICATES).ifPresent(builder::validateCertificate); - jsonObject.getValue(JsonFields.PROCESSOR_POOL_SIZE).ifPresent(builder::processorPoolSize); - jsonObject.getValue(JsonFields.TRUSTED_CERTIFICATES).ifPresent(builder::trustedCertificates); - jsonObject.getValue(JsonFields.SSH_TUNNEL) - .ifPresent(jsonFields -> builder.sshTunnel(ImmutableSshTunnel.fromJson(jsonFields))); + final ImmutableConnection.Builder builder = new Builder(type); + buildFromJson(jsonObject, builder); return builder.build(); } - private static ConnectionType getConnectionTypeOrThrow(final JsonObject jsonObject) { + static ConnectionType getConnectionTypeOrThrow(final JsonObject jsonObject) { final String readConnectionType = jsonObject.getValueOrThrow(JsonFields.CONNECTION_TYPE); return ConnectionType.forName(readConnectionType) .orElseThrow(() -> JsonParseException.newBuilder() @@ -199,462 +96,14 @@ private static ConnectionType getConnectionTypeOrThrow(final JsonObject jsonObje .build()); } - private static ConnectivityStatus getConnectionStatusOrThrow(final JsonObject jsonObject) { - final String readConnectionStatus = jsonObject.getValueOrThrow(JsonFields.CONNECTION_STATUS); - return ConnectivityStatus.forName(readConnectionStatus) - .orElseThrow(() -> JsonParseException.newBuilder() - .message(MessageFormat.format("Connection status <{0}> is invalid!", readConnectionStatus)) - .build()); - } - - private static List getSources(final JsonObject jsonObject) { - final Optional sourcesArray = jsonObject.getValue(JsonFields.SOURCES); - if (sourcesArray.isPresent()) { - final JsonArray values = sourcesArray.get(); - return IntStream.range(0, values.getSize()) - .mapToObj(index -> values.get(index) - .filter(JsonValue::isObject) - .map(JsonValue::asObject) - .map(valueAsObject -> ConnectivityModelFactory.sourceFromJson(valueAsObject, index))) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toList()); - } else { - return Collections.emptyList(); - } - } - - private static List getTargets(final JsonObject jsonObject) { - return jsonObject.getValue(JsonFields.TARGETS) - .map(array -> array.stream() - .filter(JsonValue::isObject) - .map(JsonValue::asObject) - .map(ConnectivityModelFactory::targetFromJson) - .collect(Collectors.toList())) - .orElse(Collections.emptyList()); - } - - private static Map getSpecificConfiguration(final JsonObject jsonObject) { - return jsonObject.getValue(JsonFields.SPECIFIC_CONFIG) - .filter(JsonValue::isObject) - .map(JsonValue::asObject) - .map(JsonObject::stream) - .map(jsonFields -> jsonFields.collect(Collectors.toMap(JsonField::getKeyName, - f -> f.getValue().isString() ? f.getValue().asString() : f.getValue().toString()))) - .orElse(Collections.emptyMap()); - } - - private static Set getTags(final JsonObject jsonObject) { - return jsonObject.getValue(JsonFields.TAGS) - .map(array -> array.stream() - .filter(JsonValue::isString) - .map(JsonValue::asString) - .collect(Collectors.toCollection(LinkedHashSet::new))) - .orElseGet(LinkedHashSet::new); - } - - @Override - public ConnectionId getId() { - return id; - } - - @Override - public Optional getName() { - return Optional.ofNullable(name); - } - - @Override - public ConnectionType getConnectionType() { - return connectionType; - } - - @Override - public ConnectivityStatus getConnectionStatus() { - return connectionStatus; - } - - @Override - public List getSources() { - return sources; - } - - @Override - public List getTargets() { - return targets; - } - - @Override - public Optional getSshTunnel() { - return Optional.ofNullable(sshTunnel); - } - - @Override - public int getClientCount() { - return clientCount; - } - - @Override - public boolean isFailoverEnabled() { - return failOverEnabled; - } - - @Override - public Optional getCredentials() { - return Optional.ofNullable(credentials); - } - - @Override - public Optional getTrustedCertificates() { - return Optional.ofNullable(trustedCertificates); - } - - @Override - public String getUri() { - return uri.toString(); - } - - @Override - public String getProtocol() { - return uri.getProtocol(); - } - - @Override - public Optional getUsername() { - return uri.getUserName(); - } - - @Override - public Optional getPassword() { - return uri.getPassword(); - } - - @Override - public String getHostname() { - return uri.getHostname(); - } - - @Override - public int getPort() { - return uri.getPort(); - } - - @Override - public Optional getPath() { - return uri.getPath(); - } - - @Override - public boolean isValidateCertificates() { - return validateCertificate; - } - - @Override - public int getProcessorPoolSize() { - return processorPoolSize; - } - - @Override - public Map getSpecificConfig() { - return specificConfig; - } - - @Override - public PayloadMappingDefinition getPayloadMappingDefinition() { - return payloadMappingDefinition; - } - - @Override - public Set getTags() { - return tags; - } - - @Override - public Optional getLifecycle() { - return Optional.ofNullable(lifecycle); - } - - @Override - public JsonObject toJson(final JsonSchemaVersion schemaVersion, final Predicate thePredicate) { - final Predicate predicate = schemaVersion.and(thePredicate); - final JsonObjectBuilder jsonObjectBuilder = JsonFactory.newObjectBuilder(); - - if (null != lifecycle) { - jsonObjectBuilder.set(JsonFields.LIFECYCLE, lifecycle.name(), predicate); - } - jsonObjectBuilder.set(JsonFields.ID, String.valueOf(id), predicate); - jsonObjectBuilder.set(JsonFields.NAME, name, predicate); - jsonObjectBuilder.set(JsonFields.CONNECTION_TYPE, connectionType.getName(), predicate); - jsonObjectBuilder.set(JsonFields.CONNECTION_STATUS, connectionStatus.getName(), predicate); - jsonObjectBuilder.set(JsonFields.URI, uri.toString(), predicate); - jsonObjectBuilder.set(JsonFields.SOURCES, sources.stream() - .sorted(Comparator.comparingInt(Source::getIndex)) - .map(source -> source.toJson(schemaVersion, thePredicate)) - .collect(JsonCollectors.valuesToArray()), predicate.and(Objects::nonNull)); - jsonObjectBuilder.set(JsonFields.TARGETS, targets.stream() - .map(target -> target.toJson(schemaVersion, thePredicate)) - .collect(JsonCollectors.valuesToArray()), predicate.and(Objects::nonNull)); - jsonObjectBuilder.set(JsonFields.CLIENT_COUNT, clientCount, predicate); - jsonObjectBuilder.set(JsonFields.FAILOVER_ENABLED, failOverEnabled, predicate); - jsonObjectBuilder.set(JsonFields.VALIDATE_CERTIFICATES, validateCertificate, predicate); - jsonObjectBuilder.set(JsonFields.PROCESSOR_POOL_SIZE, processorPoolSize, predicate); - if (!specificConfig.isEmpty()) { - jsonObjectBuilder.set(JsonFields.SPECIFIC_CONFIG, specificConfig.entrySet() - .stream() - .map(entry -> JsonField.newInstance(entry.getKey(), JsonValue.of(entry.getValue()))) - .collect(JsonCollectors.fieldsToObject()), predicate); - } - if (!payloadMappingDefinition.isEmpty()) { - jsonObjectBuilder.set(JsonFields.MAPPING_DEFINITIONS, - payloadMappingDefinition.toJson(schemaVersion, thePredicate)); - } - if (credentials != null) { - jsonObjectBuilder.set(JsonFields.CREDENTIALS, credentials.toJson()); - } - if (trustedCertificates != null) { - jsonObjectBuilder.set(JsonFields.TRUSTED_CERTIFICATES, trustedCertificates, predicate); - } - if (sshTunnel != null) { - jsonObjectBuilder.set(JsonFields.SSH_TUNNEL, sshTunnel.toJson(predicate), predicate); - } - jsonObjectBuilder.set(JsonFields.TAGS, tags.stream() - .map(JsonFactory::newValue) - .collect(JsonCollectors.valuesToArray()), predicate); - return jsonObjectBuilder.build(); - } - - @SuppressWarnings("OverlyComplexMethod") - @Override - public boolean equals(@Nullable final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - final ImmutableConnection that = (ImmutableConnection) o; - return failOverEnabled == that.failOverEnabled && - Objects.equals(id, that.id) && - Objects.equals(name, that.name) && - Objects.equals(connectionType, that.connectionType) && - Objects.equals(connectionStatus, that.connectionStatus) && - Objects.equals(sources, that.sources) && - Objects.equals(targets, that.targets) && - Objects.equals(clientCount, that.clientCount) && - Objects.equals(credentials, that.credentials) && - Objects.equals(trustedCertificates, that.trustedCertificates) && - Objects.equals(uri, that.uri) && - Objects.equals(processorPoolSize, that.processorPoolSize) && - Objects.equals(validateCertificate, that.validateCertificate) && - Objects.equals(specificConfig, that.specificConfig) && - Objects.equals(payloadMappingDefinition, that.payloadMappingDefinition) && - Objects.equals(lifecycle, that.lifecycle) && - Objects.equals(sshTunnel, that.sshTunnel) && - Objects.equals(tags, that.tags); - } - - @Override - public int hashCode() { - return Objects.hash(id, name, connectionType, connectionStatus, sources, targets, clientCount, failOverEnabled, - credentials, trustedCertificates, uri, validateCertificate, processorPoolSize, specificConfig, - payloadMappingDefinition, sshTunnel, tags, lifecycle); - } - - @Override - public String toString() { - return getClass().getSimpleName() + " [" + - "id=" + id + - ", name=" + name + - ", connectionType=" + connectionType + - ", connectionStatus=" + connectionStatus + - ", failoverEnabled=" + failOverEnabled + - ", credentials=" + credentials + - ", trustedCertificates=hash:" + Objects.hash(trustedCertificates) + - ", uri=" + uri.getUriStringWithMaskedPassword() + - ", sources=" + sources + - ", targets=" + targets + - ", sshTunnel=" + sshTunnel + - ", clientCount=" + clientCount + - ", validateCertificate=" + validateCertificate + - ", processorPoolSize=" + processorPoolSize + - ", specificConfig=" + specificConfig + - ", payloadMappingDefinition=" + payloadMappingDefinition + - ", tags=" + tags + - ", lifecycle=" + lifecycle + - "]"; - } - /** * Builder for {@code ImmutableConnection}. */ @NotThreadSafe - private static final class Builder implements ConnectionBuilder { - - private static final String MIGRATED_MAPPER_ID = "javascript"; - private final ConnectionType connectionType; - - // required but changeable: - @Nullable private ConnectionId id; - @Nullable private ConnectivityStatus connectionStatus; - @Nullable private String uri; - - // optional: - @Nullable private String name = null; - @Nullable private Credentials credentials; - @Nullable private MappingContext mappingContext = null; - @Nullable private String trustedCertificates; - @Nullable private ConnectionLifecycle lifecycle = null; - @Nullable private SshTunnel sshTunnel = null; - - // optional with default: - private Set tags = new LinkedHashSet<>(); - private boolean failOverEnabled = true; - private boolean validateCertificate = true; - private final List sources = new ArrayList<>(); - private final List targets = new ArrayList<>(); - private int clientCount = 1; - private int processorPoolSize = 1; - private PayloadMappingDefinition payloadMappingDefinition = - ConnectivityModelFactory.emptyPayloadMappingDefinition(); - private final Map specificConfig = new HashMap<>(); - - private Builder(final ConnectionType connectionType) { - this.connectionType = checkNotNull(connectionType, "Connection Type"); - } - - private static boolean isBlankOrNull(@Nullable final String toTest) { - return null == toTest || toTest.trim().isEmpty(); - } - - @Override - public ConnectionBuilder id(final ConnectionId id) { - this.id = checkNotNull(id, "ID"); - return this; - } - - @Override - public ConnectionBuilder name(@Nullable final String name) { - this.name = name; - return this; - } - - @Override - public ConnectionBuilder credentials(@Nullable Credentials credentials) { - this.credentials = credentials; - return this; - } - - @Override - public Builder trustedCertificates(@Nullable final String trustedCertificates) { - if (isBlankOrNull(trustedCertificates)) { - this.trustedCertificates = null; - } else { - this.trustedCertificates = trustedCertificates; - } - return this; - } - - @Override - public ConnectionBuilder uri(final String uri) { - this.uri = checkNotNull(uri, "URI"); - return this; - } - - @Override - public ConnectionBuilder connectionStatus(final ConnectivityStatus connectionStatus) { - this.connectionStatus = checkNotNull(connectionStatus, "ConnectionStatus"); - return this; - } - - @Override - public ConnectionBuilder failoverEnabled(final boolean failOverEnabled) { - this.failOverEnabled = failOverEnabled; - return this; - } - - @Override - public ConnectionBuilder validateCertificate(final boolean validateCertificate) { - this.validateCertificate = validateCertificate; - return this; - } - - @Override - public ConnectionBuilder processorPoolSize(final int processorPoolSize) { - checkArgument(processorPoolSize, ps -> ps > 0, () -> "The processor pool size must be positive!"); - this.processorPoolSize = processorPoolSize; - return this; - } - - @Override - public ConnectionBuilder sources(final List sources) { - this.sources.addAll(checkNotNull(sources, "sources")); - return this; - } - - @Override - public ConnectionBuilder targets(final List targets) { - this.targets.addAll(checkNotNull(targets, "targets")); - return this; - } - - @Override - public ConnectionBuilder setSources(final List sources) { - this.sources.clear(); - return sources(sources); - } - - @Override - public ConnectionBuilder setTargets(final List targets) { - this.targets.clear(); - return targets(targets); - } - - @Override - public ConnectionBuilder clientCount(final int clientCount) { - checkArgument(clientCount, ps -> ps > 0, () -> "The client count must be > 0!"); - this.clientCount = clientCount; - return this; - } - - @Override - public ConnectionBuilder specificConfig(final Map specificConfig) { - this.specificConfig.putAll(checkNotNull(specificConfig, "Specific Config")); - return this; - } - - @Override - public ConnectionBuilder mappingContext(@Nullable final MappingContext mappingContext) { - this.mappingContext = mappingContext; - return this; - } - - @Override - public ConnectionBuilder tags(final Collection tags) { - this.tags = new LinkedHashSet<>(checkNotNull(tags, "tags to set")); - return this; - } - - @Override - public ConnectionBuilder tag(final String tag) { - tags.add(checkNotNull(tag, "tag to set")); - return this; - } - - @Override - public ConnectionBuilder lifecycle(@Nullable final ConnectionLifecycle lifecycle) { - this.lifecycle = lifecycle; - return this; - } - - @Override - public ConnectionBuilder sshTunnel(@Nullable final SshTunnel sshTunnel) { - this.sshTunnel = sshTunnel; - return this; - } - - @Override - public ConnectionBuilder payloadMappingDefinition(final PayloadMappingDefinition payloadMappingDefinition) { - this.payloadMappingDefinition = payloadMappingDefinition; - return this; + private static final class Builder extends AbstractConnectionBuilder { + Builder(final ConnectionType connectionType) { + super(connectionType); + this.connectionType = checkNotNull(connectionType, "connectionType"); } @Override @@ -666,275 +115,6 @@ public Connection build() { return new ImmutableConnection(this); } - private boolean shouldMigrateMappingContext() { - return mappingContext != null; - } - - private void migrateLegacyConfigurationOnTheFly() { - if (shouldMigrateMappingContext()) { - this.payloadMappingDefinition = - payloadMappingDefinition.withDefinition(MIGRATED_MAPPER_ID, mappingContext); - } - setSources(sources.stream().map(this::migrateSource).collect(Collectors.toList())); - setTargets(targets.stream().map(this::migrateTarget).collect(Collectors.toList())); - } - - private Source migrateSource(final Source source) { - final Source sourceAfterReplyTargetMigration = ImmutableSource.migrateReplyTarget(source, connectionType); - if (shouldMigrateMappingContext()) { - return new ImmutableSource.Builder(sourceAfterReplyTargetMigration) - .payloadMapping(addMigratedPayloadMappings(source.getPayloadMapping())) - .build(); - } else { - return sourceAfterReplyTargetMigration; - } - } - - private Target migrateTarget(final Target target) { - final boolean shouldAddHeaderMapping = shouldAddDefaultHeaderMappingToTarget(connectionType); - final boolean shouldMigrateMappingContext = shouldMigrateMappingContext(); - if (shouldMigrateMappingContext || shouldAddHeaderMapping) { - final TargetBuilder builder = new ImmutableTarget.Builder(target); - if (shouldMigrateMappingContext) { - builder.payloadMapping(addMigratedPayloadMappings(target.getPayloadMapping())); - } - if (shouldAddHeaderMapping) { - builder.headerMapping(target.getHeaderMapping()); - } - return builder.build(); - } else { - return target; - } - } - - private boolean shouldAddDefaultHeaderMappingToTarget(final ConnectionType connectionType) { - switch (connectionType) { - case AMQP_091: - case AMQP_10: - case KAFKA: - case MQTT_5: - return true; - case MQTT: - case HTTP_PUSH: - default: - return false; - } - } - - - private PayloadMapping addMigratedPayloadMappings(final PayloadMapping payloadMapping) { - final ArrayList merged = new ArrayList<>(payloadMapping.getMappings()); - merged.add(MIGRATED_MAPPER_ID); - return ConnectivityModelFactory.newPayloadMapping(merged); - } - - private void checkSourceAndTargetAreValid() { - if (sources.isEmpty() && targets.isEmpty()) { - throw ConnectionConfigurationInvalidException.newBuilder("Either a source or a target must be " + - "specified in the configuration of a connection!").build(); - } - } - - /** - * If no context is set on connection level each target and source must have its own context. - */ - private void checkAuthorizationContextsAreValid() { - // if the auth context on connection level is empty, - // an auth context is required to be set on each source/target - final Set sourcesWithoutAuthContext = sources.stream() - .filter(source -> source.getAuthorizationContext().isEmpty()) - .flatMap(source -> source.getAddresses().stream()) - .collect(Collectors.toCollection(LinkedHashSet::new)); - final Set targetsWithoutAuthContext = targets.stream() - .filter(target -> target.getAuthorizationContext().isEmpty()) - .map(Target::getAddress) - .collect(Collectors.toCollection(LinkedHashSet::new)); - - if (!sourcesWithoutAuthContext.isEmpty() || !targetsWithoutAuthContext.isEmpty()) { - final StringBuilder message = new StringBuilder("The "); - if (!sourcesWithoutAuthContext.isEmpty()) { - message.append("Sources ").append(sourcesWithoutAuthContext); - } - if (!sourcesWithoutAuthContext.isEmpty() && !targetsWithoutAuthContext.isEmpty()) { - message.append(" and "); - } - if (!targetsWithoutAuthContext.isEmpty()) { - message.append("Targets ").append(targetsWithoutAuthContext); - } - message.append(" are missing an authorization context."); - throw ConnectionConfigurationInvalidException.newBuilder(message.toString()).build(); - } - } - - private void checkConnectionAnnouncementsOnlySetIfClientCount1() { - if (clientCount > 1 && containsTargetWithConnectionAnnouncementsTopic()) { - final String message = MessageFormat.format("Connection announcements (topic {0}) can" + - " only be used with client count 1.", Topic.CONNECTION_ANNOUNCEMENTS.getName()); - throw ConnectionConfigurationInvalidException.newBuilder(message) - .build(); - } - } - - private boolean containsTargetWithConnectionAnnouncementsTopic() { - return targets.stream() - .map(Target::getTopics) - .flatMap(Set::stream) - .map(FilteredTopic::getTopic) - .anyMatch(Topic.CONNECTION_ANNOUNCEMENTS::equals); - } - - } - - @Immutable - static final class ConnectionUri { - - private static final String MASKED_URI_PATTERN = "{0}://{1}{2}:{3,number,#}{4}"; - - @SuppressWarnings("squid:S2068") // S2068 tripped due to 'PASSWORD' in variable name - private static final String USERNAME_PASSWORD_SEPARATOR = ":"; - - private final String uriString; - private final String protocol; - private final String hostname; - private final int port; - private final String path; - @Nullable private final String userName; - @Nullable private final String password; - private final String uriStringWithMaskedPassword; - - private ConnectionUri(final String theUriString) { - final URI uri; - try { - uri = new URI(theUriString).parseServerAuthority(); - } catch (final URISyntaxException e) { - throw ConnectionUriInvalidException.newBuilder(theUriString).build(); - } - // validate self - if (!isValid(uri)) { - throw ConnectionUriInvalidException.newBuilder(theUriString).build(); - } - - uriString = uri.toASCIIString(); - protocol = uri.getScheme(); - hostname = uri.getHost(); - port = uri.getPort(); - path = uri.getPath(); - - // initialize nullable fields - final String userInfo = uri.getUserInfo(); - if (userInfo != null && userInfo.contains(USERNAME_PASSWORD_SEPARATOR)) { - final int separatorIndex = userInfo.indexOf(USERNAME_PASSWORD_SEPARATOR); - userName = userInfo.substring(0, separatorIndex); - password = userInfo.substring(separatorIndex + 1); - } else { - userName = null; - password = null; - } - - // must be initialized after all else - uriStringWithMaskedPassword = createUriStringWithMaskedPassword(); - } - - private String createUriStringWithMaskedPassword() { - return MessageFormat.format(MASKED_URI_PATTERN, protocol, getUserCredentialsOrEmptyString(), hostname, port, - getPathOrEmptyString()); - } - - private String getUserCredentialsOrEmptyString() { - if (null != userName && null != password) { - return userName + ":*****@"; - } - return ""; - } - - private String getPathOrEmptyString() { - return getPath().orElse(""); - } - - /** - * Test validity of a connection URI. A connection URI is valid if it has an explicit port number ,has no query - * parameters, and has a nonempty password whenever it has a nonempty username. - * - * @param uri the URI object with which the connection URI is created. - * @return whether the connection URI is valid. - */ - private static boolean isValid(final URI uri) { - return uri.getPort() > 0 && uri.getQuery() == null; - } - - /** - * Returns a new instance of {@code ConnectionUri}. The is the reverse function of {@link #toString()}. - * - * @param uriString the string representation of the Connection URI. - * @return the instance. - * @throws NullPointerException if {@code uriString} is {@code null}. - * @throws ConnectionUriInvalidException if {@code uriString} is not a - * valid URI. - * @see #toString() - */ - static ConnectionUri of(final String uriString) { - return new ConnectionUri(uriString); - } - - String getProtocol() { - return protocol; - } - - Optional getUserName() { - return Optional.ofNullable(userName); - } - - Optional getPassword() { - return Optional.ofNullable(password); - } - - String getHostname() { - return hostname; - } - - int getPort() { - return port; - } - - /** - * Returns the path or an empty string. - * - * @return the path or an empty string. - */ - Optional getPath() { - return path.isEmpty() ? Optional.empty() : Optional.of(path); - } - - String getUriStringWithMaskedPassword() { - return uriStringWithMaskedPassword; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - final ConnectionUri that = (ConnectionUri) o; - return Objects.equals(uriString, that.uriString); - } - - @Override - public int hashCode() { - return Objects.hash(uriString); - } - - /** - * @return the string representation of this ConnectionUri. This is the reverse function of {@link #of(String)}. - * @see #of(String) - */ - @Override - public String toString() { - return uriString; - } - } } diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableHeaderMapping.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableHeaderMapping.java index e6c6bc6f0b..c7ac3812e5 100644 --- a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableHeaderMapping.java +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableHeaderMapping.java @@ -22,10 +22,10 @@ import javax.annotation.concurrent.Immutable; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; import org.eclipse.ditto.json.JsonFactory; import org.eclipse.ditto.json.JsonField; import org.eclipse.ditto.json.JsonObject; -import org.eclipse.ditto.base.model.json.JsonSchemaVersion; /** * Immutable implementation of a {@link HeaderMapping}. diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableSource.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableSource.java index 91d8b10bc4..1ef25b6783 100644 --- a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableSource.java +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/ImmutableSource.java @@ -420,6 +420,7 @@ public Builder(final Source source) { .qos(source.getQos().orElse(null)) .acknowledgementRequests(source.getAcknowledgementRequests().orElse(null)) .replyTarget(source.getReplyTarget().orElse(null)) + .replyTargetEnabled(source.isReplyTargetEnabled()) .declaredAcknowledgementLabels(source.getDeclaredAcknowledgementLabels()) .payloadMapping(source.getPayloadMapping()) .index(source.getIndex()) diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/UserPasswordCredentials.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/UserPasswordCredentials.java index 6d22882291..89a7da98ff 100644 --- a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/UserPasswordCredentials.java +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/UserPasswordCredentials.java @@ -102,12 +102,25 @@ static UserPasswordCredentials fromJson(final JsonObject jsonObject) { /** * Create credentials with username and password. * + * @param username the username + * @param password the password * @return credentials. */ public static UserPasswordCredentials newInstance(final String username, final String password) { return new UserPasswordCredentials(username, password); } + /** + * Create credentials from JsonObject + * + * @param jsonObject the jsonObject + * @return credentials. + * @since 3.2.0 + */ + public static UserPasswordCredentials newInstance(final JsonObject jsonObject) { + return UserPasswordCredentials.fromJson(jsonObject); + } + /** * JSON field definitions. */ diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveConnectionStatusResponse.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveConnectionStatusResponse.java index de68f2dd13..0663e6a575 100644 --- a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveConnectionStatusResponse.java +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveConnectionStatusResponse.java @@ -444,6 +444,8 @@ public Builder withAddressStatus(final ResourceStatus resourceStatus) { case SSH_TUNNEL: sshTunnelStatus = addToList(sshTunnelStatus, resourceStatus); break; + default: + // Do nothing } return this; } diff --git a/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveResolvedHonoConnection.java b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveResolvedHonoConnection.java new file mode 100644 index 0000000000..f4ba9a5367 --- /dev/null +++ b/connectivity/model/src/main/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveResolvedHonoConnection.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model.signals.commands.query; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Objects; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.JsonParsableCommand; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.SignalWithEntityId; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommand; +import org.eclipse.ditto.base.model.signals.commands.CommandJsonDeserializer; +import org.eclipse.ditto.connectivity.model.ConnectionId; +import org.eclipse.ditto.connectivity.model.WithConnectionId; +import org.eclipse.ditto.connectivity.model.signals.commands.ConnectivityCommand; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; + +/** + * Command which retrieves a {@link org.eclipse.ditto.connectivity.model.Connection} of type 'hono' + * after resolving its aliases and with its additional properties like header mappings and specific config. + * + * @since 3.2.0 + */ +@Immutable +@JsonParsableCommand(typePrefix = ConnectivityCommand.TYPE_PREFIX, name = RetrieveResolvedHonoConnection.NAME) +public final class RetrieveResolvedHonoConnection extends AbstractCommand + implements ConnectivityQueryCommand, WithConnectionId, SignalWithEntityId { + + /** + * Name of this command. + */ + public static final String NAME = "retrieveResolvedHonoConnection"; + + /** + * Type of this command. + */ + public static final String TYPE = ConnectivityCommand.TYPE_PREFIX + NAME; + + private final ConnectionId connectionId; + + private RetrieveResolvedHonoConnection(final ConnectionId connectionId, final DittoHeaders dittoHeaders) { + super(TYPE, dittoHeaders); + this.connectionId = connectionId; + } + + /** + * Returns a new instance of {@code RetrieveHonoConnection}. + * + * @param connectionId the identifier of the connection to be retrieved. + * @param dittoHeaders the headers of the request. + * @return a new RetrieveHonoConnection command. + * @throws NullPointerException if any argument is {@code null}. + */ + public static RetrieveResolvedHonoConnection of(final ConnectionId connectionId, final DittoHeaders dittoHeaders) { + checkNotNull(connectionId, "connectionId"); + return new RetrieveResolvedHonoConnection(connectionId, dittoHeaders); + } + + /** + * Creates a new {@code RetrieveHonoConnection} from a JSON string. + * + * @param jsonString the JSON string of which the command is to be retrieved. + * @param dittoHeaders the headers of the command. + * @return the command. + * @throws NullPointerException if {@code jsonString} is {@code null}. + * @throws IllegalArgumentException if {@code jsonString} is empty. + * @throws org.eclipse.ditto.json.JsonParseException if the passed in {@code jsonString} was not in the expected + * @throws org.eclipse.ditto.json.JsonMissingFieldException if connectionId is missing in the passed in {@code jsonString} + */ + public static RetrieveResolvedHonoConnection fromJson(final String jsonString, final DittoHeaders dittoHeaders) { + return fromJson(JsonFactory.newObject(jsonString), dittoHeaders); + } + + /** + * Creates a new {@code RetrieveHonoConnection} from a JSON object. + * + * @param jsonObject the JSON object of which the command is to be created. + * @param dittoHeaders the headers of the command. + * @return the command. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonParseException if the passed in {@code jsonObject} was not in the expected + * format. + */ + public static RetrieveResolvedHonoConnection fromJson(final JsonObject jsonObject, final DittoHeaders dittoHeaders) { + return new CommandJsonDeserializer(TYPE, jsonObject).deserialize(() -> { + final String readConnectionId = jsonObject.getValueOrThrow(ConnectivityCommand.JsonFields.JSON_CONNECTION_ID); + final ConnectionId connectionId = ConnectionId.of(readConnectionId); + + return of(connectionId, dittoHeaders); + }); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, final JsonSchemaVersion schemaVersion, + final Predicate thePredicate) { + + final Predicate predicate = schemaVersion.and(thePredicate); + jsonObjectBuilder.set(ConnectivityCommand.JsonFields.JSON_CONNECTION_ID, String.valueOf(connectionId), + predicate); + } + + @Override + public ConnectionId getEntityId() { + return connectionId; + } + + @Override + public Category getCategory() { + return Category.QUERY; + } + + @Override + public RetrieveResolvedHonoConnection setDittoHeaders(final DittoHeaders dittoHeaders) { + return of(connectionId, dittoHeaders); + } + + @Override + protected boolean canEqual(@Nullable final Object other) { + return other instanceof RetrieveResolvedHonoConnection; + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + final RetrieveResolvedHonoConnection that = (RetrieveResolvedHonoConnection) o; + return Objects.equals(connectionId, that.connectionId); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), connectionId); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + super.toString() + + ", connectionId=" + connectionId + + "]"; + } + +} diff --git a/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/ConnectionUriTest.java b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/ConnectionUriTest.java new file mode 100644 index 0000000000..13605c985c --- /dev/null +++ b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/ConnectionUriTest.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; + +public final class ConnectionUriTest { + + @Test + public void parseUriAsExpected() { + final ConnectionUri underTest = + ConnectionUri.of("amqps://foo:bar@hono.eclipse.org:5671/vhost"); + + assertThat(underTest.getProtocol()).isEqualTo("amqps"); + assertThat(underTest.getUserName()).contains("foo"); + assertThat(underTest.getPassword()).contains("bar"); + assertThat(underTest.getHostname()).isEqualTo("hono.eclipse.org"); + assertThat(underTest.getPort()).isEqualTo(5671); + assertThat(underTest.getPath()).contains("/vhost"); + } + + @Test + public void parsePasswordWithPlusSign() { + final ConnectionUri underTest = + ConnectionUri.of("amqps://foo:bar+baz@hono.eclipse.org:5671/vhost"); + assertThat(underTest.getPassword()).contains("bar+baz"); + } + + @Test + public void parsePasswordWithPlusSignEncoded() { + final ConnectionUri underTest = + ConnectionUri.of("amqps://foo:bar%2Bbaz@hono.eclipse.org:5671/vhost"); + assertThat(underTest.getPassword()).contains("bar+baz"); + } + + @Test + public void parsePasswordWithPlusSignDoubleEncoded() { + final ConnectionUri underTest = + ConnectionUri.of("amqps://foo:bar%252Bbaz@hono.eclipse.org:5671/vhost"); + assertThat(underTest.getPassword()).contains("bar%2Bbaz"); + } + + @Test + public void parseUriWithoutCredentials() { + final ConnectionUri underTest = + ConnectionUri.of("amqps://hono.eclipse.org:5671"); + + assertThat(underTest.getUserName()).isEmpty(); + assertThat(underTest.getPassword()).isEmpty(); + } + + @Test + public void parseUriWithoutPath() { + final ConnectionUri underTest = ConnectionUri.of("amqps://foo:bar@hono.eclipse.org:5671"); + assertThat(underTest.getPath()).isEmpty(); + } + + @Test(expected = ConnectionUriInvalidException.class) + public void cannotParseUriWithoutPort() { + ConnectionUri.of("amqps://foo:bar@hono.eclipse.org"); + } + + @Test(expected = ConnectionUriInvalidException.class) + public void cannotParseUriWithoutHost() { + ConnectionUri.of("amqps://foo:bar@:5671"); + } + + + /** + * Permit construction of connection URIs with username and without password because RFC-3986 permits it. + */ + @Test + public void canParseUriWithUsernameWithoutPassword() { + final ConnectionUri underTest = + ConnectionUri.of("amqps://foo:@hono.eclipse.org:5671"); + + assertThat(underTest.getUserName()).contains("foo"); + assertThat(underTest.getPassword()).contains(""); + } + + @Test + public void canParseUriWithoutUsernameWithPassword() { + final ConnectionUri underTest = + ConnectionUri.of("amqps://:bar@hono.eclipse.org:5671"); + + assertThat(underTest.getUserName()).contains(""); + assertThat(underTest.getPassword()).contains("bar"); + } + + @Test(expected = ConnectionUriInvalidException.class) + public void uriRegexFailsWithoutProtocol() { + ConnectionUri.of("://foo:bar@hono.eclipse.org:5671"); + } + + @Test + public void testPasswordFromUriWithNullValue() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getPassword()).isEmpty(); + } + + @Test + public void testUserFromUriWithNullValue() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getUserName()).isEmpty(); + } + + @Test + public void testPortFromUriWithNullValue() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getPort()).isEqualTo(9999); + } + + @Test + public void testHostFromUriWithNullValue() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getHostname()).isEmpty(); + } + + @Test + public void testProtocolFromUriWithNullValue() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getPassword()).isEmpty(); + } + + @Test + public void testPathFromUriWithNullValue() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getPath()).isEmpty(); + } + + @Test + public void testUriStringWithMaskedPasswordFromUriWithNullValue() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getUriStringWithMaskedPassword()).isEmpty(); + } + + @Test + public void testPasswordFromUriWithEmptyString() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getPassword()).isEmpty(); + } + + @Test + public void testUserFromUriWithEmptyString() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getUserName()).isEmpty(); + } + + @Test + public void testPortFromUriWithEmptyString() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getPort()).isEqualTo(9999); + } + + @Test + public void testHostFromUriWithEmptyString() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getHostname()).isEmpty(); + } + + @Test + public void testProtocolFromUriWithEmptyString() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getPassword()).isEmpty(); + } + + @Test + public void testPathFromUriWithEmptyString() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getPath()).isEmpty(); + } + + @Test + public void testUriStringWithMaskedPasswordFromUriWithEmptyString() { + final ConnectionUri underTest = ConnectionUri.of(null); + assertThat(underTest.getUriStringWithMaskedPassword()).isEmpty(); + } + +} diff --git a/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/HonoAddressAliasTest.java b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/HonoAddressAliasTest.java new file mode 100644 index 0000000000..2d97de4dad --- /dev/null +++ b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/HonoAddressAliasTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Locale; +import java.util.UUID; +import java.util.stream.Stream; + +import org.assertj.core.api.JUnitSoftAssertions; +import org.junit.Rule; +import org.junit.Test; + +/** + * Unit test for {@link HonoAddressAlias}. + */ +public final class HonoAddressAliasTest { + + @Rule + public final JUnitSoftAssertions softly = new JUnitSoftAssertions(); + + @Test + public void forAliasValueWithNullAliasValueReturnsEmptyOptional() { + assertThat(HonoAddressAlias.forAliasValue(null)).isEmpty(); + } + + @Test + public void forAliasValueWithUnknownAliasValueReturnsEmptyOptional() { + assertThat(HonoAddressAlias.forAliasValue(String.valueOf(UUID.randomUUID()))).isEmpty(); + } + + @Test + public void getAliasValueReturnsExpected() { + for (final HonoAddressAlias honoAddressAlias : HonoAddressAlias.values()) { + softly.assertThat(honoAddressAlias.getAliasValue()) + .as(honoAddressAlias.name()) + .isEqualTo(honoAddressAlias.name().toLowerCase(Locale.ENGLISH)); + } + } + + @Test + public void forAliasValueReturnsExpectedForKnownAliasValue() { + for (final HonoAddressAlias honoAddressAlias : HonoAddressAlias.values()) { + softly.assertThat(HonoAddressAlias.forAliasValue(honoAddressAlias.getAliasValue())) + .as(honoAddressAlias.getAliasValue()) + .hasValue(honoAddressAlias); + } + } + + @Test + public void forInvalidAliasValueReturnsEmptyOptional() { + Stream.concat( + Stream.of(HonoAddressAlias.values()).map(HonoAddressAlias::name), + Stream.of("Telemetry", " command") + ).forEach(invalidAliasValue -> { + softly.assertThat(HonoAddressAlias.forAliasValue(invalidAliasValue)) + .as(invalidAliasValue) + .isEmpty();; + }); + } + +} \ No newline at end of file diff --git a/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/HonoConnectionTest.java b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/HonoConnectionTest.java new file mode 100644 index 0000000000..6a2f3c6b1c --- /dev/null +++ b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/HonoConnectionTest.java @@ -0,0 +1,498 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mutabilitydetector.unittesting.AllowedReason.assumingFields; +import static org.mutabilitydetector.unittesting.AllowedReason.provided; +import static org.mutabilitydetector.unittesting.MutabilityAssert.assertInstancesOf; +import static org.mutabilitydetector.unittesting.MutabilityMatchers.areImmutable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.ditto.base.model.auth.AuthorizationContext; +import org.eclipse.ditto.base.model.auth.AuthorizationSubject; +import org.eclipse.ditto.base.model.auth.DittoAuthorizationContextType; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonCollectors; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonParseException; +import org.eclipse.ditto.json.JsonValue; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit test for {@link org.eclipse.ditto.connectivity.model.HonoConnection}. + */ +public final class HonoConnectionTest { + private static final ConnectionType TYPE = ConnectionType.HONO; + private static final ConnectivityStatus STATUS = ConnectivityStatus.OPEN; + + private static final ConnectionId ID = ConnectionId.of("myHonoConnectionId"); + private static final String NAME = "myHonoConnection"; + + private static final String URI_EMPTY = ""; + private static final Credentials CREDENTIALS = ClientCertificateCredentials.newBuilder().build(); + + private static final AuthorizationContext AUTHORIZATION_CONTEXT = + AuthorizationContext.newInstance(DittoAuthorizationContextType.PRE_AUTHENTICATED_CONNECTION, + AuthorizationSubject.newInstance("myIssuer:mySubject")); + + private static final String STATUS_MAPPING = "ConnectionStatus"; + private static final String JAVA_SCRIPT_MAPPING = "JavaScript"; + private static final String MIGRATED_MAPPER_ID = "javascript"; + + private static final Source SOURCE1 = ConnectivityModelFactory.newSource(AUTHORIZATION_CONTEXT, "source"); + private static final Source SOURCE2 = ConnectivityModelFactory.newSource(AUTHORIZATION_CONTEXT, "source", 1); + private static final List SOURCES = Arrays.asList(SOURCE1, SOURCE2); + private static final List SOURCES_WITH_REPLY_TARGET_DISABLED = SOURCES.stream() + .map(s -> ConnectivityModelFactory.newSourceBuilder(s).replyTargetEnabled(false).build()) + .collect(Collectors.toList()); + private static final HeaderMapping HEADER_MAPPING = ConnectivityModelFactory.emptyHeaderMapping(); + private static final Target TARGET1 = ConnectivityModelFactory.newTargetBuilder() + .address("target") + .authorizationContext(AUTHORIZATION_CONTEXT) + .headerMapping(HEADER_MAPPING) + .topics(Topic.TWIN_EVENTS, Topic.LIVE_EVENTS) + .build(); + private static final Target TARGET2 = ConnectivityModelFactory.newTargetBuilder() + .address("target") + .authorizationContext(AUTHORIZATION_CONTEXT) + .headerMapping(HEADER_MAPPING) + .topics(Topic.LIVE_MESSAGES, Topic.LIVE_MESSAGES, Topic.LIVE_EVENTS) + .build(); + private static final Target TARGET3 = ConnectivityModelFactory.newTargetBuilder() + .address("target") + .authorizationContext(AUTHORIZATION_CONTEXT) + .headerMapping(HEADER_MAPPING) + .topics(Topic.LIVE_MESSAGES, Topic.LIVE_MESSAGES, Topic.LIVE_COMMANDS) + .build(); + private static final List TARGETS = Arrays.asList(TARGET1, TARGET2, TARGET3); + + private static final JsonArray KNOWN_SOURCES_JSON = + SOURCES.stream().map(Source::toJson).collect(JsonCollectors.valuesToArray()); + private static final JsonArray KNOWN_TARGETS_JSON = + TARGETS.stream().map(Target::toJson).collect(JsonCollectors.valuesToArray()); + + private static final JsonArray KNOWN_SOURCES_WITH_MAPPING_JSON = + KNOWN_SOURCES_JSON.stream() + .map(JsonValue::asObject) + .map(o -> o.set(Source.JsonFields.PAYLOAD_MAPPING, JsonArray.of(JsonValue.of(JAVA_SCRIPT_MAPPING)))) + .collect(JsonCollectors.valuesToArray()); + private static final JsonArray KNOWN_TARGETS_WITH_MAPPING_JSON = + KNOWN_TARGETS_JSON.stream() + .map(JsonValue::asObject) + .map(o -> o.set(Source.JsonFields.PAYLOAD_MAPPING, JsonArray.of(JsonValue.of(STATUS_MAPPING)))) + .collect(JsonCollectors.valuesToArray()); + private static final JsonArray KNOWN_SOURCES_WITH_REPLY_TARGET = + KNOWN_SOURCES_WITH_MAPPING_JSON.stream() + .map(o -> o.asObject().toBuilder() + .set(Source.JsonFields.HEADER_MAPPING.getPointer(), + ImmutableSource.DEFAULT_SOURCE_HEADER_MAPPING.toJson()) + .set(Source.JsonFields.REPLY_TARGET.getPointer(), + ReplyTarget.newBuilder().address(ImmutableSource.DEFAULT_REPLY_TARGET_ADDRESS) + .headerMapping(ImmutableSource.DEFAULT_REPLY_TARGET_HEADER_MAPPING) + .build() + .toJson()) + .set(Source.JsonFields.REPLY_TARGET_ENABLED, true) + .build()) + .collect(JsonCollectors.valuesToArray()); + private static final JsonArray KNOWN_TARGETS_WITH_HEADER_MAPPING = + KNOWN_TARGETS_WITH_MAPPING_JSON.stream() + .map(o -> o.asObject().toBuilder() + .set(Target.JsonFields.HEADER_MAPPING, o.asObject() + .getValue(Target.JsonFields.HEADER_MAPPING) + .orElseGet(ConnectivityModelFactory.emptyHeaderMapping()::toJson)) + .build()) + .collect(JsonCollectors.valuesToArray()); + + private static final MappingContext KNOWN_MAPPING_CONTEXT = ConnectivityModelFactory.newMappingContext( + JAVA_SCRIPT_MAPPING, + Collections.singletonMap("incomingScript", + "function mapToDittoProtocolMsg(\n" + + " headers,\n" + + " textPayload,\n" + + " bytePayload,\n" + + " contentType\n" + + ") {\n" + + "\n" + + " // ###\n" + + " // Insert your mapping logic here\n" + + " let namespace = \"org.eclipse.ditto\";\n" + + " let name = \"foo-bar\";\n" + + " let group = \"things\";\n" + + " let channel = \"twin\";\n" + + " let criterion = \"commands\";\n" + + " let action = \"modify\";\n" + + " let path = \"/attributes/foo\";\n" + + " let dittoHeaders = headers;\n" + + " let value = textPayload;\n" + + " // ###\n" + + "\n" + + " return Ditto.buildDittoProtocolMsg(\n" + + " namespace,\n" + + " name,\n" + + " group,\n" + + " channel,\n" + + " criterion,\n" + + " action,\n" + + " path,\n" + + " dittoHeaders,\n" + + " value\n" + + " );\n" + + "}")); + + private static final MappingContext KNOWN_JAVA_MAPPING_CONTEXT = ConnectivityModelFactory.newMappingContext( + STATUS_MAPPING, new HashMap<>()); + + private static final PayloadMappingDefinition KNOWN_MAPPING_DEFINITIONS = + ConnectivityModelFactory.newPayloadMappingDefinition( + Stream.of(KNOWN_MAPPING_CONTEXT, KNOWN_JAVA_MAPPING_CONTEXT) + .collect(Collectors.toMap(MappingContext::getMappingEngine, ctx -> ctx))); + + private static final PayloadMappingDefinition LEGACY_MAPPINGS = + ConnectivityModelFactory.newPayloadMappingDefinition( + Stream.of(KNOWN_MAPPING_CONTEXT).collect(Collectors.toMap(ctx -> MIGRATED_MAPPER_ID, ctx -> ctx))); + + private static final Set KNOWN_TAGS = Collections.singleton("HONO"); + + private static final JsonObject KNOWN_JSON_WITHOUT_URI= JsonObject.newBuilder() + .set(Connection.JsonFields.ID, ID.toString()) + .set(Connection.JsonFields.NAME, NAME) + .set(Connection.JsonFields.CONNECTION_TYPE, TYPE.getName()) + .set(Connection.JsonFields.CONNECTION_STATUS, STATUS.getName()) + .set(Connection.JsonFields.CREDENTIALS, CREDENTIALS.toJson()) + .set(Connection.JsonFields.SOURCES, KNOWN_SOURCES_WITH_MAPPING_JSON) + .set(Connection.JsonFields.TARGETS, KNOWN_TARGETS_WITH_MAPPING_JSON) + .set(Connection.JsonFields.CLIENT_COUNT, 2) + .set(Connection.JsonFields.FAILOVER_ENABLED, true) + .set(Connection.JsonFields.VALIDATE_CERTIFICATES, true) + .set(Connection.JsonFields.PROCESSOR_POOL_SIZE, 1) + .set(Connection.JsonFields.MAPPING_DEFINITIONS, + JsonObject.newBuilder() + .set(JAVA_SCRIPT_MAPPING, KNOWN_MAPPING_CONTEXT.toJson()) + .set(STATUS_MAPPING, KNOWN_JAVA_MAPPING_CONTEXT.toJson()) + .build()) + .set(Connection.JsonFields.TAGS, KNOWN_TAGS.stream() + .map(JsonFactory::newValue) + .collect(JsonCollectors.valuesToArray())) + .build(); + + private final static JsonObject KNOWN_JSON_WITH_EMPTY_URI = KNOWN_JSON_WITHOUT_URI.set(Connection.JsonFields.URI, ""); + + private final static JsonObject KNOWN_JSON_WITH_NULL_URI = KNOWN_JSON_WITHOUT_URI.set(Connection.JsonFields.URI, null); + + private static final JsonObject KNOWN_JSON_WITH_REPLY_TARGET = KNOWN_JSON_WITH_EMPTY_URI + .set(Connection.JsonFields.SOURCES, KNOWN_SOURCES_WITH_REPLY_TARGET) + .set(Connection.JsonFields.TARGETS, KNOWN_TARGETS_WITH_HEADER_MAPPING); + + private static final JsonObject KNOWN_LEGACY_JSON = KNOWN_JSON_WITH_EMPTY_URI + .set(Connection.JsonFields.MAPPING_CONTEXT, KNOWN_MAPPING_CONTEXT.toJson()); + + @Test + public void testHashCodeAndEquals() { + EqualsVerifier.forClass(HonoConnection.class) + .usingGetClass() + .verify(); + } + + @Test + public void assertImmutability() { + assertInstancesOf(ImmutableConnection.class, areImmutable(), + provided(AuthorizationContext.class, Source.class, Target.class, + MappingContext.class, Credentials.class, ConnectionId.class, + PayloadMappingDefinition.class, SshTunnel.class).isAlsoImmutable(), + assumingFields("mappings").areSafelyCopiedUnmodifiableCollectionsWithImmutableElements()); + } + + @Test + public void createMinimalConnectionConfigurationInstance() { + Connection connection = HonoConnection.fromJson(KNOWN_JSON_WITHOUT_URI); + connection = connection.toBuilder().setSources(SOURCES_WITH_REPLY_TARGET_DISABLED).build(); + assertThat((CharSequence) connection.getId()).isEqualTo(ID); + assertThat((Object) connection.getConnectionType()).isEqualTo(TYPE); + assertThat(connection.getUri()).isEqualTo(URI_EMPTY); + assertThat(connection.getSources()).isEqualTo(SOURCES_WITH_REPLY_TARGET_DISABLED); + } + + @Test + public void createMinimalConnectionConfigurationInstanceWithEmptyUri() { + Connection connection = HonoConnection.fromJson(KNOWN_JSON_WITH_EMPTY_URI); + connection = connection.toBuilder().setSources(SOURCES_WITH_REPLY_TARGET_DISABLED).build(); + assertThat((CharSequence) connection.getId()).isEqualTo(ID); + assertThat((Object) connection.getConnectionType()).isEqualTo(TYPE); + assertThat(connection.getUri()).isEqualTo(URI_EMPTY); + assertThat(connection.getSources()).isEqualTo(SOURCES_WITH_REPLY_TARGET_DISABLED); + } + + @Test + public void createInstanceWithNullId() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> ConnectivityModelFactory.newConnectionBuilder(null, TYPE, STATUS, URI_EMPTY)) + .withMessage("The %s must not be null!", "id") + .withNoCause(); + } + + @Test + public void createInstanceWithNullUri() { + Connection connection = HonoConnection.fromJson(KNOWN_JSON_WITH_NULL_URI); + connection = connection.toBuilder().setSources(SOURCES_WITH_REPLY_TARGET_DISABLED).build(); + assertThat(connection.getUri()).isEmpty(); + } + + @Test + public void createInstanceWithEmptyUri() { + Connection connection = HonoConnection.fromJson(KNOWN_JSON_WITH_EMPTY_URI); + assertThat(connection.getUri()).isEmpty(); + } + + @Test + public void getBuilderFromConnectionCoversAllFields() { + + final Connection connection = HonoConnection.getBuilder(ID, TYPE, STATUS, URI_EMPTY) + .sources(SOURCES) + .targets(TARGETS) + .connectionStatus(ConnectivityStatus.OPEN) + .name("connection") + .clientCount(5) + .tag("AAA") + .trustedCertificates("certs") + .processorPoolSize(8) + .credentials(ClientCertificateCredentials.newBuilder() + .clientKey("clientkey") + .clientCertificate("certificate") + .build()) + .validateCertificate(true) + //.uri(null) + .id(ID) + .payloadMappingDefinition( + ConnectivityModelFactory.newPayloadMappingDefinition("test", KNOWN_JAVA_MAPPING_CONTEXT)) + .build(); + assertThat(HonoConnection.getBuilder(connection).build()).isEqualTo(connection); + assertThat(HonoConnection.getBuilder(connection).build().getUri()).isEqualTo(URI_EMPTY); + } + + @Test + public void createInstanceWithNullSources() { + final ConnectionBuilder builder = HonoConnection.getBuilder(ID, TYPE, STATUS, URI_EMPTY); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> builder.sources(null)) + .withMessage("The %s must not be null!", "sources") + .withNoCause(); + } + + @Test + public void createInstanceWithNullEventTarget() { + final ConnectionBuilder builder = HonoConnection.getBuilder(ID, TYPE, STATUS, URI_EMPTY); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> builder.targets(null)) + .withMessage("The %s must not be null!", "targets") + .withNoCause(); + } + + @Test + public void createHonoWhitInvalidConnectionType() { + final Connection connection = HonoConnection.getBuilder(ID, ConnectionType.AMQP_10, STATUS, URI_EMPTY) + .sources(SOURCES) + .targets(TARGETS) + .connectionStatus(ConnectivityStatus.OPEN) + .name("connection") + .clientCount(5) + .trustedCertificates("certs") + .processorPoolSize(8) + .id(ID) + .build(); + + assertThatExceptionOfType(JsonParseException.class) + .isThrownBy(() -> HonoConnection.getConnectionTypeOrThrow(connection.toJson())) + .withMessage("Connection type <%s> is invalid! Connection type must be of type <%s>.", + ConnectionType.AMQP_10.getName(), ConnectionType.HONO.getName()) + .withNoCause(); + } + + @Test + public void createInstanceWithNullSourcesAndEmptyUri() { + final ConnectionBuilder builder = HonoConnection.getBuilder(ID, TYPE, STATUS, URI_EMPTY); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> builder.sources(null)) + .withMessage("The %s must not be null!", "sources") + .withNoCause(); + } + + @Test + public void createInstanceWithNullEventTargetAndEmptyUri() { + final ConnectionBuilder builder = HonoConnection.getBuilder(ID, TYPE, STATUS, URI_EMPTY); + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> builder.targets(null)) + .withMessage("The %s must not be null!", "targets") + .withNoCause(); + } + + @Test + public void createInstanceWithConnectionAnnouncementsAndClientCountGreater1() { + final ConnectionBuilder builder = HonoConnection.getBuilder(ID, TYPE, STATUS, URI_EMPTY) + .targets(Collections.singletonList( + ConnectivityModelFactory.newTargetBuilder(TARGET1) + .topics(Topic.CONNECTION_ANNOUNCEMENTS).build()) + ) + .clientCount(2); + assertThatExceptionOfType(ConnectionConfigurationInvalidException.class) + .isThrownBy(builder::build) + .withMessageContaining(Topic.CONNECTION_ANNOUNCEMENTS.getName()) + .withNoCause(); + } + + @Test + public void fromJsonWithLegacyMappingContextReturnsExpected() { + final Map definitions = new HashMap<>(KNOWN_MAPPING_DEFINITIONS.getDefinitions()); + definitions.putAll(LEGACY_MAPPINGS.getDefinitions()); + final Connection expected = ConnectivityModelFactory.newConnectionBuilder(ID, TYPE, STATUS, URI_EMPTY) + .credentials(CREDENTIALS) + .name(NAME) + .setSources(addSourceMapping(SOURCES, JAVA_SCRIPT_MAPPING, "javascript")) + .setTargets(addTargetMapping(TARGETS, STATUS_MAPPING, "javascript")) + .clientCount(2) + .payloadMappingDefinition(ConnectivityModelFactory.newPayloadMappingDefinition(definitions)) + .tags(KNOWN_TAGS) + .build(); + + final Connection actual = HonoConnection.fromJson(KNOWN_LEGACY_JSON); + assertThat(actual).isEqualTo(expected); + } + + @Test + public void fromInvalidJsonFails() { + final JsonObject INVALID_JSON = KNOWN_JSON_WITHOUT_URI.remove(Connection.JsonFields.SOURCES.getPointer()) + .remove(Connection.JsonFields.TARGETS.getPointer()); + + assertThatExceptionOfType(ConnectionConfigurationInvalidException.class) + .isThrownBy(() -> HonoConnection.fromJson(INVALID_JSON)) + .withMessageContaining("source") + .withMessageContaining("target") + .withNoCause(); + } + + @Test + public void fromJsonReturnsExpected() { + final Connection expected = ConnectivityModelFactory.newConnectionBuilder(ID, TYPE, STATUS, URI_EMPTY) + .credentials(CREDENTIALS) + .name(NAME) + .setSources(addSourceMapping(SOURCES, JAVA_SCRIPT_MAPPING)) + .setTargets(addTargetMapping(TARGETS, STATUS_MAPPING)) + .clientCount(2) + .payloadMappingDefinition(KNOWN_MAPPING_DEFINITIONS) + .tags(KNOWN_TAGS) + .build(); + + final Connection actual = HonoConnection.fromJson(KNOWN_JSON_WITHOUT_URI); + assertThat(actual).isEqualTo(expected); + } + + @Test + public void toJsonReturnsExpected() { + final Connection underTest = ConnectivityModelFactory.newConnectionBuilder(ID, TYPE, STATUS, URI_EMPTY) + .credentials(CREDENTIALS) + .name(NAME) + .sources(addSourceMapping(Arrays.asList(SOURCE2, SOURCE1), + JAVA_SCRIPT_MAPPING)) // use different order to test sorting + .targets(addTargetMapping(TARGETS, STATUS_MAPPING)) + .clientCount(2) + .payloadMappingDefinition(KNOWN_MAPPING_DEFINITIONS) + .tags(KNOWN_TAGS) + .build(); + + final JsonObject actual = underTest.toJson(); + assertThat(actual).isEqualTo(KNOWN_JSON_WITH_REPLY_TARGET); + } + + @Test + public void emptyCertificatesLeadToEmptyOptional() { + final Connection underTest = ConnectivityModelFactory.newConnectionBuilder(ID, TYPE, STATUS, URI_EMPTY) + .targets(TARGETS) + .validateCertificate(true) + .trustedCertificates("") + .build(); + + assertThat(underTest.getTrustedCertificates()).isEmpty(); + } + + @Test + public void emptyCertificatesFromJsonLeadToEmptyOptional() { + final JsonObject connectionJsonWithEmptyCa = KNOWN_JSON_WITHOUT_URI + .set(Connection.JsonFields.VALIDATE_CERTIFICATES, true) + .set(Connection.JsonFields.TRUSTED_CERTIFICATES, ""); + final Connection underTest = ConnectivityModelFactory.connectionFromJson(connectionJsonWithEmptyCa); + + assertThat(underTest.getTrustedCertificates()).isEmpty(); + } + + private List addSourceMapping(final List sources, final String... mapping) { + return sources.stream() + .map(s -> new ImmutableSource.Builder(s).payloadMapping( + ConnectivityModelFactory.newPayloadMapping(mapping)).build()) + .collect(Collectors.toList()); + } + + private List addTargetMapping(final List targets, final String... mapping) { + return targets.stream() + .map(t -> new ImmutableTarget.Builder(t).payloadMapping( + ConnectivityModelFactory.newPayloadMapping(mapping)).build()) + .collect(Collectors.toList()); + } + + @Test + public void providesDefaultHeaderMappings() { + + final Target targetWithoutHeaderMapping = ConnectivityModelFactory.newTargetBuilder() + .address("amqp/target1") + .authorizationContext(AUTHORIZATION_CONTEXT) + .topics(Topic.TWIN_EVENTS) + .build(); + final Connection connectionWithoutHeaderMappingForTarget = + ConnectivityModelFactory.newConnectionBuilder(ID, TYPE, STATUS, URI_EMPTY) + .targets(Collections.singletonList(targetWithoutHeaderMapping)) + .build(); + + connectionWithoutHeaderMappingForTarget.getTargets() + .forEach(target -> assertThat(target.getHeaderMapping()) + .isEqualTo(ConnectivityModelFactory.emptyHeaderMapping())); + } + + @Test + public void providesDefaultHeaderMappingsFromJson() { + final JsonObject connectionJsonWithoutHeaderMappingForTarget = KNOWN_JSON_WITHOUT_URI + .set(Connection.JsonFields.TARGETS, JsonArray.of( + TARGET1.toJson() + .remove(Target.JsonFields.HEADER_MAPPING.getPointer()))); + final Connection connectionWithoutHeaderMappingForTarget = + HonoConnection.fromJson(connectionJsonWithoutHeaderMappingForTarget); + + connectionWithoutHeaderMappingForTarget.getTargets() + .forEach(target -> assertThat(target.getHeaderMapping()) + .isEqualTo(ConnectivityModelFactory.emptyHeaderMapping())); + } + +} diff --git a/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/ImmutableConnectionTest.java b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/ImmutableConnectionTest.java index 8114345e57..a983978d17 100644 --- a/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/ImmutableConnectionTest.java +++ b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/ImmutableConnectionTest.java @@ -243,7 +243,7 @@ public void createMinimalConnectionConfigurationInstance() { public void createInstanceWithNullId() { assertThatExceptionOfType(NullPointerException.class) .isThrownBy(() -> ConnectivityModelFactory.newConnectionBuilder(null, TYPE, STATUS, URI)) - .withMessage("The %s must not be null!", "ID") + .withMessage("The %s must not be null!", "id") .withNoCause(); } @@ -251,7 +251,7 @@ public void createInstanceWithNullId() { public void createInstanceWithNullUri() { assertThatExceptionOfType(NullPointerException.class) .isThrownBy(() -> ConnectivityModelFactory.newConnectionBuilder(ID, TYPE, STATUS, null)) - .withMessage("The %s must not be null!", "URI") + .withMessage("The %s must not be null!", "uri") .withNoCause(); } @@ -439,107 +439,6 @@ public void nullSshTunnelLeadToEmptyOptional() { assertThat(underTest.getSshTunnel()).isEmpty(); } - @Test - public void parseUriAsExpected() { - final ImmutableConnection.ConnectionUri underTest = - ImmutableConnection.ConnectionUri.of("amqps://foo:bar@hono.eclipse.org:5671/vhost"); - - assertThat(underTest.getProtocol()).isEqualTo("amqps"); - assertThat(underTest.getUserName()).contains("foo"); - assertThat(underTest.getPassword()).contains("bar"); - assertThat(underTest.getHostname()).isEqualTo("hono.eclipse.org"); - assertThat(underTest.getPort()).isEqualTo(5671); - assertThat(underTest.getPath()).contains("/vhost"); - } - - @Test - public void parsePasswordWithPlusSign() { - final ImmutableConnection.ConnectionUri underTest = - ImmutableConnection.ConnectionUri.of("amqps://foo:bar+baz@hono.eclipse.org:5671/vhost"); - assertThat(underTest.getPassword()).contains("bar+baz"); - } - - @Test - public void parsePasswordWithPlusSignEncoded() { - final ImmutableConnection.ConnectionUri underTest = - ImmutableConnection.ConnectionUri.of("amqps://foo:bar%2Bbaz@hono.eclipse.org:5671/vhost"); - assertThat(underTest.getPassword()).contains("bar+baz"); - } - - @Test - public void parsePasswordWithPlusSignDoubleEncoded() { - final ImmutableConnection.ConnectionUri underTest = - ImmutableConnection.ConnectionUri.of("amqps://foo:bar%252Bbaz@hono.eclipse.org:5671/vhost"); - assertThat(underTest.getPassword()).contains("bar%2Bbaz"); - } - - @Test - public void parseUriWithoutCredentials() { - final ImmutableConnection.ConnectionUri underTest = - ImmutableConnection.ConnectionUri.of("amqps://hono.eclipse.org:5671"); - - assertThat(underTest.getUserName()).isEmpty(); - assertThat(underTest.getPassword()).isEmpty(); - } - - @Test - public void parseUriWithoutPath() { - final ImmutableConnection.ConnectionUri underTest = - ImmutableConnection.ConnectionUri.of("amqps://foo:bar@hono.eclipse.org:5671"); - - assertThat(underTest.getPath()).isEmpty(); - } - - @Test(expected = ConnectionUriInvalidException.class) - public void cannotParseUriWithoutPort() { - ImmutableConnection.ConnectionUri.of("amqps://foo:bar@hono.eclipse.org"); - } - - @Test(expected = ConnectionUriInvalidException.class) - public void cannotParseUriWithoutHost() { - ImmutableConnection.ConnectionUri.of("amqps://foo:bar@:5671"); - } - - - /** - * Permit construction of connection URIs with username and without password because RFC-3986 permits it. - */ - @Test - public void canParseUriWithUsernameWithoutPassword() { - final ImmutableConnection.ConnectionUri underTest = - ImmutableConnection.ConnectionUri.of("amqps://foo:@hono.eclipse.org:5671"); - - assertThat(underTest.getUserName()).contains("foo"); - assertThat(underTest.getPassword()).contains(""); - } - - @Test - public void canParseUriWithoutUsernameWithPassword() { - final ImmutableConnection.ConnectionUri underTest = - ImmutableConnection.ConnectionUri.of("amqps://:bar@hono.eclipse.org:5671"); - - assertThat(underTest.getUserName()).contains(""); - assertThat(underTest.getPassword()).contains("bar"); - } - - @Test(expected = ConnectionUriInvalidException.class) - public void uriRegexFailsWithoutProtocol() { - ImmutableConnection.ConnectionUri.of("://foo:bar@hono.eclipse.org:5671"); - } - - @Test - public void toStringDoesNotContainPassword() { - final String password = "thePassword"; - - final String uri = "amqps://foo:" + password + "@host.com:5671"; - - final Connection connection = ConnectivityModelFactory.newConnectionBuilder(ID, TYPE, STATUS, uri) - .sources(Collections.singletonList(SOURCE1)) - .build(); - - assertThat(connection.toString()).doesNotContain(password); - } - @Test public void providesDefaultHeaderMappings() { diff --git a/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/signals/commands/TestConstants.java b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/signals/commands/TestConstants.java index 2ec869ab2b..bfa1f5c052 100644 --- a/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/signals/commands/TestConstants.java +++ b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/signals/commands/TestConstants.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 Contributors to the Eclipse Foundation + * Copyright (c) 2022 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -65,6 +65,7 @@ public final class TestConstants { public static final String TIMESTAMP = "2019-05-21T11:06:54.210Z"; public static final ConnectionType TYPE = ConnectionType.AMQP_10; + public static final ConnectionType HONO_TYPE = ConnectionType.HONO; public static final ConnectivityStatus STATUS = ConnectivityStatus.OPEN; private static final String URI = "amqps://username:password@my.endpoint:443"; @@ -135,6 +136,13 @@ public final class TestConstants { .mappingContext(MAPPING_CONTEXT) .build(); + public static final Connection HONO_CONNECTION = + ConnectivityModelFactory.newConnectionBuilder(ID, HONO_TYPE, STATUS, URI) + .sources(SOURCES) + .targets(TARGETS) + .mappingContext(MAPPING_CONTEXT) + .build(); + public static final class Metrics { private static final Instant LAST_MESSAGE_AT = Instant.now(); diff --git a/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveResolvedHonoConnectionTest.java b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveResolvedHonoConnectionTest.java new file mode 100644 index 0000000000..94bd12fb22 --- /dev/null +++ b/connectivity/model/src/test/java/org/eclipse/ditto/connectivity/model/signals/commands/query/RetrieveResolvedHonoConnectionTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.model.signals.commands.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mutabilitydetector.unittesting.AllowedReason.provided; +import static org.mutabilitydetector.unittesting.MutabilityAssert.assertInstancesOf; +import static org.mutabilitydetector.unittesting.MutabilityMatchers.areImmutable; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.connectivity.model.ConnectionId; +import org.eclipse.ditto.connectivity.model.signals.commands.ConnectivityCommand; +import org.eclipse.ditto.connectivity.model.signals.commands.TestConstants; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.assertions.DittoJsonAssertions; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit test for {@link RetrieveResolvedHonoConnection}. + */ +public final class RetrieveResolvedHonoConnectionTest { + + private static final JsonObject KNOWN_JSON = JsonObject.newBuilder() + .set(Command.JsonFields.TYPE, RetrieveResolvedHonoConnection.TYPE) + .set(ConnectivityCommand.JsonFields.JSON_CONNECTION_ID, TestConstants.ID.toString()) + .build(); + + @Test + public void testHashCodeAndEquals() { + EqualsVerifier.forClass(RetrieveResolvedHonoConnection.class) + .usingGetClass() + .verify(); + } + + @Test + public void assertImmutability() { + assertInstancesOf(RetrieveResolvedHonoConnection.class, + areImmutable(), + provided(ConnectionId.class).isAlsoImmutable()); + } + + @Test + public void createInstanceWithNullConnectionId() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> RetrieveResolvedHonoConnection.of(null, DittoHeaders.empty())) + .withMessage("The %s must not be null!", "connectionId") + .withNoCause(); + } + + @Test + public void fromJsonReturnsExpected() { + final RetrieveResolvedHonoConnection expected = + RetrieveResolvedHonoConnection.of(TestConstants.ID, DittoHeaders.empty()); + + final RetrieveResolvedHonoConnection actual = + RetrieveResolvedHonoConnection.fromJson(KNOWN_JSON, DittoHeaders.empty()); + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void toJsonReturnsExpected() { + final JsonObject actual = + RetrieveResolvedHonoConnection.of(TestConstants.ID, DittoHeaders.empty()).toJson(); + + assertThat(actual).isEqualTo(KNOWN_JSON); + } + + @Test + public void getEntityIdReturnsExpected() { + final RetrieveResolvedHonoConnection actual = + RetrieveResolvedHonoConnection.of(TestConstants.ID, DittoHeaders.empty()); + + assertThat((CharSequence) actual.getEntityId()).isEqualTo(ConnectionId.of(TestConstants.ID)); + } + + @Test + public void setDittoHeadersReturnsExpected() { + Map map = new HashMap<>(); + map.put("header1_key", "header1_value"); + map.put("header2_key", "header2_value"); + map.put("header3_key", "header3_value"); + final DittoHeaders EXPECTED_DITTO_HEADERS = DittoHeaders.of(map); + final RetrieveResolvedHonoConnection actual = + RetrieveResolvedHonoConnection.of(TestConstants.ID, DittoHeaders.empty()); + + assertThat(actual.getDittoHeaders()).isEmpty(); + RetrieveResolvedHonoConnection changed = actual.setDittoHeaders(EXPECTED_DITTO_HEADERS); + assertThat(changed.getDittoHeaders()).isEqualTo(EXPECTED_DITTO_HEADERS); + } + + @Test + public void getResourcePathReturnsExpected() { + final JsonPointer expectedResourcePath = JsonFactory.emptyPointer(); + + final RetrieveResolvedHonoConnection underTest = + RetrieveResolvedHonoConnection.of(TestConstants.ID, DittoHeaders.empty()); + + DittoJsonAssertions.assertThat(underTest.getResourcePath()).isEqualTo(expectedResourcePath); + } + +} diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectionConfigProvider.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectionConfigProvider.java index 0b85deacd6..c78873b711 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectionConfigProvider.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectionConfigProvider.java @@ -13,7 +13,6 @@ package org.eclipse.ditto.connectivity.service.config; -import java.util.Optional; import java.util.concurrent.CompletionStage; import javax.annotation.Nullable; @@ -50,15 +49,15 @@ CompletionStage getConnectivityConfigOverwrites(ConnectionId connectionI * * @param connectionId the connection id * @param dittoHeaders the DittoHeaders of the original command which woke up the connection supervisor actor. - * @param subscriber the subscriber that will receive {@link org.eclipse.ditto.base.model.signals.events.Event}s + * @param subscriber the supervisor actor of the connection interested in these {@link org.eclipse.ditto.base.model.signals.events.Event}s * @return a future that succeeds or fails depending on whether registration was successful. */ CompletionStage registerForConnectivityConfigChanges(ConnectionId connectionId, @Nullable DittoHeaders dittoHeaders, ActorRef subscriber); /** - * Returns {@code true} if the implementation can handle the given {@code event} to generate a modified {@link - * ConnectivityConfig} when passed to {@link #handleEvent(Event)}. + * Returns {@code true} if the implementation can handle the given {@code event} to generate a modified {@link ConnectivityConfig} + * when passed to {@link #handleEvent(org.eclipse.ditto.base.model.signals.events.Event, akka.actor.ActorRef, akka.actor.ActorRef)}. * * @param event the event that may be used to generate modified config * @return {@code true} if the event is compatible @@ -68,9 +67,10 @@ CompletionStage registerForConnectivityConfigChanges(ConnectionId connecti /** * Uses the given {@code event} to create a config which should overwrite the default connectivity config. * - * @param event the event used to create a config which should overwrite the default connectivity config. - * @return Potentially empty config which holds the overwrites for the default connectivity config. + * @param event the event used to invoke restart of the connection due to some changes in its configuration + * @param supervisorActor the supervisor actor of the connection interested in these {@link Event}s + * @param persistenceActor the persistence actor of the connection */ - Optional handleEvent(Event event); + void handleEvent(Event event, ActorRef supervisorActor, @Nullable ActorRef persistenceActor); } diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectivityConfigModifiedBehavior.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectivityConfigModifiedBehavior.java index b2c8276ac9..966281e9e2 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectivityConfigModifiedBehavior.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectivityConfigModifiedBehavior.java @@ -12,12 +12,19 @@ */ package org.eclipse.ditto.connectivity.service.config; +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.function.Supplier; + +import javax.annotation.Nullable; + import org.eclipse.ditto.base.model.signals.events.Event; import com.typesafe.config.Config; import akka.actor.AbstractActor; import akka.actor.Actor; +import akka.actor.ActorRef; import akka.japi.pf.ReceiveBuilder; /** @@ -27,23 +34,32 @@ public interface ConnectivityConfigModifiedBehavior extends Actor { /** * Injectable behavior to handle an {@code Event} that transports config changes. + * This involves modified credentials for Hono-connections as well. * + * @param supervisorActor the actor that potentially will receive a command message after handling the event. + * @param persistenceActorSupplier a supplier of the actor that potentially will receive a command message after + * handling the event. * @return behavior to handle an {@code Event} that transports config changes. */ - default AbstractActor.Receive connectivityConfigModifiedBehavior() { + default AbstractActor.Receive connectivityConfigModifiedBehavior(final ActorRef supervisorActor, + final Supplier persistenceActorSupplier) { + checkNotNull(persistenceActorSupplier); return ReceiveBuilder.create() - .match(Event.class, event -> getConnectivityConfigProvider().canHandle(event), this::handleEvent) + .match(Event.class, getConnectivityConfigProvider()::canHandle, + event -> handleEvent(event, supervisorActor, persistenceActorSupplier.get())) .build(); } /** - * Handles the received event by converting it to a {@link Config} and passing it to - * {@link #onConnectivityConfigModified(Config)}. + * Handles the received event by converting it to a {@link Config}. * + * @param supervisorActor the connection supervisor actor reference + * @param persistenceActor the connection persistence actor reference * @param event the received event */ - default void handleEvent(final Event event) { - getConnectivityConfigProvider().handleEvent(event).ifPresent(this::onConnectivityConfigModified); + default void handleEvent(final Event event, final ActorRef supervisorActor, + @Nullable final ActorRef persistenceActor) { + getConnectivityConfigProvider().handleEvent(event, supervisorActor, persistenceActor); } /** @@ -53,11 +69,4 @@ default ConnectionConfigProvider getConnectivityConfigProvider() { return ConnectionConfigProviderFactory.getInstance(context().system()); } - /** - * This method is called when a config modification is received. Implementations must handle the modified config - * appropriately i.e. check if any relevant config has changed and re-initialize state if necessary. - * - * @param connectivityConfigOverwrites the modified config - */ - void onConnectivityConfigModified(Config connectivityConfigOverwrites); } diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultHonoConfig.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultHonoConfig.java new file mode 100644 index 0000000000..fe42bc5402 --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultHonoConfig.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.config; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.net.URI; +import java.net.URISyntaxException; +import java.text.MessageFormat; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiFunction; + +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.connectivity.model.UserPasswordCredentials; +import org.eclipse.ditto.internal.utils.config.ConfigWithFallback; +import org.eclipse.ditto.internal.utils.config.DittoConfigError; +import org.eclipse.ditto.internal.utils.config.ScopedConfig; + +import com.typesafe.config.Config; + +import akka.actor.ActorSystem; + +/** + * Default implementation for {@link HonoConfig}. + */ +@Immutable +public final class DefaultHonoConfig implements HonoConfig { + + private final URI baseUri; + private final boolean validateCertificates; + private final SaslMechanism saslMechanism; + private final Set bootstrapServerUris; + private final UserPasswordCredentials credentials; + + + /** + * Constructs a {@code DefaultHonoConfig} for the specified ActorSystem. + * + * @param actorSystem the actor system that provides the overall core config. + * @throws NullPointerException if {@code actorSystem} is {@code null}. + */ + public DefaultHonoConfig(final ActorSystem actorSystem) { + this(ConfigWithFallback.newInstance(checkNotNull(actorSystem, "actorSystem").settings().config(), + PREFIX, + HonoConfigValue.values())); + } + + private DefaultHonoConfig(final ScopedConfig scopedConfig) { + baseUri = getBaseUriOrThrow(scopedConfig); + validateCertificates = scopedConfig.getBoolean(HonoConfigValue.VALIDATE_CERTIFICATES.getConfigPath()); + saslMechanism = scopedConfig.getEnum(SaslMechanism.class, HonoConfigValue.SASL_MECHANISM.getConfigPath()); + bootstrapServerUris = Collections.unmodifiableSet(getBootstrapServerUrisOrThrow(scopedConfig)); + credentials = UserPasswordCredentials.newInstance( + scopedConfig.getString(HonoConfigValue.USERNAME.getConfigPath()), + scopedConfig.getString(HonoConfigValue.PASSWORD.getConfigPath()) + ); + } + + private static URI getBaseUriOrThrow(final Config scopedConfig) { + final var configPath = HonoConfigValue.BASE_URI.getConfigPath(); + try { + return new URI(scopedConfig.getString(configPath)); + } catch (final URISyntaxException e) { + throw new DittoConfigError( + MessageFormat.format("The string value at <{0}> is not a {1}: {2}", + configPath, + URI.class.getSimpleName(), + e.getMessage()), + e + ); + } + } + + private static Set getBootstrapServerUrisOrThrow(final Config scopedConfig) { + final var configPath = HonoConfigValue.BOOTSTRAP_SERVERS.getConfigPath(); + final BiFunction getUriOrThrow = (index, uriString) -> { + try { + return new URI(uriString.trim()); + } catch (final URISyntaxException e) { + throw new DittoConfigError( + MessageFormat.format("The string at index <{0}> for key <{1}> is not a valid URI: {2}", + index, + configPath, + e.getMessage()), + e + ); + } + }; + + final var bootstrapServersString = scopedConfig.getString(configPath); + final var bootstrapServerUriStrings = bootstrapServersString.split(","); + final Set result = new LinkedHashSet<>(bootstrapServerUriStrings.length); + for (var i = 0; i < bootstrapServerUriStrings.length; i++) { + result.add(getUriOrThrow.apply(i, bootstrapServerUriStrings[i])); + } + return result; + } + + @Override + public URI getBaseUri() { + return baseUri; + } + + @Override + public boolean isValidateCertificates() { + return validateCertificates; + } + + @Override + public SaslMechanism getSaslMechanism() { + return saslMechanism; + } + + @Override + public Set getBootstrapServerUris() { + return bootstrapServerUris; + } + + @Override + public UserPasswordCredentials getUserPasswordCredentials() { + return credentials; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final var that = (DefaultHonoConfig) o; + return Objects.equals(baseUri, that.baseUri) + && Objects.equals(validateCertificates, that.validateCertificates) + && Objects.equals(saslMechanism, that.saslMechanism) + && Objects.equals(bootstrapServerUris, that.bootstrapServerUris) + && Objects.equals(credentials, that.credentials); + + } + + @Override + public int hashCode() { + return Objects.hash(baseUri, validateCertificates, saslMechanism, bootstrapServerUris, credentials); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "baseUri=" + baseUri + + ", validateCertificates=" + validateCertificates + + ", saslMechanism=" + saslMechanism + + ", bootstrapServers=" + bootstrapServerUris + + "]"; + } + +} \ No newline at end of file diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DittoConnectionConfigProvider.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DittoConnectionConfigProvider.java index 6afd796a6e..e2d9f3d1ab 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DittoConnectionConfigProvider.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DittoConnectionConfigProvider.java @@ -12,7 +12,6 @@ */ package org.eclipse.ditto.connectivity.service.config; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -58,8 +57,9 @@ public boolean canHandle(final Event event) { } @Override - public Optional handleEvent(final Event event) { - return Optional.empty(); + public void handleEvent(final Event event, final ActorRef supervisorActor, + @Nullable final ActorRef persistenceActor) { + // By default not handled in Ditto } } diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/HonoConfig.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/HonoConfig.java new file mode 100644 index 0000000000..18114f7b7e --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/HonoConfig.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.config; + +import java.net.URI; +import java.util.Set; + +import org.eclipse.ditto.connectivity.model.UserPasswordCredentials; +import org.eclipse.ditto.internal.utils.config.KnownConfigValue; + +/** + * This interface provides access to the configuration properties Hono connections. + * The actual configuration can be obtained via actor system extension. + */ +public interface HonoConfig { + + /** + * Prefix in .conf files + */ + String PREFIX = "ditto.connectivity.hono"; + + /** + * Gets the Base URI configuration value. + * + * @return the connection base URI. + */ + URI getBaseUri(); + + /** + * Indicates whether the certificates should be validated. + * + * @return {@code true} if the certificates should be validated, {@code false} else. + */ + boolean isValidateCertificates(); + + /** + * Gets the SASL mechanism of Hono-connection (Kafka specific property). + * + * @return the configured SaslMechanism. + */ + SaslMechanism getSaslMechanism(); + + /** + * Returns the URIs of bootstrap servers. + * + * @return an unmodifiable unsorted Set containing the URIs of bootstrap servers. + */ + Set getBootstrapServerUris(); + + /** + * Gets the credentials for the specified Hono connection. + * + * @return the credentials of the connection. + * @throws NullPointerException if {@code connectionId} is {@code null}. + */ + UserPasswordCredentials getUserPasswordCredentials(); + + enum HonoConfigValue implements KnownConfigValue { + + /** + * Base URL, including port number. + */ + BASE_URI("base-uri", "tcp://localhost:30092"), + + /** + * validateCertificates boolean property. + */ + VALIDATE_CERTIFICATES("validate-certificates", false), + + /** + * SASL mechanism for connections of type Hono. + */ + SASL_MECHANISM("sasl-mechanism", SaslMechanism.PLAIN.name()), + + /** + * Bootstrap servers, comma separated. + */ + BOOTSTRAP_SERVERS("bootstrap-servers", "bootstrap.server:9999"), + + /** + * The Hono credentials username. + */ + USERNAME("username", ""), + + /** + * The Hono credentials password. + */ + PASSWORD("password", ""); + + private final String path; + private final Object defaultValue; + + HonoConfigValue(final String thePath, final Object theDefaultValue) { + path = thePath; + defaultValue = theDefaultValue; + } + + @Override + public Object getDefaultValue() { + return defaultValue; + } + + @Override + public String getConfigPath() { + return path; + } + + } + + enum SaslMechanism { + + PLAIN("plain"), + + SCRAM_SHA_256("scram-sha-256"), + + SCRAM_SHA_512("scram-sha-512"); + + private final String value; + + SaslMechanism(final String value) { + this.value = value; + } + + /** + * Returns the value of this SaslMechanism. + * + * @return the value. + */ + @Override + public String toString() { + return value; + } + + } + +} \ No newline at end of file diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/DefaultClientActorPropsFactory.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/DefaultClientActorPropsFactory.java index 61989e6999..14bc5a34f1 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/DefaultClientActorPropsFactory.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/DefaultClientActorPropsFactory.java @@ -14,13 +14,14 @@ import javax.annotation.concurrent.Immutable; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeException; import org.eclipse.ditto.base.model.headers.DittoHeaders; import org.eclipse.ditto.connectivity.model.Connection; import org.eclipse.ditto.connectivity.service.messaging.amqp.AmqpClientActor; +import org.eclipse.ditto.connectivity.service.messaging.hono.HonoConnectionFactory; import org.eclipse.ditto.connectivity.service.messaging.httppush.HttpPushClientActor; import org.eclipse.ditto.connectivity.service.messaging.kafka.KafkaClientActor; import org.eclipse.ditto.connectivity.service.messaging.mqtt.hivemq.MqttClientActor; -import org.eclipse.ditto.connectivity.service.messaging.mqtt.hivemq.client.GenericMqttClientFactory; import org.eclipse.ditto.connectivity.service.messaging.rabbitmq.RabbitMQClientActor; import com.typesafe.config.Config; @@ -36,7 +37,10 @@ @Immutable public final class DefaultClientActorPropsFactory implements ClientActorPropsFactory { + private final HonoConnectionFactory honoConnectionFactory; + public DefaultClientActorPropsFactory(final ActorSystem actorSystem, final Config config) { + honoConnectionFactory = HonoConnectionFactory.get(actorSystem, config); } @Override @@ -74,7 +78,20 @@ public Props getActorPropsForType(final Connection connection, connectionActor, dittoHeaders, connectivityConfigOverwrites); + case HONO -> KafkaClientActor.props(getResolvedHonoConnectionOrThrow(connection, dittoHeaders), + commandForwarderActor, + connectionActor, + dittoHeaders, + connectivityConfigOverwrites); }; } + private Connection getResolvedHonoConnectionOrThrow(final Connection connection, final DittoHeaders dittoHeaders) { + try { + return honoConnectionFactory.getHonoConnection(connection); + } catch (final DittoRuntimeException e) { + throw e.setDittoHeaders(dittoHeaders); + } + } + } diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/DefaultHonoConnectionFactory.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/DefaultHonoConnectionFactory.java new file mode 100644 index 0000000000..18c806b30f --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/DefaultHonoConnectionFactory.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.messaging.hono; + +import java.net.URI; +import java.text.MessageFormat; +import java.util.Set; + +import org.eclipse.ditto.connectivity.model.HonoAddressAlias; +import org.eclipse.ditto.connectivity.model.UserPasswordCredentials; +import org.eclipse.ditto.connectivity.service.config.DefaultHonoConfig; +import org.eclipse.ditto.connectivity.service.config.HonoConfig; + +import com.typesafe.config.Config; + +import akka.actor.ActorSystem; + +/** + * Default implementation of {@link HonoConnectionFactory}. + * This implementation uses {@link HonoConfig} to obtain the required properties for creating the Hono connection. + */ +public final class DefaultHonoConnectionFactory extends HonoConnectionFactory { + + private final HonoConfig honoConfig; + + /** + * Constructs a {@code DefaultHonoConnectionFactory} for the specified arguments. + * + * @param actorSystem the actor system in which to load the factory. + * @param config configuration properties for this factory. + * @throws NullPointerException if {@code actorSystem} is {@code null}. + */ + public DefaultHonoConnectionFactory(final ActorSystem actorSystem, final Config config) { + honoConfig = new DefaultHonoConfig(actorSystem); + } + + @Override + public URI getBaseUri() { + return honoConfig.getBaseUri(); + } + + @Override + public boolean isValidateCertificates() { + return honoConfig.isValidateCertificates(); + } + + @Override + public HonoConfig.SaslMechanism getSaslMechanism() { + return honoConfig.getSaslMechanism(); + } + + @Override + public Set getBootstrapServerUris() { + return honoConfig.getBootstrapServerUris(); + } + + @Override + protected String getGroupId(final String suffix) { + return suffix; + } + + @Override + protected UserPasswordCredentials getCredentials() { + return honoConfig.getUserPasswordCredentials(); + } + + @Override + protected String resolveSourceAddress(final HonoAddressAlias honoAddressAlias) { + return MessageFormat.format("hono.{0}", honoAddressAlias.getAliasValue()); + } + + @Override + protected String resolveTargetAddress(final HonoAddressAlias honoAddressAlias) { + return MessageFormat.format("hono.{0}/'{{thing:id}}'", honoAddressAlias.getAliasValue()); + } + +} diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoConnectionFactory.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoConnectionFactory.java new file mode 100644 index 0000000000..3057a0ea4d --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoConnectionFactory.java @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.messaging.hono; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkArgument; +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.net.URI; +import java.text.MessageFormat; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import javax.annotation.concurrent.NotThreadSafe; + +import org.eclipse.ditto.connectivity.model.Connection; +import org.eclipse.ditto.connectivity.model.ConnectionType; +import org.eclipse.ditto.connectivity.model.ConnectivityModelFactory; +import org.eclipse.ditto.connectivity.model.FilteredTopic; +import org.eclipse.ditto.connectivity.model.HeaderMapping; +import org.eclipse.ditto.connectivity.model.HonoAddressAlias; +import org.eclipse.ditto.connectivity.model.ReplyTarget; +import org.eclipse.ditto.connectivity.model.Source; +import org.eclipse.ditto.connectivity.model.Target; +import org.eclipse.ditto.connectivity.model.Topic; +import org.eclipse.ditto.connectivity.model.UserPasswordCredentials; +import org.eclipse.ditto.connectivity.service.config.HonoConfig; +import org.eclipse.ditto.internal.utils.extension.DittoExtensionIds; +import org.eclipse.ditto.internal.utils.extension.DittoExtensionPoint; + +import com.typesafe.config.Config; + +import akka.actor.ActorSystem; + +/** + * Base implementation of a factory for getting a Hono {@link Connection}. + * The Connection this factory supplies is based on a provided Connection with adjustments of + *
    + *
  • the base URI,
  • + *
  • the "validate certificates" flag,
  • + *
  • the specific config including SASL mechanism, bootstrap server URIs and group ID,
  • + *
  • the credentials and
  • + *
  • the sources and targets.
  • + *
+ */ +public abstract class HonoConnectionFactory implements DittoExtensionPoint { + + /** + * Constructs a {@code HonoConnectionFactory}. + */ + protected HonoConnectionFactory() { + super(); + } + + /** + * Loads the implementation of {@code HonoConnectionFactory} which is configured for the specified + * {@code ActorSystem}. + * + * @param actorSystem the actorSystem in which the {@code HonoConnectionFactory} should be loaded. + * @param config the configuration for this extension. + * @return the {@code HonoConnectionFactory} implementation. + * @throws NullPointerException if any argument is {@code null}. + */ + public static HonoConnectionFactory get(final ActorSystem actorSystem, final Config config) { + checkNotNull(actorSystem, "actorSystem"); + checkNotNull(config, "config"); + + return DittoExtensionIds.get(actorSystem) + .computeIfAbsent( + DittoExtensionPoint.ExtensionId.ExtensionIdConfig.of(HonoConnectionFactory.class, + config, + ExtensionId.CONFIG_KEY), + ExtensionId::new + ) + .get(actorSystem); + } + + /** + * Returns a proper Hono Connection for the Connection that was used to create this factory instance. + * + * @param connection the connection that serves as base for the Hono connection this factory returns. + * @return the Hono Connection. + * @throws NullPointerException if {@code connection} is {@code null}. + * @throws IllegalArgumentException if the type of {@code connection} is not {@link ConnectionType#HONO}; + * @throws org.eclipse.ditto.base.model.exceptions.DittoRuntimeException if converting {@code connection} to a + * Hono connection failed for some reason. + */ + public Connection getHonoConnection(final Connection connection) { + checkArgument( + checkNotNull(connection, "connection"), + arg -> ConnectionType.HONO == arg.getConnectionType(), + () -> MessageFormat.format("Expected type of connection to be <{0}> but it was <{1}>.", + ConnectionType.HONO, + connection.getConnectionType()) + ); + + preConversion(connection); + + return ConnectivityModelFactory.newConnectionBuilder(connection) + .uri(combineUriWithCredentials(String.valueOf(getBaseUri()), getCredentials())) + .validateCertificate(isValidateCertificates()) + .specificConfig(makeupSpecificConfig(connection)) + .setSources(makeupSources(connection.getSources())) + .setTargets(makeupTargets(connection.getTargets())) + .build(); + } + + /** + * User overridable callback. + * This method is called before the actual conversion of the specified {@code Connection} is performed. + * Empty default implementation. + * + * @param honoConnection the connection that is guaranteed to have type {@link ConnectionType#HONO}. + */ + protected void preConversion(final Connection honoConnection) { + // Do nothing by default. + } + + protected abstract URI getBaseUri(); + + protected abstract boolean isValidateCertificates(); + + protected abstract HonoConfig.SaslMechanism getSaslMechanism(); + + protected abstract Set getBootstrapServerUris(); + + protected abstract String getGroupId(String suffix); + + protected abstract UserPasswordCredentials getCredentials(); + + protected abstract String resolveSourceAddress(HonoAddressAlias honoAddressAlias); + + protected abstract String resolveTargetAddress(HonoAddressAlias honoAddressAlias); + + private String combineUriWithCredentials(final String uri, final UserPasswordCredentials credentials) { + return uri.replaceFirst("(\\S+://)(\\S+)", + "$1" + credentials.getUsername() + ":" + credentials.getPassword() + "@$2"); + } + + private Map makeupSpecificConfig(final Connection connection) { + var groupId = getGroupId(Optional + .ofNullable(connection.getSpecificConfig().get("groupId")) + .orElse(connection.getId().toString())); + return Map.of( + "saslMechanism", String.valueOf(getSaslMechanism()), + "bootstrapServers", getAsCommaSeparatedListString(getBootstrapServerUris()), + "groupId", groupId + ); + } + + private static String getAsCommaSeparatedListString(final Collection uris) { + return uris.stream() + .map(URI::toString) + .collect(Collectors.joining(",")); + } + + @SuppressWarnings("unchecked") + private List makeupSources(final Collection originalSources) { + return originalSources.stream() + .map(originalSource -> ConnectivityModelFactory.newSourceBuilder(originalSource) + .addresses(resolveSourceAddresses(originalSource.getAddresses())) + .replyTarget(getReplyTargetForSource(originalSource).orElse(null)) + .headerMapping(getSourceHeaderMapping(originalSource)) + .build()) + .collect(Collectors.toList()); + } + + private List makeupTargets(final Collection originalTargets) { + return originalTargets.stream() + .map(originalTarget -> ConnectivityModelFactory.newTargetBuilder(originalTarget) + .address(resolveTargetAddressOrKeepUnresolved(originalTarget.getAddress())) + .originalAddress(resolveTargetAddressOrKeepUnresolved(originalTarget.getOriginalAddress())) + .headerMapping(getTargetHeaderMapping(originalTarget)) + .build()) + .collect(Collectors.toList()); + } + + private Set resolveSourceAddresses(final Collection unresolvedSourceAddresses) { + return unresolvedSourceAddresses.stream() + .map(unresolvedSourceAddress -> HonoAddressAlias.forAliasValue(unresolvedSourceAddress) + .map(this::resolveSourceAddress) + .orElse(unresolvedSourceAddress)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private String resolveTargetAddressOrKeepUnresolved(final String unresolvedTargetAddress) { + return HonoAddressAlias.forAliasValue(unresolvedTargetAddress) + .map(this::resolveTargetAddress) + .orElse(unresolvedTargetAddress); + } + + private Optional getReplyTargetForSource(final Source source) { + final Optional result; + if (isApplyReplyTarget(source.getAddresses())) { + result = source.getReplyTarget() + .map(replyTarget -> replyTarget.toBuilder() + .address(resolveTargetAddressOrKeepUnresolved(replyTarget.getAddress())) + .headerMapping(getReplyTargetHeaderMapping(replyTarget)) + .build()); + } else { + result = Optional.empty(); + } + return result; + } + + private static HeaderMapping getReplyTargetHeaderMapping(final ReplyTarget replyTarget) { + final var headerMappingBuilder = HeaderMappingBuilder.of(replyTarget.getHeaderMapping()); + headerMappingBuilder.putCorrelationId(); + if (isCommandHonoAddressAlias(replyTarget.getAddress())) { + headerMappingBuilder.putDeviceId(); + headerMappingBuilder.putSubject( + "{{ header:subject | fn:default(topic:action-subject) | fn:default(topic:criterion) }}-response" + ); + } + return headerMappingBuilder.build(); + } + + private static HeaderMapping getSourceHeaderMapping(final Source source) { + final HeaderMapping result; + if (isAdjustSourceHeaderMapping(source.getAddresses())) { + result = HeaderMappingBuilder.of(source.getHeaderMapping()) + .putCorrelationId() + .putEntry("status", "{{ header:status }}") + .build(); + } else { + result = source.getHeaderMapping(); + } + return result; + } + + private static HeaderMapping getTargetHeaderMapping(final Target target) { + final var headerMappingBuilder = HeaderMappingBuilder.of(target.getHeaderMapping()) + .putDeviceId() + .putCorrelationId() + .putSubject("{{ header:subject | fn:default(topic:action-subject) }}"); + + if (isResponseRequiredHeaderMapping(target.getTopics())) { + headerMappingBuilder.putEntry("response-required", "{{ header:response-required }}"); + } + return headerMappingBuilder.build(); + } + + private static boolean isApplyReplyTarget(final Collection sourceAddresses) { + final Predicate isTelemetryHonoAddressAlias = HonoAddressAlias.TELEMETRY.getAliasValue()::equals; + final Predicate isEventHonoAddressAlias = HonoAddressAlias.EVENT.getAliasValue()::equals; + + return sourceAddresses.stream() + .anyMatch(isTelemetryHonoAddressAlias.or(isEventHonoAddressAlias)); + } + + private static boolean isCommandHonoAddressAlias(final String replyTargetAddress) { + return replyTargetAddress.equals(HonoAddressAlias.COMMAND.getAliasValue()); + } + + private static boolean isAdjustSourceHeaderMapping(final Collection sourceAddresses) { + return sourceAddresses.contains(HonoAddressAlias.COMMAND_RESPONSE.getAliasValue()); + } + + private static boolean isResponseRequiredHeaderMapping(final Collection targetTopics) { + final Predicate isLiveMessages = topic -> Topic.LIVE_MESSAGES == topic; + final Predicate isLiveCommands = topic -> Topic.LIVE_COMMANDS == topic; + + return targetTopics.stream() + .map(FilteredTopic::getTopic) + .anyMatch(isLiveMessages.or(isLiveCommands)); + } + + public static final class ExtensionId extends DittoExtensionPoint.ExtensionId { + + private static final String CONFIG_KEY = "hono-connection-factory"; + + private ExtensionId(final ExtensionIdConfig extensionIdConfig) { + super(extensionIdConfig); + } + + @Override + protected String getConfigKey() { + return CONFIG_KEY; + } + + } + + @NotThreadSafe + private static final class HeaderMappingBuilder { + + private final Map headerMappingDefinition; + + private HeaderMappingBuilder(final HeaderMapping existingHeaderMapping) { + headerMappingDefinition = new LinkedHashMap<>(existingHeaderMapping.getMapping()); + } + + static HeaderMappingBuilder of(final HeaderMapping existingHeaderMapping) { + return new HeaderMappingBuilder(checkNotNull(existingHeaderMapping, "existingHeaderMapping")); + } + + HeaderMappingBuilder putCorrelationId() { + headerMappingDefinition.putIfAbsent("correlation-id", "{{ header:correlation-id }}"); + return this; + } + + HeaderMappingBuilder putDeviceId() { + headerMappingDefinition.putIfAbsent("device_id", "{{ thing:id }}"); + return this; + } + + HeaderMappingBuilder putSubject(final String subjectValue) { + headerMappingDefinition.putIfAbsent("subject", subjectValue); + return this; + } + + HeaderMappingBuilder putEntry(final String key, final String value) { + headerMappingDefinition.putIfAbsent(key, value); + return this; + } + + HeaderMapping build() { + return ConnectivityModelFactory.newHeaderMapping(headerMappingDefinition); + } + + } + +} diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoValidator.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoValidator.java new file mode 100644 index 0000000000..5d864339b3 --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoValidator.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.messaging.hono; + +import java.text.MessageFormat; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.connectivity.model.Connection; +import org.eclipse.ditto.connectivity.model.ConnectionConfigurationInvalidException; +import org.eclipse.ditto.connectivity.model.ConnectionType; +import org.eclipse.ditto.connectivity.model.HonoAddressAlias; +import org.eclipse.ditto.connectivity.model.Source; +import org.eclipse.ditto.connectivity.model.Target; +import org.eclipse.ditto.connectivity.service.config.ConnectivityConfig; +import org.eclipse.ditto.connectivity.service.messaging.validation.AbstractProtocolValidator; +import org.eclipse.ditto.connectivity.service.placeholders.ConnectivityPlaceholders; +import org.eclipse.ditto.placeholders.PlaceholderFactory; + +import akka.actor.ActorSystem; + +@Immutable +public final class HonoValidator extends AbstractProtocolValidator { + + @Nullable private static HonoValidator instance; + + private final Set allowedSourceAddressHonoAliasValues; + + private HonoValidator() { + allowedSourceAddressHonoAliasValues = Stream.of(HonoAddressAlias.values()) + .filter(honoAddressAlias -> HonoAddressAlias.COMMAND != honoAddressAlias) + .map(HonoAddressAlias::getAliasValue) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * Returns an instance of the Hono validator. + * + * @return the instance. + */ + public static HonoValidator getInstance() { + var result = instance; + if (null == result) { + result = new HonoValidator(); + instance = result; + } + return result; + } + + @Override + public ConnectionType type() { + return ConnectionType.HONO; + } + + @Override + public void validate(final Connection connection, + final DittoHeaders dittoHeaders, + final ActorSystem actorSystem, + final ConnectivityConfig connectivityConfig) { + + validateSourceConfigs(connection, dittoHeaders); + validateTargetConfigs(connection, dittoHeaders); + validatePayloadMappings(connection, actorSystem, connectivityConfig, dittoHeaders); + } + + @Override + protected void validateSource(final Source source, + final DittoHeaders dittoHeaders, + final Supplier sourceDescription) { + + validateSourceEnforcement(source, dittoHeaders); + validateSourceAddresses(source, dittoHeaders); + validateSourceQos(source, dittoHeaders); + } + + private void validateSourceEnforcement(final Source source, final DittoHeaders dittoHeaders) { + final Consumer validateInputTemplate = + inputTemplate -> validateTemplate(inputTemplate, + dittoHeaders, + PlaceholderFactory.newHeadersPlaceholder()); + + final Consumer> validateFilterTemplates = + filters -> filters.forEach( + filterTemplate -> validateTemplate(filterTemplate, + dittoHeaders, + ConnectivityPlaceholders.newThingPlaceholder(), + ConnectivityPlaceholders.newPolicyPlaceholder(), + ConnectivityPlaceholders.newEntityPlaceholder(), + ConnectivityPlaceholders.newFeaturePlaceholder()) + ); + + source.getEnforcement() + .ifPresent(enforcement -> { + validateInputTemplate.accept(enforcement.getInput()); + validateFilterTemplates.accept(enforcement.getFilters()); + }); + } + + private void validateSourceAddresses(final Source source, final DittoHeaders dittoHeaders) { + final var sourceAddresses = source.getAddresses(); + sourceAddresses.forEach(address -> validateSourceAddress(address, dittoHeaders)); + } + + private void validateSourceAddress(final String sourceAddress, final DittoHeaders dittoHeaders) { + if (sourceAddress.isEmpty()) { + throw newConnectionConfigurationInvalidException("The provided source address must not be empty.", + dittoHeaders); + } + + if (!allowedSourceAddressHonoAliasValues.contains(sourceAddress)) { + throw newConnectionConfigurationInvalidException( + MessageFormat.format("The provided source address <{0}> is invalid." + + " It should be one of the defined aliases: {1}", + sourceAddress, + allowedSourceAddressHonoAliasValues), + dittoHeaders + ); + } + } + + private static ConnectionConfigurationInvalidException newConnectionConfigurationInvalidException( + final String errorMessage, + final DittoHeaders dittoHeaders + ) { + return ConnectionConfigurationInvalidException.newBuilder(errorMessage).dittoHeaders(dittoHeaders).build(); + } + + private static void validateSourceQos(final Source source, final DittoHeaders dittoHeaders) { + source.getQos() + .filter(qos -> qos < 0 || qos > 1) + .ifPresent(qos -> { + throw newConnectionConfigurationInvalidException( + MessageFormat.format( + "Invalid source ''qos'' value <{0}>. Supported values are <0> and <1>.", + qos), + dittoHeaders + ); + }); + } + + @Override + protected void validateTarget(final Target target, + final DittoHeaders dittoHeaders, + final Supplier targetDescription) { + + validateTargetAddress(target.getAddress(), dittoHeaders); + validateExtraFields(target); + } + + private static void validateTargetAddress(final String targetAddress, final DittoHeaders dittoHeaders) { + if (targetAddress.isEmpty()) { + throw newConnectionConfigurationInvalidException("The provided target address must not be empty.", + dittoHeaders); + } + + final var allowedTargetAddressAliasValue = HonoAddressAlias.COMMAND.getAliasValue(); + if (!Objects.equals(allowedTargetAddressAliasValue, targetAddress)) { + throw newConnectionConfigurationInvalidException( + MessageFormat.format("The provided target address <{0}> is invalid. It should be <{1}>.", + targetAddress, + allowedTargetAddressAliasValue), + dittoHeaders + ); + } + } + +} diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/package-info.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/package-info.java new file mode 100644 index 0000000000..4cc3440549 --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/hono/package-info.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +@org.eclipse.ditto.utils.jsr305.annotations.AllParametersAndReturnValuesAreNonnullByDefault +package org.eclipse.ditto.connectivity.service.messaging.hono; diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionPersistenceActor.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionPersistenceActor.java index b829441618..240ae92062 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionPersistenceActor.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionPersistenceActor.java @@ -53,6 +53,7 @@ import org.eclipse.ditto.connectivity.model.ConnectionId; import org.eclipse.ditto.connectivity.model.ConnectionLifecycle; import org.eclipse.ditto.connectivity.model.ConnectionMetrics; +import org.eclipse.ditto.connectivity.model.ConnectionType; import org.eclipse.ditto.connectivity.model.ConnectivityModelFactory; import org.eclipse.ditto.connectivity.model.ConnectivityStatus; import org.eclipse.ditto.connectivity.model.signals.commands.ConnectivityCommand; @@ -86,6 +87,8 @@ import org.eclipse.ditto.connectivity.service.messaging.ClientActorPropsArgs; import org.eclipse.ditto.connectivity.service.messaging.ClientActorPropsFactory; import org.eclipse.ditto.connectivity.service.messaging.amqp.AmqpValidator; +import org.eclipse.ditto.connectivity.service.messaging.hono.HonoConnectionFactory; +import org.eclipse.ditto.connectivity.service.messaging.hono.HonoValidator; import org.eclipse.ditto.connectivity.service.messaging.httppush.HttpPushValidator; import org.eclipse.ditto.connectivity.service.messaging.kafka.KafkaValidator; import org.eclipse.ditto.connectivity.service.messaging.monitoring.logs.ConnectionLogger; @@ -173,6 +176,7 @@ public final class ConnectionPersistenceActor // never retry, just escalate. ConnectionSupervisorActor will handle restarting this actor private static final SupervisorStrategy ESCALATE_ALWAYS_STRATEGY = OneForOneEscalateStrategy.escalateStrategy(); + private final ActorSystem actorSystem; private final Cluster cluster; private final ActorRef commandForwarderActor; private final ClientActorPropsFactory propsFactory; @@ -187,6 +191,7 @@ public final class ConnectionPersistenceActor private final ConnectionPriorityProvider connectionPriorityProvider; private final ConnectivityCommandInterceptor commandValidator; private final ConnectionPubSub connectionPubSub; + private final HonoConnectionFactory honoConnectionFactory; private final ActorRef clientShardRegion; private int subscriptionCounter = 0; private int ongoingStagedCommands = 0; @@ -204,7 +209,7 @@ public final class ConnectionPersistenceActor final ActorRef clientShardRegion) { super(connectionId); - final ActorSystem actorSystem = context().system(); + this.actorSystem = context().system(); cluster = Cluster.get(actorSystem); final Config dittoExtensionConfig = ScopedConfig.dittoExtension(actorSystem.settings().config()); this.commandForwarderActor = commandForwarderActor; @@ -224,6 +229,7 @@ public final class ConnectionPersistenceActor loggingEnabledDuration = monitoringConfig.logger().logDuration(); checkLoggingActiveInterval = monitoringConfig.logger().loggingActiveCheckInterval(); connectionPubSub = ConnectionPubSub.get(actorSystem); + honoConnectionFactory = HonoConnectionFactory.get(actorSystem, actorSystem.settings().config()); // Make duration fuzzy to avoid all connections getting updated at once. final Duration fuzzyPriorityUpdateInterval = makeFuzzy(connectivityConfig.getConnectionConfig().getPriorityUpdateInterval()); @@ -262,6 +268,7 @@ protected DittoDiagnosticLoggingAdapter createLogger() { * * @param connectionId the connection ID. * @param commandForwarderActor the actor used to send signals into the ditto cluster.. + * @param pubSubMediator the pubSubMediator * @param connectivityConfigOverwrites the overwrites for the connectivity config for the given connection. * @return the Akka configuration Props object. */ @@ -307,12 +314,13 @@ protected Class getEventClass() { @Override protected CommandStrategy.Context getStrategyContext() { return DefaultContext.getInstance( - ConnectionState.of(entityId, connectionLoggerRegistry, connectionLogger, commandValidator), log); + ConnectionState.of(entityId, connectionLoggerRegistry, connectionLogger, commandValidator), + log, getContext().getSystem()); } @Override protected ConnectionCreatedStrategies getCreatedStrategy() { - return ConnectionCreatedStrategies.getInstance(); + return ConnectionCreatedStrategies.getInstance(actorSystem); } @Override @@ -680,10 +688,26 @@ protected Receive matchAnyAfterInitialization() { // Respond with not-accessible-exception for unhandled connectivity commands .match(ConnectivitySudoCommand.class, this::notAccessible) .match(ConnectivityCommand.class, this::notAccessible) + .match(ConnectionSupervisorActor.RestartByConnectionType.class, this::initiateRestartByConnectionType) .build() .orElse(super.matchAnyAfterInitialization()); } + private void initiateRestartByConnectionType( + final ConnectionSupervisorActor.RestartByConnectionType restartByConnectionType) { + if (entity != null) { + if (entity.getConnectionType().equals(restartByConnectionType.getConnectionType())) { + sender().tell(ConnectionSupervisorActor.RestartConnection.of(null), self()); + log.info("Restart command sent to ConnectionSupervisorActor {}.", sender()); + } else { + log.info("Skipping restart of non-{} connection {}.", + restartByConnectionType.getConnectionType(), entityId); + } + } else { + log.info("Skipping restart of connection {} due to unexpected null-entity.", entityId); + } + } + @Override protected void becomeDeletedHandler() { cancelPeriodicPriorityUpdate(); @@ -785,7 +809,6 @@ private void testConnection(final StagedCommand command) { .toBuilder() .dryRun(true) .build(); - final TestConnection testConnection = (TestConnection) command.getCommand().setDittoHeaders(headersWithDryRun); if (connectionOpened) { // connection is open, so either another TestConnection command is currently executed or the @@ -793,6 +816,17 @@ private void testConnection(final StagedCommand command) { // prevent strange behavior. origin.tell(TestConnectionResponse.alreadyCreated(entityId, command.getDittoHeaders()), self); } else { + final TestConnection testConnection; + final TestConnection testConnectionUnresolved = + (TestConnection) command.getCommand().setDittoHeaders(headersWithDryRun); + if (testConnectionUnresolved.getConnection().getConnectionType() == ConnectionType.HONO) { + testConnection = TestConnection.of( + honoConnectionFactory.getHonoConnection(testConnectionUnresolved.getConnection()), + headersWithDryRun); + } else { + testConnection = testConnectionUnresolved; + } + connectionOpened = true; // no need to start more than 1 client for tests // set connection status to CLOSED so that client actors will not try to connect on startup @@ -1124,7 +1158,6 @@ private void restoreOpenConnection() { private ConnectivityCommandInterceptor getCommandValidator() { - final var actorSystem = getContext().getSystem(); final MqttConfig mqttConfig = connectivityConfig.getConnectionConfig().getMqttConfig(); final ConnectionValidator connectionValidator = ConnectionValidator.of(actorSystem.log(), @@ -1133,6 +1166,7 @@ private ConnectivityCommandInterceptor getCommandValidator() { AmqpValidator.newInstance(), Mqtt3Validator.newInstance(mqttConfig), Mqtt5Validator.newInstance(mqttConfig), + HonoValidator.getInstance(), KafkaValidator.getInstance(), HttpPushValidator.newInstance(connectivityConfig.getConnectionConfig().getHttpPushConfig())); @@ -1198,7 +1232,7 @@ enum Control { } /** - * Local message this actor may sent to itself in order to update the priority of the connection. + * Local message this actor may send to itself in order to update the priority of the connection. */ @Immutable static final class UpdatePriority implements WithDittoHeaders { diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionSupervisorActor.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionSupervisorActor.java index 29ee9987f2..358030b220 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionSupervisorActor.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionSupervisorActor.java @@ -12,10 +12,13 @@ */ package org.eclipse.ditto.connectivity.service.messaging.persistence; +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; @@ -29,6 +32,7 @@ import org.eclipse.ditto.base.service.actors.ShutdownBehaviour; import org.eclipse.ditto.base.service.config.supervision.ExponentialBackOffConfig; import org.eclipse.ditto.connectivity.model.ConnectionId; +import org.eclipse.ditto.connectivity.model.ConnectionType; import org.eclipse.ditto.connectivity.model.signals.commands.ConnectivityCommand; import org.eclipse.ditto.connectivity.model.signals.commands.exceptions.ConnectionUnavailableException; import org.eclipse.ditto.connectivity.model.signals.commands.modify.LoggingExpired; @@ -147,12 +151,14 @@ protected Receive activeBehaviour(final Runnable matchProcessNextTwinMessageBeha .match(Config.class, this::onConnectivityConfigModified) .match(CheckForOverwritesConfig.class, checkForOverwrites -> initConfigOverwrites(getEntityId(), checkForOverwrites.dittoHeaders)) + .match(RestartConnection.class, restartConnection -> restartConnection.getModifiedConfig() + .ifPresentOrElse(this::onConnectivityConfigModified, this::restartChild)) .matchEquals(Control.REGISTRATION_FOR_CONFIG_CHANGES_SUCCESSFUL, c -> { log.debug("Successfully registered for connectivity config changes."); isRegisteredForConnectivityConfigChanges = true; }) .build() - .orElse(connectivityConfigModifiedBehavior()) + .orElse(connectivityConfigModifiedBehavior(getSelf(), () -> persistenceActorChild)) .orElse(super.activeBehaviour(matchProcessNextTwinMessageBehavior, matchAnyBehavior)); } @@ -214,8 +220,11 @@ public SupervisorStrategy supervisorStrategy() { return SUPERVISOR_STRATEGY; } - @Override - public void onConnectivityConfigModified(final Config modifiedConfig) { + /* + * This method is called when a config modification is received. Implementations must handle the modified config + * appropriately i.e. check if any relevant config has changed and re-initialize state if necessary. + */ + private void onConnectivityConfigModified(final Config modifiedConfig) { if (Objects.equals(connectivityConfigOverwrites, modifiedConfig)) { log.debug("Received modified config is unchanged, not restarting persistence actor."); } else { @@ -303,4 +312,83 @@ private CheckForOverwritesConfig(@Nullable final DittoHeaders dittoHeaders) { this.dittoHeaders = dittoHeaders; } } + + /** + * Command to restart the connection with a modified Config (provided in the command) or unconditional restart if modifiedConfig is null. + */ + public static final class RestartConnection { + + @Nullable + private final Config modifiedConfig; + + private RestartConnection(@Nullable final Config modifiedConfig) { + this.modifiedConfig = modifiedConfig; + } + + /** + * + * @param modifiedConfig a new config to restart connection if changed or {@code null} to restart it unconditionally + * @return {@link RestartConnection} command class + */ + public static RestartConnection of(@Nullable final Config modifiedConfig) { + return new RestartConnection(modifiedConfig); + } + + /** + * Getter + * @return the modified config + */ + public Optional getModifiedConfig() { + return Optional.ofNullable(modifiedConfig); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + final RestartConnection that = (RestartConnection) o; + return Objects.equals(modifiedConfig, that.modifiedConfig); + } + + @Override + public int hashCode() { + return Objects.hash(modifiedConfig); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + ", modifiedConfig=" + modifiedConfig + + "]"; + } + } + + /** + * Signals the persistence actor to initiate restart of itself if its type is equal to the specified connectionType. + */ + public static final class RestartByConnectionType { + + private final ConnectionType connectionType; + + /** + * Constructor + * @param connectionType the desired connection type to filter by + */ + public RestartByConnectionType(final ConnectionType connectionType) { + this.connectionType = checkNotNull(connectionType, "connectionType"); + } + + /** + * Getter + * @return the connection type + */ + public ConnectionType getConnectionType() { + return connectionType; + } + } } diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/strategies/commands/ConnectionCreatedStrategies.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/strategies/commands/ConnectionCreatedStrategies.java index e305a7d7dd..161f1988d1 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/strategies/commands/ConnectionCreatedStrategies.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/strategies/commands/ConnectionCreatedStrategies.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 Contributors to the Eclipse Foundation + * Copyright (c) 2022 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -15,9 +15,7 @@ import javax.annotation.Nullable; import org.eclipse.ditto.base.model.signals.commands.Command; -import org.eclipse.ditto.connectivity.api.commands.sudo.ConnectivitySudoCommand; import org.eclipse.ditto.connectivity.model.Connection; -import org.eclipse.ditto.connectivity.model.signals.commands.ConnectivityCommand; import org.eclipse.ditto.connectivity.model.signals.commands.exceptions.ConnectionNotAccessibleException; import org.eclipse.ditto.connectivity.model.signals.events.ConnectivityEvent; import org.eclipse.ditto.connectivity.service.messaging.persistence.stages.ConnectionState; @@ -25,6 +23,8 @@ import org.eclipse.ditto.internal.utils.persistentactors.results.Result; import org.eclipse.ditto.internal.utils.persistentactors.results.ResultFactory; +import akka.actor.ActorSystem; + /** * Strategies to handle signals as an existing connection. */ @@ -32,20 +32,19 @@ public final class ConnectionCreatedStrategies extends AbstractCommandStrategies, Connection, ConnectionState, ConnectivityEvent> implements ConnectivityCommandStrategies { - private static final ConnectionCreatedStrategies CREATED_STRATEGIES = newCreatedStrategies(); - private ConnectionCreatedStrategies() { super(Command.class); } /** + * @param actorSystem Actor system reference * @return the unique instance of this class. */ - public static ConnectionCreatedStrategies getInstance() { - return CREATED_STRATEGIES; + public static ConnectionCreatedStrategies getInstance(final ActorSystem actorSystem) { + return newCreatedStrategies(actorSystem); } - private static ConnectionCreatedStrategies newCreatedStrategies() { + private static ConnectionCreatedStrategies newCreatedStrategies(final ActorSystem actorSystem) { final ConnectionCreatedStrategies strategies = new ConnectionCreatedStrategies(); strategies.addStrategy(new StagedCommandStrategy()); strategies.addStrategy(new TestConnectionConflictStrategy()); @@ -59,6 +58,7 @@ private static ConnectionCreatedStrategies newCreatedStrategies() { strategies.addStrategy(new RetrieveConnectionLogsStrategy()); strategies.addStrategy(new ResetConnectionLogsStrategy()); strategies.addStrategy(new RetrieveConnectionStrategy()); + strategies.addStrategy(new RetrieveResolvedHonoConnectionStrategy(actorSystem)); strategies.addStrategy(new RetrieveConnectionStatusStrategy()); strategies.addStrategy(new RetrieveConnectionMetricsStrategy()); strategies.addStrategy(new LoggingExpiredStrategy()); diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/strategies/commands/RetrieveResolvedHonoConnectionStrategy.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/strategies/commands/RetrieveResolvedHonoConnectionStrategy.java new file mode 100644 index 0000000000..d997d23cb4 --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/strategies/commands/RetrieveResolvedHonoConnectionStrategy.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.messaging.persistence.strategies.commands; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.entity.metadata.Metadata; +import org.eclipse.ditto.connectivity.model.Connection; +import org.eclipse.ditto.connectivity.model.ConnectionType; +import org.eclipse.ditto.connectivity.model.signals.commands.query.RetrieveConnectionResponse; +import org.eclipse.ditto.connectivity.model.signals.commands.query.RetrieveResolvedHonoConnection; +import org.eclipse.ditto.connectivity.model.signals.events.ConnectivityEvent; +import org.eclipse.ditto.connectivity.service.messaging.hono.HonoConnectionFactory; +import org.eclipse.ditto.connectivity.service.messaging.persistence.stages.ConnectionState; +import org.eclipse.ditto.internal.utils.persistentactors.results.Result; +import org.eclipse.ditto.internal.utils.persistentactors.results.ResultFactory; + +import akka.actor.ActorSystem; + +/** + * This strategy handles the {@link RetrieveResolvedHonoConnection} command. + */ +final class RetrieveResolvedHonoConnectionStrategy + extends AbstractConnectivityCommandStrategy { + + private final HonoConnectionFactory honoConnectionFactory; + + RetrieveResolvedHonoConnectionStrategy(final ActorSystem actorSystem) { + super(RetrieveResolvedHonoConnection.class); + this.honoConnectionFactory = HonoConnectionFactory.get(actorSystem, actorSystem.settings().config()); + } + + @Override + protected Result> doApply(final Context context, + @Nullable final Connection entity, + final long nextRevision, + final RetrieveResolvedHonoConnection command, + @Nullable final Metadata metadata) { + + final Result> result; + if (entity != null && entity.getConnectionType() == ConnectionType.HONO) { + final var json = honoConnectionFactory.getHonoConnection(entity).toJson(); + + result = ResultFactory.newQueryResult(command, + RetrieveConnectionResponse.of(json, command.getDittoHeaders())); + } else { + result = ResultFactory.newErrorResult(notAccessible(context, command), command); + } + return result; + } +} diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/validation/ConnectionValidator.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/validation/ConnectionValidator.java index afeb2a062c..6c9bf95f3e 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/validation/ConnectionValidator.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/validation/ConnectionValidator.java @@ -199,13 +199,16 @@ void validate(final Connection connection, final DittoHeaders dittoHeaders, fina final ConnectionLogger connectionLogger = ConnectionLogger.getInstance(connection.getId(), connectivityConfig.getMonitoringConfig().logger()); validateFormatOfCertificates(connection, dittoHeaders, connectionLogger); - + final ConnectionType connectionType = connection.getConnectionType(); // validate configured host final HostValidator hostValidator = new DefaultHostValidator(connectivityConfig, loggingAdapter); - hostValidator.validateHostname(connection.getHostname(), dittoHeaders); + + if(connectionType != ConnectionType.HONO) { + hostValidator.validateHostname(connection.getHostname(), dittoHeaders); + } // tunneling not supported for kafka - if (ConnectionType.KAFKA == connection.getConnectionType() && connection.getSshTunnel().isPresent()) { + if (ConnectionType.KAFKA == connectionType && connection.getSshTunnel().isPresent()) { throw ConnectionConfigurationInvalidException .newBuilder("SSH tunneling not supported.") .description( diff --git a/connectivity/service/src/main/resources/connectivity-extension.conf b/connectivity/service/src/main/resources/connectivity-extension.conf index 0047b8fda8..e69de29bb2 100644 --- a/connectivity/service/src/main/resources/connectivity-extension.conf +++ b/connectivity/service/src/main/resources/connectivity-extension.conf @@ -1 +0,0 @@ -ditto.mapping-strategy.implementation = "org.eclipse.ditto.connectivity.api.ConnectivityMappingStrategies" diff --git a/connectivity/service/src/main/resources/connectivity.conf b/connectivity/service/src/main/resources/connectivity.conf index 8402e80011..30f757423b 100644 --- a/connectivity/service/src/main/resources/connectivity.conf +++ b/connectivity/service/src/main/resources/connectivity.conf @@ -1,5 +1,4 @@ ditto { - service-name = "connectivity" mongodb { @@ -12,6 +11,8 @@ ditto { connection-priority-provider-factory = "org.eclipse.ditto.connectivity.service.messaging.persistence.UsageBasedPriorityProviderFactory" # Factory for custom client actor props. client-actor-props-factory = "org.eclipse.ditto.connectivity.service.messaging.DefaultClientActorPropsFactory" + # Factory for getting a fully-fledged Hono factory for a particular base connection. + hono-connection-factory = "org.eclipse.ditto.connectivity.service.messaging.hono.DefaultHonoConnectionFactory" # Extension for custom message mapper behaviour message-mapper-extension = "org.eclipse.ditto.connectivity.service.mapping.NoOpMessageMapperExtension" @@ -64,11 +65,33 @@ ditto { snapshot-adapter = "org.eclipse.ditto.connectivity.service.messaging.persistence.ConnectionMongoSnapshotAdapter" } + mapping-strategy.implementation = "org.eclipse.ditto.connectivity.api.ConnectivityMappingStrategies" + persistence.operations.delay-after-persistence-actor-shutdown = 5s persistence.operations.delay-after-persistence-actor-shutdown = ${?DELAY_AFTER_PERSISTENCE_ACTOR_SHUTDOWN} connectivity { + hono { + base-uri = "tcp://localhost:9092" + base-uri = ${?HONO_CONNECTION_URI} + + validate-certificates = false + validate-certificates = ${?HONO_CONNECTION_VALIDATE_CERTIFICATE} + + sasl-mechanism = "PLAIN" + sasl-mechanism = ${?HONO_CONNECTION_SASL_MECHANISM} + + bootstrap-servers = "localhost:9092" + bootstrap-servers = ${?HONO_CONNECTION_BOOTSTRAP_SERVERS} + + username = "honoUsername" + username = ${?HONO_CONNECTION_HONO_USERNAME} + + password = "honoPassword" + password = ${?HONO_CONNECTION_HONO_PASSWORD} + } + user-indicated-errors-base = [ # Kafka {exceptionName: "org.apache.kafka.common.errors.SaslAuthenticationException", messagePattern: ".*"} @@ -127,7 +150,7 @@ ditto { connection { # A comma separated string of hostnames to which http requests will allowed. This overrides the blocked # hostnames i.e if a host is blocked *and* allowed, it will be allowed. - allowed-hostnames = "" + allowed-hostnames = "hono-endpoint" #allowed-hostnames = "localhost" allowed-hostnames = ${?CONNECTIVITY_CONNECTION_ALLOWED_HOSTNAMES} diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/config/DefaultHonoConfigTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/config/DefaultHonoConfigTest.java new file mode 100644 index 0000000000..374a1b1fca --- /dev/null +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/config/DefaultHonoConfigTest.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.mutabilitydetector.unittesting.AllowedReason.assumingFields; +import static org.mutabilitydetector.unittesting.MutabilityAssert.assertInstancesOf; +import static org.mutabilitydetector.unittesting.MutabilityMatchers.areImmutable; + +import java.net.URI; +import java.net.URISyntaxException; +import java.text.MessageFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.eclipse.ditto.connectivity.model.UserPasswordCredentials; +import org.eclipse.ditto.internal.utils.config.DittoConfigError; +import org.eclipse.ditto.internal.utils.config.WithConfigPath; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; +import com.typesafe.config.ConfigFactory; + +import akka.actor.ActorSystem; +import akka.testkit.javadsl.TestKit; +import nl.jqno.equalsverifier.EqualsVerifier; +import scala.concurrent.duration.FiniteDuration; + +/** + * Unit test for {@link DefaultHonoConfig}. + */ +public final class DefaultHonoConfigTest { + + @Rule + public final TestName testName = new TestName(); + + private ActorSystem actorSystem; + + @After + public void after() { + if (null != actorSystem) { + TestKit.shutdownActorSystem(actorSystem, FiniteDuration.apply(5, TimeUnit.SECONDS), false); + } + } + + @Test + public void assertImmutability() { + assertInstancesOf(DefaultHonoConfig.class, + areImmutable(), + assumingFields("bootstrapServerUris").areSafelyCopiedUnmodifiableCollectionsWithImmutableElements()); + } + + @Test + public void testHashCodeAndEquals() { + EqualsVerifier.forClass(DefaultHonoConfig.class) + .usingGetClass() + .verify(); + } + + @Test + public void newInstanceWithNullActorSystemThrowsException() { + assertThatNullPointerException() + .isThrownBy(() -> new DefaultHonoConfig(null)) + .withMessage("The actorSystem must not be null!") + .withNoCause(); + } + + @Test + public void newInstanceThrowsDittoConfigErrorIfBaseUriSyntaxIsInvalid() { + final var configKey = getFullQualifiedConfigKey(HonoConfig.HonoConfigValue.BASE_URI); + final var invalidUri = "192.168.1.256:80"; + + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> new DefaultHonoConfig(getActorSystem( + ConfigFactory.parseMap(Map.of(configKey, invalidUri)) + ))) + .withMessageStartingWith("The string value at <%s> is not a URI:", + HonoConfig.HonoConfigValue.BASE_URI.getConfigPath()) + .withMessageEndingWith(invalidUri) + .withCauseInstanceOf(URISyntaxException.class); + } + + @Test + public void getBaseUriReturnsDefaultValueIfNotContainedInConfig() { + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.empty())); + + assertThat(defaultHonoConfig.getBaseUri()) + .isEqualTo(URI.create(HonoConfig.HonoConfigValue.BASE_URI.getDefaultValue().toString())); + } + + @Test + public void getBaseUriReturnsExplicitlyConfiguredValue() { + final var baseUri = URI.create("example.org:8080"); + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.parseMap( + Map.of(getFullQualifiedConfigKey(HonoConfig.HonoConfigValue.BASE_URI), baseUri.toString()) + ))); + + assertThat(defaultHonoConfig.getBaseUri()).isEqualTo(baseUri); + } + + @Test + public void isValidateCertificatesReturnsDefaultValueIfNotContainedInConfig() { + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.empty())); + + assertThat(defaultHonoConfig.isValidateCertificates()) + .isEqualTo(HonoConfig.HonoConfigValue.VALIDATE_CERTIFICATES.getDefaultValue()); + } + + @Test + public void isValidateCertificatesReturnsExplicitlyConfiguredValue() { + final var validateCertificates = true; + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.parseMap( + Map.of(getFullQualifiedConfigKey(HonoConfig.HonoConfigValue.VALIDATE_CERTIFICATES), + validateCertificates) + ))); + + assertThat(defaultHonoConfig.isValidateCertificates()).isEqualTo(validateCertificates); + } + + @Test + public void newInstanceThrowsDittoConfigErrorIfSaslMechanismIsUnknown() { + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> new DefaultHonoConfig(getActorSystem(ConfigFactory.parseMap( + Map.of(getFullQualifiedConfigKey(HonoConfig.HonoConfigValue.SASL_MECHANISM), 42) + )))) + .withCauseInstanceOf(ConfigException.BadValue.class); + } + + @Test + public void getSaslMechanismReturnsDefaultValueIfNotContainedInConfig() { + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.empty())); + + assertThat(defaultHonoConfig.getSaslMechanism()) + .isEqualTo(HonoConfig.SaslMechanism.valueOf( + HonoConfig.HonoConfigValue.SASL_MECHANISM.getDefaultValue().toString() + )); + } + + @Test + public void newInstanceThrowsDittoConfigErrorIfOneBootstrapServerUriSyntaxIsInvalid() { + final var configKey = getFullQualifiedConfigKey(HonoConfig.HonoConfigValue.BOOTSTRAP_SERVERS); + final var invalidUri = "192.168.1.256:80"; + final var bootstrapServerUrisString = "example.com," + invalidUri + ",10.1.0.35:8080"; + + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> new DefaultHonoConfig(getActorSystem( + ConfigFactory.parseMap(Map.of(configKey, bootstrapServerUrisString)) + ))) + .withMessageStartingWith("The string at index <1> for key <%s> is not a valid URI:", + HonoConfig.HonoConfigValue.BOOTSTRAP_SERVERS.getConfigPath()) + .withMessageEndingWith(invalidUri) + .withCauseInstanceOf(URISyntaxException.class); + } + + @Test + public void getBootstrapServerUrisReturnsDefaultValueIfNotContainedInConfig() { + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.empty())); + + assertThat(defaultHonoConfig.getBootstrapServerUris()) + .containsOnly(URI.create(HonoConfig.HonoConfigValue.BOOTSTRAP_SERVERS.getDefaultValue().toString())); + } + + @Test + public void getBootstrapServerUrisReturnsExplicitlyConfiguredValues() { + final var bootstrapServerUris = List.of(URI.create("www.example.org"), + URI.create("tcp://192.168.10.1:8080"), + URI.create("file://bin/server")); + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.parseMap(Map.of( + getFullQualifiedConfigKey(HonoConfig.HonoConfigValue.BOOTSTRAP_SERVERS), + bootstrapServerUris.stream() + .map(URI::toString) + .map(s -> s.concat(" ")) // blanks should get trimmed + .collect(Collectors.joining(",")) + )))); + + assertThat(defaultHonoConfig.getBootstrapServerUris()).containsExactlyElementsOf(bootstrapServerUris); + } + + @Test + public void getCredentialsReturnsDefaultValueIfNotContainedInConfig() { + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.empty())); + + assertThat(defaultHonoConfig.getUserPasswordCredentials()) + .isEqualTo(UserPasswordCredentials.newInstance("", "")); + } + + @Test + public void getCredentialsReturnsExplicitlyConfiguredValues() { + final var username = "Herbert W. Franke"; + final var password = "PeterParsival"; + final var defaultHonoConfig = new DefaultHonoConfig(getActorSystem(ConfigFactory.parseMap(Map.of( + getFullQualifiedConfigKey(HonoConfig.HonoConfigValue.USERNAME), username, + getFullQualifiedConfigKey(HonoConfig.HonoConfigValue.PASSWORD), password + )))); + + assertThat(defaultHonoConfig.getUserPasswordCredentials()) + .isEqualTo(UserPasswordCredentials.newInstance(username, password)); + } + + private static String getFullQualifiedConfigKey(final WithConfigPath withConfigPath) { + return MessageFormat.format("{0}.{1}", HonoConfig.PREFIX, withConfigPath.getConfigPath()); + } + + private ActorSystem getActorSystem(final Config config) { + actorSystem = ActorSystem.create(testName.getMethodName(), config); + return actorSystem; + } + +} \ No newline at end of file diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/DefaultClientActorPropsFactoryTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/DefaultClientActorPropsFactoryTest.java index 4643e66eaa..3bcd82976f 100644 --- a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/DefaultClientActorPropsFactoryTest.java +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/DefaultClientActorPropsFactoryTest.java @@ -15,6 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.ditto.connectivity.model.ConnectionType.AMQP_091; import static org.eclipse.ditto.connectivity.model.ConnectionType.AMQP_10; +import static org.eclipse.ditto.connectivity.model.ConnectionType.HONO; import static org.eclipse.ditto.connectivity.model.ConnectionType.KAFKA; import static org.eclipse.ditto.connectivity.model.ConnectionType.MQTT; import static org.eclipse.ditto.connectivity.model.ConnectionType.MQTT_5; @@ -111,6 +112,16 @@ public void kafkaActorPropsIsSerializable() { actorPropsIsSerializable(KAFKA); } + /** + * Tests serialization of props of Hono client actor. The props needs to be serializable because client actors + * may be created on a different connectivity service instance using a local connection object. + */ + @Test + @SuppressWarnings("squid:S2699") + public void honoActorPropsIsSerializable() { + actorPropsIsSerializable(HONO); + } + private void actorPropsIsSerializable(final ConnectionType connectionType) { final Props props = underTest.getActorPropsForType(randomConnection(connectionType), actorSystem.deadLetters(), actorSystem.deadLetters(), actorSystem, DittoHeaders.empty(), ConfigFactory.empty()); diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/hono/DefaultHonoConnectionFactoryTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/hono/DefaultHonoConnectionFactoryTest.java new file mode 100644 index 0000000000..6a72dd5017 --- /dev/null +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/hono/DefaultHonoConnectionFactoryTest.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.messaging.hono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.ditto.connectivity.model.HonoAddressAlias.COMMAND_RESPONSE; +import static org.eclipse.ditto.connectivity.model.HonoAddressAlias.EVENT; +import static org.eclipse.ditto.connectivity.model.HonoAddressAlias.TELEMETRY; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.assertj.core.api.Assertions; +import org.eclipse.ditto.connectivity.model.Connection; +import org.eclipse.ditto.connectivity.model.ConnectivityModelFactory; +import org.eclipse.ditto.connectivity.model.HonoAddressAlias; +import org.eclipse.ditto.connectivity.model.ReplyTarget; +import org.eclipse.ditto.connectivity.model.Source; +import org.eclipse.ditto.connectivity.service.config.DefaultHonoConfig; +import org.eclipse.ditto.connectivity.service.config.HonoConfig; +import org.eclipse.ditto.internal.utils.akka.ActorSystemResource; +import org.eclipse.ditto.json.JsonFactory; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +/** + * Unit test for {@link DefaultHonoConnectionFactory}. + */ +public final class DefaultHonoConnectionFactoryTest { + + private static final Config TEST_CONFIG = ConfigFactory.load("test"); + + @Rule + public final ActorSystemResource actorSystemResource = ActorSystemResource.newInstance(TEST_CONFIG); + + private HonoConfig honoConfig; + + private static Connection generateConnectionObjectFromJsonFile( String fileName) throws IOException { + final var testClassLoader = DefaultHonoConnectionFactoryTest.class.getClassLoader(); + try (final var connectionJsonFileStreamReader = new InputStreamReader( + testClassLoader.getResourceAsStream(fileName) + )) { + return ConnectivityModelFactory.connectionFromJson( + JsonFactory.readFrom(connectionJsonFileStreamReader).asObject()); + } + } + + @Before + public void before() { + honoConfig = new DefaultHonoConfig(actorSystemResource.getActorSystem()); + } + + @Test + public void newInstanceWithNullActorSystemThrowsException() { + Assertions.assertThatNullPointerException() + .isThrownBy(() -> new DefaultHonoConnectionFactory(null, ConfigFactory.empty())) + .withMessage("The actorSystem must not be null!") + .withNoCause(); + } + + @Test + public void getHonoConnectionWithCustomMappingsReturnsExpected() throws IOException { + final var userProvidedHonoConnection = + generateConnectionObjectFromJsonFile("hono-connection-custom-test.json"); + final var expectedHonoConnection = + generateConnectionObjectFromJsonFile("hono-connection-custom-expected.json"); + + final var underTest = + new DefaultHonoConnectionFactory(actorSystemResource.getActorSystem(), ConfigFactory.empty()); + + assertThat(underTest.getHonoConnection(userProvidedHonoConnection)).isEqualTo(expectedHonoConnection); + } + + @Test + public void getHonoConnectionWithDefaultMappingReturnsExpected() throws IOException { + final var userProvidedHonoConnection = + generateConnectionObjectFromJsonFile("hono-connection-default-test.json"); + + final var underTest = + new DefaultHonoConnectionFactory(actorSystemResource.getActorSystem(), ConfigFactory.empty()); + + assertThat(underTest.getHonoConnection(userProvidedHonoConnection)) + .isEqualTo(getExpectedHonoConnection(userProvidedHonoConnection)); + } + + @SuppressWarnings("unchecked") + private Connection getExpectedHonoConnection(final Connection originalConnection) { + final var sourcesByAddress = getSourcesByAddress(originalConnection.getSources()); + final var commandReplyTargetHeaderMapping = ConnectivityModelFactory.newHeaderMapping(Map.of( + "correlation-id", "{{ header:correlation-id }}", + "device_id", "{{ thing:id }}", + "subject", + "{{ header:subject | fn:default(topic:action-subject) | fn:default(topic:criterion) }}-response" + )); + final var targets = originalConnection.getTargets(); + final var basicAdditionalTargetHeaderMappingEntries = Map.of( + "device_id", "{{ thing:id }}", + "correlation-id", "{{ header:correlation-id }}", + "subject", "{{ header:subject | fn:default(topic:action-subject) }}" + ); + return ConnectivityModelFactory.newConnectionBuilder(originalConnection) + .uri(honoConfig.getBaseUri().toString().replaceFirst("(\\S+://)(\\S+)", + "$1" + honoConfig.getUserPasswordCredentials().getUsername() + + ":" + honoConfig.getUserPasswordCredentials().getPassword() + + "@$2")) + .validateCertificate(honoConfig.isValidateCertificates()) + .specificConfig(Map.of( + "saslMechanism", honoConfig.getSaslMechanism().toString(), + "bootstrapServers", TEST_CONFIG.getString(HonoConfig.PREFIX + ".bootstrap-servers"), + "groupId", originalConnection.getId().toString()) + ) + .setSources(List.of( + ConnectivityModelFactory.newSourceBuilder(sourcesByAddress.get(TELEMETRY.getAliasValue())) + .addresses(Set.of(getExpectedResolvedSourceAddress(TELEMETRY))) + .replyTarget(ReplyTarget.newBuilder() + .address(getExpectedResolvedCommandTargetAddress()) + .headerMapping(commandReplyTargetHeaderMapping) + .build()) + .build(), + ConnectivityModelFactory.newSourceBuilder(sourcesByAddress.get(EVENT.getAliasValue())) + .addresses(Set.of(getExpectedResolvedSourceAddress(EVENT))) + .replyTarget(ReplyTarget.newBuilder() + .address(getExpectedResolvedCommandTargetAddress()) + .headerMapping(commandReplyTargetHeaderMapping) + .build()) + .build(), + ConnectivityModelFactory.newSourceBuilder( + sourcesByAddress.get(COMMAND_RESPONSE.getAliasValue())) + .addresses(Set.of(getExpectedResolvedSourceAddress(COMMAND_RESPONSE))) + .headerMapping(ConnectivityModelFactory.newHeaderMapping(Map.of( + "correlation-id", "{{ header:correlation-id }}", + "status", "{{ header:status }}" + ))) + .build() + )) + .setTargets(List.of( + ConnectivityModelFactory.newTargetBuilder(targets.get(0)) + .address(getExpectedResolvedCommandTargetAddress()) + .originalAddress(getExpectedResolvedCommandTargetAddress()) + .headerMapping(ConnectivityModelFactory.newHeaderMapping( + Stream.concat( + basicAdditionalTargetHeaderMappingEntries.entrySet().stream(), + Stream.of(Map.entry("response-required", + "{{ header:response-required }}")) + ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)) + )) + .build(), + ConnectivityModelFactory.newTargetBuilder(targets.get(1)) + .address(getExpectedResolvedCommandTargetAddress()) + .originalAddress(getExpectedResolvedCommandTargetAddress()) + .headerMapping(ConnectivityModelFactory.newHeaderMapping( + basicAdditionalTargetHeaderMappingEntries + )) + .build() + )) + .build(); + } + + private static Map getSourcesByAddress(final Iterable sources) { + final var result = new LinkedHashMap(); + sources.forEach(source -> source.getAddresses().forEach(address -> result.put(address, source))); + return result; + } + + private static String getExpectedResolvedSourceAddress(final HonoAddressAlias honoAddressAlias) { + return "hono." + honoAddressAlias.getAliasValue(); + } + + private static String getExpectedResolvedCommandTargetAddress() { + return "hono." + HonoAddressAlias.COMMAND.getAliasValue() + "/{{thing:id}}"; + } + +} diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoValidatorTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoValidatorTest.java new file mode 100644 index 0000000000..185f590395 --- /dev/null +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/hono/HonoValidatorTest.java @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2022 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 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.connectivity.service.messaging.hono; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.eclipse.ditto.connectivity.service.messaging.TestConstants.Authorization.AUTHORIZATION_CONTEXT; + +import java.util.List; +import java.util.stream.Stream; + +import org.assertj.core.api.JUnitSoftAssertions; +import org.eclipse.ditto.base.model.correlationid.TestNameCorrelationId; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.connectivity.model.Connection; +import org.eclipse.ditto.connectivity.model.ConnectionConfigurationInvalidException; +import org.eclipse.ditto.connectivity.model.ConnectionId; +import org.eclipse.ditto.connectivity.model.ConnectionType; +import org.eclipse.ditto.connectivity.model.ConnectivityModelFactory; +import org.eclipse.ditto.connectivity.model.ConnectivityStatus; +import org.eclipse.ditto.connectivity.model.Enforcement; +import org.eclipse.ditto.connectivity.model.HonoAddressAlias; +import org.eclipse.ditto.connectivity.model.Topic; +import org.eclipse.ditto.connectivity.service.config.ConnectivityConfig; +import org.eclipse.ditto.connectivity.service.messaging.TestConstants; +import org.eclipse.ditto.internal.utils.akka.ActorSystemResource; +import org.eclipse.ditto.placeholders.UnresolvedPlaceholderException; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; + +/** + * Unit test for {@link org.eclipse.ditto.connectivity.service.messaging.hono.HonoValidator}. + */ +public final class HonoValidatorTest { + + @ClassRule + public static final ActorSystemResource ACTOR_SYSTEM_RESOURCE = + ActorSystemResource.newInstance(TestConstants.CONFIG); + + private static final ConnectionId CONNECTION_ID = TestConstants.createRandomConnectionId(); + private static final ConnectivityConfig CONNECTIVITY_CONFIG = TestConstants.CONNECTIVITY_CONFIG; + + @Rule + public final TestNameCorrelationId testNameCorrelationId = TestNameCorrelationId.newInstance(); + + @Rule + public final JUnitSoftAssertions softly = new JUnitSoftAssertions(); + + private HonoValidator underTest; + + @Before + public void before() { + underTest = HonoValidator.getInstance(); + } + + @Test + public void validateWithValidEnforcementThrowsNoException() { + assertThatCode( + () -> underTest.validate(getConnectionWithSourceEnforcement( + ConnectivityModelFactory.newEnforcement("{{ header:device_id }}", + "{{ thing:id }}", + "{{ thing:name }}", + "{{ thing:namespace }}") + ), + getDittoHeadersWithCorrelationId(), + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG) + ).doesNotThrowAnyException(); + } + + @Test + public void validateWithInvalidMatcherThrowsException() { + final var dittoHeaders = getDittoHeadersWithCorrelationId(); + + assertThatExceptionOfType(ConnectionConfigurationInvalidException.class) + .isThrownBy(() -> underTest.validate(getConnectionWithSourceEnforcement( + ConnectivityModelFactory.newEnforcement( + "{{ header:device_id }}", + "{{ header:ditto }}") + ), + dittoHeaders, + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG)) + .withCauseInstanceOf(UnresolvedPlaceholderException.class) + .satisfies(exception -> assertThat(exception.getDittoHeaders()).isEqualTo(dittoHeaders)); + } + + @Test + public void validateWithValidSourceAddressesThrowsNoException() { + final var dittoHeaders = getDittoHeadersWithCorrelationId(); + Stream.of(HonoAddressAlias.values()) + .filter(honoAddressAlias -> HonoAddressAlias.COMMAND != honoAddressAlias) + .map(HonoAddressAlias::getAliasValue) + .forEach(honoAddressAliasValue -> softly.assertThatCode(() -> underTest.validate( + getConnectionWithSourceAddress(honoAddressAliasValue), + dittoHeaders, + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG + )) + .as(honoAddressAliasValue) + .doesNotThrowAnyException()); + } + + @Test + public void validateWithEmptySourceAddressThrowsException() { + final var dittoHeaders = getDittoHeadersWithCorrelationId(); + + assertThatExceptionOfType(ConnectionConfigurationInvalidException.class) + .isThrownBy(() -> underTest.validate(getConnectionWithSourceAddress(""), + dittoHeaders, + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG)) + .withMessage("The provided source address must not be empty.") + .withNoCause() + .satisfies(exception -> assertThat(exception.getDittoHeaders()).isEqualTo(dittoHeaders)); + } + + @Test + public void validateWithInvalidSourceAddressesThrowsException() { + final var dittoHeaders = getDittoHeadersWithCorrelationId(); + + Stream.of( + HonoAddressAlias.COMMAND.getAliasValue(), + "events/", + "hono.telemetry.c4bc9a62-8516-4232-bb81-dbbfe4d0fa8c_hub", + "ditto*a" + ).forEach( + invalidSourceAddress -> softly.assertThatThrownBy( + () -> underTest.validate(getConnectionWithSourceAddress(invalidSourceAddress), + dittoHeaders, + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG) + ) + .as(invalidSourceAddress) + .hasMessageStartingWith("The provided source address <%s> is invalid." + + " It should be one of the defined aliases: ", + invalidSourceAddress) + .hasMessageContainingAll(Stream.of(HonoAddressAlias.values()) + .filter(honoAddressAlias -> HonoAddressAlias.COMMAND !=honoAddressAlias) + .map(HonoAddressAlias::getAliasValue) + .toArray(CharSequence[]::new)) + .hasNoCause() + .isInstanceOfSatisfying(ConnectionConfigurationInvalidException.class, + exception -> assertThat(exception.getDittoHeaders()).isEqualTo(dittoHeaders)) + ); + } + + @Test + public void validateWithInvalidSourceQosThrowsException() { + final var invalidQos = 3; + final var dittoHeaders = getDittoHeadersWithCorrelationId(); + + assertThatExceptionOfType(ConnectionConfigurationInvalidException.class) + .isThrownBy(() -> underTest.validate(ConnectivityModelFactory.newConnectionBuilder(CONNECTION_ID, + ConnectionType.HONO, + ConnectivityStatus.OPEN, + "tcp://localhost:999999") + .sources(singletonList(ConnectivityModelFactory.newSourceBuilder() + .address("event") + .authorizationContext(AUTHORIZATION_CONTEXT) + .qos(invalidQos) + .build())) + .build(), + dittoHeaders, + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG)) + .withMessage("Invalid source 'qos' value <%d>. Supported values are <0> and <1>.", invalidQos) + .withNoCause() + .satisfies(exception -> assertThat(exception.getDittoHeaders()).isEqualTo(dittoHeaders)); + } + + @Test + public void validateWithValidTargetAddressThrowsNoException() { + assertThatCode( + () -> underTest.validate(getConnectionWithTargetAddress(HonoAddressAlias.COMMAND.getAliasValue()), + getDittoHeadersWithCorrelationId(), + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG) + ).doesNotThrowAnyException(); + } + + @Test + public void validateWithEmptyTargetAddressThrowsException() { + final var dittoHeaders = getDittoHeadersWithCorrelationId(); + + assertThatExceptionOfType(ConnectionConfigurationInvalidException.class) + .isThrownBy(() -> underTest.validate(getConnectionWithTargetAddress(""), + dittoHeaders, + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG)) + .withMessage("The provided target address must not be empty.") + .withNoCause() + .satisfies(exception -> assertThat(exception.getDittoHeaders()).isEqualTo(dittoHeaders)); + } + + @Test + public void validateWithInvalidTargetAddressesThrowsException() { + final var dittoHeaders = getDittoHeadersWithCorrelationId(); + + Stream.concat( + Stream.of(HonoAddressAlias.values()) + .filter(honoAddressAlias -> HonoAddressAlias.COMMAND != honoAddressAlias) + .map(HonoAddressAlias::getAliasValue), + Stream.of("hono.command.c4bc9a62-8516-4232-bb81-dbbfe4d0fa8c_hub/{{thing:id}}") + ).forEach( + invalidTargetAddress -> softly.assertThatThrownBy( + () -> underTest.validate(getConnectionWithTargetAddress(invalidTargetAddress), + dittoHeaders, + ACTOR_SYSTEM_RESOURCE.getActorSystem(), + CONNECTIVITY_CONFIG) + ) + .as(invalidTargetAddress) + .hasMessage("The provided target address <%s> is invalid. It should be <%s>.", + invalidTargetAddress, + HonoAddressAlias.COMMAND.getAliasValue()) + .hasNoCause() + .isInstanceOfSatisfying(ConnectionConfigurationInvalidException.class, + exception -> assertThat(exception.getDittoHeaders()).isEqualTo(dittoHeaders)) + ); + } + + private static Connection getConnectionWithSourceEnforcement(final Enforcement sourceEnforcement) { + return ConnectivityModelFactory.newConnectionBuilder(CONNECTION_ID, + ConnectionType.HONO, + ConnectivityStatus.OPEN, + "tcp://localhost:99999") + .sources(List.of(ConnectivityModelFactory.newSourceBuilder() + .address(HonoAddressAlias.TELEMETRY.getAliasValue()) + .authorizationContext(AUTHORIZATION_CONTEXT) + .enforcement(sourceEnforcement) + .qos(1) + .build())) + .build(); + } + + private static Connection getConnectionWithSourceAddress(final String sourceAddress) { + return ConnectivityModelFactory.newConnectionBuilder(CONNECTION_ID, + ConnectionType.HONO, + ConnectivityStatus.OPEN, + "tcp://localhost:99999") + .sources(List.of(ConnectivityModelFactory.newSourceBuilder() + .address(sourceAddress) + .authorizationContext(AUTHORIZATION_CONTEXT) + .qos(1) + .build())) + .build(); + } + + private static Connection getConnectionWithTargetAddress(final String targetAddress) { + return ConnectivityModelFactory.newConnectionBuilder(CONNECTION_ID, + ConnectionType.HONO, + ConnectivityStatus.OPEN, + "tcp://localhost:1883") + .targets(singletonList(ConnectivityModelFactory.newTargetBuilder() + .address(targetAddress) + .authorizationContext(AUTHORIZATION_CONTEXT) + .qos(1) + .topics(Topic.LIVE_EVENTS) + .build())) + .build(); + } + + private DittoHeaders getDittoHeadersWithCorrelationId() { + return DittoHeaders.newBuilder().correlationId(testNameCorrelationId.getCorrelationId()).build(); + } + +} + diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionPersistenceActorTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionPersistenceActorTest.java index 8fd458214d..3e19a92cc3 100644 --- a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionPersistenceActorTest.java +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectionPersistenceActorTest.java @@ -17,7 +17,10 @@ import static org.eclipse.ditto.connectivity.service.messaging.MockClientActorPropsFactory.mockClientActorProbe; import static org.eclipse.ditto.connectivity.service.messaging.TestConstants.INSTANT; +import java.io.IOException; +import java.io.InputStreamReader; import java.time.Instant; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -36,6 +39,7 @@ import org.eclipse.ditto.connectivity.model.ConnectionConfigurationInvalidException; import org.eclipse.ditto.connectivity.model.ConnectionId; import org.eclipse.ditto.connectivity.model.ConnectionIdInvalidException; +import org.eclipse.ditto.connectivity.model.ConnectionType; import org.eclipse.ditto.connectivity.model.ConnectivityModelFactory; import org.eclipse.ditto.connectivity.model.ConnectivityStatus; import org.eclipse.ditto.connectivity.model.RecoveryStatus; @@ -75,6 +79,7 @@ import org.eclipse.ditto.connectivity.service.messaging.MockCommandValidator; import org.eclipse.ditto.connectivity.service.messaging.TestConstants; import org.eclipse.ditto.connectivity.service.messaging.WithMockServers; +import org.eclipse.ditto.connectivity.service.messaging.hono.DefaultHonoConnectionFactoryTest; import org.eclipse.ditto.connectivity.service.util.ConnectionPubSub; import org.eclipse.ditto.internal.utils.akka.ActorSystemResource; import org.eclipse.ditto.internal.utils.akka.PingCommand; @@ -83,6 +88,7 @@ import org.eclipse.ditto.internal.utils.persistentactors.AbstractPersistenceSupervisor; import org.eclipse.ditto.internal.utils.test.Retry; import org.eclipse.ditto.internal.utils.tracing.DittoTracingInitResource; +import org.eclipse.ditto.json.JsonFactory; import org.eclipse.ditto.thingsearch.model.signals.commands.subscription.CreateSubscription; import org.junit.Before; import org.junit.ClassRule; @@ -118,11 +124,19 @@ public final class ConnectionPersistenceActorTest extends WithMockServers { "ditto.extensions.client-actor-props-factory.extension-class", MockClientActorPropsFactory.class.getName(), "ditto.connectivity.connection.allowed-hostnames", - ConfigValueFactory.fromAnyRef("127.0.0.1"), + ConfigValueFactory.fromAnyRef("127.0.0.1,hono-endpoint"), "ditto.connectivity.connection.blocked-hostnames", ConfigValueFactory.fromAnyRef("127.0.0.2"), "ditto.extensions.custom-connectivity-command-interceptor-provider", - MockCommandValidator.class.getName() + MockCommandValidator.class.getName(), + "ditto.extensions.hono-connection-factory", + ConfigValueFactory.fromAnyRef( + "org.eclipse.ditto.connectivity.service.messaging.hono.DefaultHonoConnectionFactory"), + "ditto.connectivity.hono.base-uri", ConfigValueFactory.fromAnyRef("tcp://localhost:9922"), + "ditto.connectivity.hono.validate-certificates", ConfigValueFactory.fromAnyRef("false"), + "ditto.connectivity.hono.sasl-mechanism", ConfigValueFactory.fromAnyRef("PLAIN"), + "ditto.connectivity.hono.bootstrap-servers", + ConfigValueFactory.fromAnyRef("tcp://server1:port1,tcp://server2:port2,tcp://server3:port3") )).withFallback(TestConstants.CONFIG)); @Rule @@ -179,7 +193,7 @@ public TestActor.AutoPilot run(final ActorRef sender, final Object msg) { public void testConnection() { //GIVEN final var connection = TestConstants.createConnection(connectionId); - var testConnection = TestConnection.of(connection, dittoHeadersWithCorrelationId); + final var testConnection = TestConnection.of(connection, dittoHeadersWithCorrelationId); final var testProbe = actorSystemResource1.newTestProbe(); final var connectionSupervisorActor = createSupervisor(); @@ -187,7 +201,7 @@ public void testConnection() { connectionSupervisorActor.tell(testConnection, testProbe.ref()); //THEN - var testConnectionWithDryRunHeader = TestConnection.of(connection, dittoHeadersWithCorrelationId + final var testConnectionWithDryRunHeader = TestConnection.of(connection, dittoHeadersWithCorrelationId .toBuilder() .dryRun(true) .build()); @@ -196,11 +210,84 @@ public void testConnection() { testProbe.expectMsg(TestConnectionResponse.success(connectionId, "mock", testConnection.getDittoHeaders())); } + @Test + public void testConnectionTypeHono() throws IOException { + //GIVEN + final var honoConnection = generateConnectionObjectFromJsonFile("hono-connection-custom-test.json"); + final var expectedHonoConnection = generateConnectionObjectFromJsonFile("hono-connection-custom-expected.json"); + final var testConnection = TestConnection.of(honoConnection, dittoHeadersWithCorrelationId); + final var testProbe = actorSystemResource1.newTestProbe(); + final var connectionSupervisorActor = createSupervisor(); + + //WHEN + connectionSupervisorActor.tell(testConnection, testProbe.ref()); + + //THEN + final var testConnectionWithDryRunHeader = TestConnection.of(expectedHonoConnection, + dittoHeadersWithCorrelationId + .toBuilder() + .dryRun(true) + .build()); + expectMockClientActorMessage(testConnectionWithDryRunHeader); + mockClientActorProbe.reply(new Status.Success("mock")); + testProbe.expectMsg( + TestConnectionResponse.success(honoConnection.getId(), "mock", testConnection.getDittoHeaders())); + } + + @Test + public void testRestartByConnectionType() throws IOException { + // GIVEN + final var honoConnection = generateConnectionObjectFromJsonFile("hono-connection-custom-test.json"); + mockClientActorProbe.setAutoPilot(new TestActor.AutoPilot() { + @Override + public TestActor.AutoPilot run(final ActorRef sender, final Object msg) { + if (msg instanceof WithSender withSender && withSender.getMessage() instanceof OpenConnection) { + sender.tell(new Status.Success("connected"), mockClientActorProbe.ref()); + } + return keepRunning(); + } + }); + final var testProbe = actorSystemResource1.newTestProbe(); + final var clientShardRegion = TestConstants.createClientActorShardRegion( + actorSystemResource1.getActorSystem(), connectionId.toString()); + final var connectionActorProps = Props.create(ConnectionPersistenceActor.class, + () -> new ConnectionPersistenceActor(connectionId, + commandForwarderActor, + pubSubMediatorProbe.ref(), + ConfigFactory.empty(), clientShardRegion)); + + final var underTest = actorSystemResource1.newActor(connectionActorProps, connectionId.toString()); + underTest.tell(createConnection(honoConnection), testProbe.ref()); + testProbe.expectMsgClass(CreateConnectionResponse.class); + + Arrays.stream(ConnectionType.values()).forEach(connectionType -> { + // WHEN + underTest.tell(new ConnectionSupervisorActor.RestartByConnectionType(connectionType), testProbe.ref()); + + // THEN + if (connectionType == honoConnection.getConnectionType()) { + testProbe.expectMsg(ConnectionSupervisorActor.RestartConnection.of(null)); + } else { + testProbe.expectNoMsg(); + } + }); + } + + private static Connection generateConnectionObjectFromJsonFile(final String fileName) throws IOException { + final var testClassLoader = DefaultHonoConnectionFactoryTest.class.getClassLoader(); + try (final var connectionJsonFileStreamReader = new InputStreamReader( + testClassLoader.getResourceAsStream(fileName) + )) { + return ConnectivityModelFactory.connectionFromJson( + JsonFactory.readFrom(connectionJsonFileStreamReader).asObject()); + } + } + @Test public void testConnectionCausingFailure() { //GIVEN final var connection = TestConstants.createConnection(connectionId); - var testConnection = TestConnection.of(connection, dittoHeadersWithCorrelationId); + final var testConnection = TestConnection.of(connection, dittoHeadersWithCorrelationId); final var testProbe = actorSystemResource1.newTestProbe(); final var connectionSupervisorActor = createSupervisor(); @@ -208,7 +295,7 @@ public void testConnectionCausingFailure() { connectionSupervisorActor.tell(testConnection, testProbe.ref()); //THEN - var testConnectionWithDryRunHeader = TestConnection.of(connection, dittoHeadersWithCorrelationId + final var testConnectionWithDryRunHeader = TestConnection.of(connection, dittoHeadersWithCorrelationId .toBuilder() .dryRun(true) .build()); @@ -224,7 +311,7 @@ public void testConnectionCausingFailure() { public void testConnectionCausingException() { //GIVEN final var connection = TestConstants.createConnection(connectionId); - var testConnection = TestConnection.of(connection, dittoHeadersWithCorrelationId); + final var testConnection = TestConnection.of(connection, dittoHeadersWithCorrelationId); final var testProbe = actorSystemResource1.newTestProbe(); final var connectionSupervisorActor = createSupervisor(); @@ -232,7 +319,7 @@ public void testConnectionCausingException() { connectionSupervisorActor.tell(testConnection, testProbe.ref()); //THEN - var testConnectionWithDryRunHeader = TestConnection.of(connection, dittoHeadersWithCorrelationId + final var testConnectionWithDryRunHeader = TestConnection.of(connection, dittoHeadersWithCorrelationId .toBuilder() .dryRun(true) .build()); diff --git a/connectivity/service/src/test/resources/hono-connection-custom-expected.json b/connectivity/service/src/test/resources/hono-connection-custom-expected.json new file mode 100644 index 0000000000..c55d5c5216 --- /dev/null +++ b/connectivity/service/src/test/resources/hono-connection-custom-expected.json @@ -0,0 +1,268 @@ +{ + "id": "test-connection-id", + "name": "Things-Hono Test 1", + "connectionType": "hono", + "connectionStatus": "open", + "uri": "tcp://test_username:test_password@localhost:9922", + "sources": [ + { + "addresses": [ + "hono.telemetry" + ], + "consumerCount": 1, + "qos": 0, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [], + "filter": "fn:delete()" + }, + "headerMapping": {}, + "payloadMapping": [ + "Ditto", + "status", + "implicitEdgeThingCreation", + "implicitStandaloneThingCreation" + ], + "replyTarget": { + "address": "hono.command/{{thing:id}}", + "headerMapping": { + "device_id": "custom_value1", + "user_key1": "user_value1", + "subject": "{{ header:subject | fn:default(topic:action-subject) | fn:default(topic:criterion) }}-response", + "correlation-id": "{{ header:correlation-id }}" + }, + "expectedResponseTypes": [ + "response", + "error" + ], + "enabled": true + } + }, + { + "addresses": [ + "hono.event" + ], + "consumerCount": 1, + "qos": 1, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [] + }, + "headerMapping": {}, + "payloadMapping": [ + "Ditto", + "status", + "implicitEdgeThingCreation", + "implicitStandaloneThingCreation" + ], + "replyTarget": { + "address": "hono.command/{{thing:id}}", + "headerMapping": { + "device_id": "{{ thing:id }}", + "subject": "custom_value2", + "user_key2": "user_value2", + "correlation-id": "{{ header:correlation-id }}" + }, + "expectedResponseTypes": [ + "response", + "error" + ], + "enabled": true + } + }, + { + "addresses": [ + "hono.command_response" + ], + "consumerCount": 1, + "qos": 0, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [], + "filter": "fn:delete()" + }, + "headerMapping": { + "status": "custom_value3", + "user_key3": "user_value3", + "correlation-id": "{{ header:correlation-id }}" + }, + "payloadMapping": [ + "Ditto" + ], + "replyTarget": { + "enabled": false + } + } + ], + "targets": [ + { + "address": "hono.command/{{thing:id}}", + "topics": [ + "_/_/things/live/messages", + "_/_/things/live/commands" + ], + "authorizationContext": [ + "nginx:ditto" + ], + "headerMapping": { + "user_key4": "user_value4", + "device_id": "{{ thing:id }}", + "response-required": "custom_value4", + "subject": "{{ header:subject | fn:default(topic:action-subject) }}", + "correlation-id": "{{ header:correlation-id }}" + } + }, + { + "address": "hono.command/{{thing:id}}", + "topics": [ + "_/_/things/twin/events", + "_/_/things/live/events" + ], + "authorizationContext": [ + "nginx:ditto" + ], + "headerMapping": { + "user_key5": "user_value5", + "device_id": "{{ thing:id }}", + "subject": "{{ header:subject | fn:default(topic:action-subject) }}", + "correlation-id": "custom_value5" + } + } + ], + "clientCount": 1, + "failoverEnabled": true, + "validateCertificates": false, + "processorPoolSize": 5, + "specificConfig": { + "saslMechanism": "plain", + "bootstrapServers": "tcp://server1:port1,tcp://server2:port2,tcp://server3:port3", + "groupId": "custom_groupId" + }, + "mappingDefinitions": { + "implicitEdgeThingCreation": { + "mappingEngine": "ImplicitThingCreation", + "options": { + "thing": { + "thingId": "{{ header:device_id }}", + "_copyPolicyFrom": "{{ header:gateway_id }}", + "attributes": { + "Info": { + "gatewayId": "{{ header:gateway_id }}" + } + } + }, + "commandHeaders": {} + }, + "incomingConditions": { + "behindGateway": "fn:filter(header:gateway_id, 'exists')", + "honoRegistration": "fn:filter(header:hono_registration_status, 'eq', 'NEW')" + } + }, + "implicitStandaloneThingCreation": { + "mappingEngine": "ImplicitThingCreation", + "options": { + "thing": { + "thingId": "{{ header:device_id }}", + "_policy": { + "entries": { + "DEVICE": { + "subjects": { + "integration:hono": { + "type": "hono-integration" + } + }, + "resources": { + "policy:/": { + "revoke": [], + "grant": [ + "READ" + ] + }, + "thing:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "message:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + } + } + }, + "DEFAULT": { + "subjects": { + "integration:hono": { + "type": "generated" + } + }, + "resources": { + "policy:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "thing:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "message:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + } + } + } + } + } + }, + "commandHeaders": {} + }, + "incomingConditions": { + "honoRegistration": "fn:filter(header:hono_registration_status, 'eq', 'NEW')", + "notBehindGateway": "fn:filter(header:gateway_id, 'exists', 'false')" + } + }, + "status": { + "mappingEngine": "ConnectionStatus", + "options": { + "thingId": "{{ header:device_id }}" + } + } + } +} \ No newline at end of file diff --git a/connectivity/service/src/test/resources/hono-connection-custom-test.json b/connectivity/service/src/test/resources/hono-connection-custom-test.json new file mode 100644 index 0000000000..1c3b288695 --- /dev/null +++ b/connectivity/service/src/test/resources/hono-connection-custom-test.json @@ -0,0 +1,256 @@ +{ + "id": "test-connection-id", + "name": "Things-Hono Test 1", + "connectionType": "hono", + "connectionStatus": "open", + "uri": "ssl://hono-endpoint:1", + "sources": [ + { + "addresses": [ + "telemetry" + ], + "consumerCount": 1, + "qos": 0, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [], + "filter": "fn:delete()" + }, + "headerMapping": {}, + "payloadMapping": [ + "Ditto", + "status", + "implicitEdgeThingCreation", + "implicitStandaloneThingCreation" + ], + "replyTarget": { + "address": "command", + "headerMapping": { + "user_key1": "user_value1", + "device_id": "custom_value1" + }, + "expectedResponseTypes": [ + "response", + "error" + ], + "enabled": true + } + }, + { + "addresses": [ + "event" + ], + "consumerCount": 1, + "qos": 1, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [] + }, + "headerMapping": {}, + "payloadMapping": [ + "Ditto", + "status", + "implicitEdgeThingCreation", + "implicitStandaloneThingCreation" + ], + "replyTarget": { + "address": "command", + "headerMapping": { + "user_key2": "user_value2", + "subject": "custom_value2" + }, + "expectedResponseTypes": [ + "response", + "error" + ], + "enabled": true + } + }, + { + "addresses": [ + "command_response" + ], + "consumerCount": 1, + "qos": 0, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [], + "filter": "fn:delete()" + }, + "headerMapping": { + "user_key3": "user_value3", + "status": "custom_value3" + }, + "payloadMapping": [ + "Ditto" + ], + "replyTarget": { + "enabled": false + } + } + ], + "targets": [ + { + "address": "command", + "topics": [ + "_/_/things/live/messages", + "_/_/things/live/commands" + ], + "authorizationContext": [ + "nginx:ditto" + ], + "headerMapping": { + "user_key4": "user_value4", + "response-required": "custom_value4" + } + }, + { + "address": "command", + "topics": [ + "_/_/things/twin/events", + "_/_/things/live/events" + ], + "authorizationContext": [ + "nginx:ditto" + ], + "headerMapping": { + "user_key5": "user_value5", + "correlation-id": "custom_value5" + } + } + ], + "clientCount": 1, + "failoverEnabled": true, + "validateCertificates": true, + "processorPoolSize": 5, + "specificConfig": { + "groupId": "custom_groupId" + }, + "mappingDefinitions": { + "implicitEdgeThingCreation": { + "mappingEngine": "ImplicitThingCreation", + "options": { + "thing": { + "thingId": "{{ header:device_id }}", + "_copyPolicyFrom": "{{ header:gateway_id }}", + "attributes": { + "Info": { + "gatewayId": "{{ header:gateway_id }}" + } + } + }, + "commandHeaders": {} + }, + "incomingConditions": { + "behindGateway": "fn:filter(header:gateway_id, 'exists')", + "honoRegistration": "fn:filter(header:hono_registration_status, 'eq', 'NEW')" + } + }, + "implicitStandaloneThingCreation": { + "mappingEngine": "ImplicitThingCreation", + "options": { + "thing": { + "thingId": "{{ header:device_id }}", + "_policy": { + "entries": { + "DEVICE": { + "subjects": { + "integration:hono": { + "type": "hono-integration" + } + }, + "resources": { + "policy:/": { + "revoke": [], + "grant": [ + "READ" + ] + }, + "thing:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "message:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + } + } + }, + "DEFAULT": { + "subjects": { + "integration:hono": { + "type": "generated" + } + }, + "resources": { + "policy:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "thing:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "message:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + } + } + } + } + } + }, + "commandHeaders": {} + }, + "incomingConditions": { + "honoRegistration": "fn:filter(header:hono_registration_status, 'eq', 'NEW')", + "notBehindGateway": "fn:filter(header:gateway_id, 'exists', 'false')" + } + }, + "status": { + "mappingEngine": "ConnectionStatus", + "options": { + "thingId": "{{ header:device_id }}" + } + } + } +} \ No newline at end of file diff --git a/connectivity/service/src/test/resources/hono-connection-default-test.json b/connectivity/service/src/test/resources/hono-connection-default-test.json new file mode 100644 index 0000000000..7fdb31de0f --- /dev/null +++ b/connectivity/service/src/test/resources/hono-connection-default-test.json @@ -0,0 +1,241 @@ +{ + "id": "test-connection-id", + "name": "Things-Hono Test 1", + "connectionType": "hono", + "connectionStatus": "open", + "uri": "ssl://hono-endpoint:1", + "sources": [ + { + "addresses": [ + "telemetry" + ], + "consumerCount": 1, + "qos": 0, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [], + "filter": "fn:delete()" + }, + "headerMapping": {}, + "payloadMapping": [ + "Ditto", + "status", + "implicitEdgeThingCreation", + "implicitStandaloneThingCreation" + ], + "replyTarget": { + "address": "command", + "headerMapping": { + }, + "expectedResponseTypes": [ + "response", + "error" + ], + "enabled": true + } + }, + { + "addresses": [ + "event" + ], + "consumerCount": 1, + "qos": 1, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [] + }, + "headerMapping": {}, + "payloadMapping": [ + "Ditto", + "status", + "implicitEdgeThingCreation", + "implicitStandaloneThingCreation" + ], + "replyTarget": { + "address": "command", + "headerMapping": { + }, + "expectedResponseTypes": [ + "response", + "error" + ], + "enabled": true + } + }, + { + "addresses": [ + "command_response" + ], + "consumerCount": 1, + "qos": 0, + "authorizationContext": [ + "nginx:ditto" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ entity:id }}" + ] + }, + "acknowledgementRequests": { + "includes": [], + "filter": "fn:delete()" + }, + "headerMapping": { + }, + "payloadMapping": [ + "Ditto" + ], + "replyTarget": { + "enabled": false + } + } + ], + "targets": [ + { + "address": "command", + "topics": [ + "_/_/things/live/messages", + "_/_/things/live/commands" + ], + "authorizationContext": [ + "nginx:ditto" + ] + }, + { + "address": "command", + "topics": [ + "_/_/things/twin/events", + "_/_/things/live/events" + ], + "authorizationContext": [ + "nginx:ditto" + ], + "headerMapping": { + } + } + ], + "clientCount": 1, + "failoverEnabled": true, + "validateCertificates": true, + "processorPoolSize": 5, + "mappingDefinitions": { + "implicitEdgeThingCreation": { + "mappingEngine": "ImplicitThingCreation", + "options": { + "thing": { + "thingId": "{{ header:device_id }}", + "_copyPolicyFrom": "{{ header:gateway_id }}", + "attributes": { + "Info": { + "gatewayId": "{{ header:gateway_id }}" + } + } + }, + "commandHeaders": {} + }, + "incomingConditions": { + "behindGateway": "fn:filter(header:gateway_id, 'exists')", + "honoRegistration": "fn:filter(header:hono_registration_status, 'eq', 'NEW')" + } + }, + "implicitStandaloneThingCreation": { + "mappingEngine": "ImplicitThingCreation", + "options": { + "thing": { + "thingId": "{{ header:device_id }}", + "_policy": { + "entries": { + "DEVICE": { + "subjects": { + "integration:hono": { + "type": "hono-integration" + } + }, + "resources": { + "policy:/": { + "revoke": [], + "grant": [ + "READ" + ] + }, + "thing:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "message:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + } + } + }, + "DEFAULT": { + "subjects": { + "integration:hono": { + "type": "generated" + } + }, + "resources": { + "policy:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "thing:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + }, + "message:/": { + "revoke": [], + "grant": [ + "READ", + "WRITE" + ] + } + } + } + } + } + }, + "commandHeaders": {} + }, + "incomingConditions": { + "honoRegistration": "fn:filter(header:hono_registration_status, 'eq', 'NEW')", + "notBehindGateway": "fn:filter(header:gateway_id, 'exists', 'false')" + } + }, + "status": { + "mappingEngine": "ConnectionStatus", + "options": { + "thingId": "{{ header:device_id }}" + } + } + } +} \ No newline at end of file diff --git a/connectivity/service/src/test/resources/test.conf b/connectivity/service/src/test/resources/test.conf index 08936d72cb..64241d8cd5 100644 --- a/connectivity/service/src/test/resources/test.conf +++ b/connectivity/service/src/test/resources/test.conf @@ -23,6 +23,7 @@ ditto { extensions { connection-priority-provider-factory = org.eclipse.ditto.connectivity.service.messaging.persistence.UsageBasedPriorityProviderFactory client-actor-props-factory = org.eclipse.ditto.connectivity.service.messaging.DefaultClientActorPropsFactory + hono-connection-factory = "org.eclipse.ditto.connectivity.service.messaging.hono.DefaultHonoConnectionFactory" message-mapper-extension = "org.eclipse.ditto.connectivity.service.mapping.NoOpMessageMapperExtension" signal-enrichment-provider { extension-class = org.eclipse.ditto.connectivity.service.mapping.DefaultConnectivitySignalEnrichmentProvider @@ -95,6 +96,16 @@ ditto { user-indicated-errors = [ {exceptionName: "org.apache.qpid.jms.provider.exceptions.ProviderSecurityException", messagePattern: ".*"} ] + + hono { + base-uri = "tcp://localhost:9922" + validate-certificates = false + sasl-mechanism = PLAIN + bootstrap-servers = "tcp://server1:port1,tcp://server2:port2,tcp://server3:port3" + username = test_username + password = test_password + } + connection { // allow localhost in unit tests blocked-hostnames = "" diff --git a/documentation/src/main/resources/_data/sidebars/ditto_sidebar.yml b/documentation/src/main/resources/_data/sidebars/ditto_sidebar.yml index b27b135c5b..1b3d92b3ca 100644 --- a/documentation/src/main/resources/_data/sidebars/ditto_sidebar.yml +++ b/documentation/src/main/resources/_data/sidebars/ditto_sidebar.yml @@ -361,6 +361,9 @@ entries: - title: Kafka 2.x protocol binding url: /connectivity-protocol-bindings-kafka2.html output: web + - title: Hono connection binding + url: /connectivity-protocol-bindings-hono.html + output: web - title: Payload mapping url: /connectivity-mapping.html output: web diff --git a/documentation/src/main/resources/jsonschema/connection.json b/documentation/src/main/resources/jsonschema/connection.json index 0ae8d3f590..c0c1be174d 100644 --- a/documentation/src/main/resources/jsonschema/connection.json +++ b/documentation/src/main/resources/jsonschema/connection.json @@ -30,6 +30,7 @@ "mqtt", "mqtt-5", "kafka", + "hono", "http-push" ], "title": "Connection type", diff --git a/documentation/src/main/resources/openapi/ditto-api-2.yml b/documentation/src/main/resources/openapi/ditto-api-2.yml index fd7ab510d8..330256ca8b 100644 --- a/documentation/src/main/resources/openapi/ditto-api-2.yml +++ b/documentation/src/main/resources/openapi/ditto-api-2.yml @@ -6493,7 +6493,7 @@ paths: Creates the connection defined in the JSON body. The ID of the connection will be **generated** by the backend. Any `ID` specified in the request body is therefore prohibited. - Supported connection types are `amqp-091`, `amqp-10`, `mqtt`, `mqtt-5`, `kafka` and `http-push`. + Supported connection types are `amqp-091`, `amqp-10`, `mqtt`, `mqtt-5`, `kafka`, `hono` and `http-push`. security: - DevOpsBasic: [] tags: @@ -8502,6 +8502,7 @@ components: - mqtt - mqtt-5 - kafka + - hono ConnectivityStatus: type: string description: The status of a connection or resource diff --git a/documentation/src/main/resources/openapi/sources/paths/connections/connectionId.yml b/documentation/src/main/resources/openapi/sources/paths/connections/connectionId.yml index 28b30655de..41c1656623 100644 --- a/documentation/src/main/resources/openapi/sources/paths/connections/connectionId.yml +++ b/documentation/src/main/resources/openapi/sources/paths/connections/connectionId.yml @@ -100,13 +100,13 @@ put: $ref: '../../schemas/connections/newConnection.yml' example: { "name": "hono-example-connection-123", - "connectionType": "amqp-10", + "connectionType": "hono", "connectionStatus": "open", - "uri": "amqps://user:password@hono.eclipseprojects.io:5671", "sources": [ { "addresses": [ - "telemetry/FOO", + "telemetry", + "event", "..." ], "authorizationContext": [ @@ -128,7 +128,7 @@ put: ], "targets": [ { - "address": "events/twin", + "address": "command", "topics": [ "_/_/things/twin/events" ], @@ -136,12 +136,8 @@ put: "ditto:outbound-auth-subject", "..." ], - "headerMapping": { - "message-id": "{{ header:correlation-id }}", - "content-type": "application/vnd.eclipse.ditto+json" - } + "headerMapping": { } } - ] } required: true diff --git a/documentation/src/main/resources/openapi/sources/paths/connections/connections.yml b/documentation/src/main/resources/openapi/sources/paths/connections/connections.yml index dbe1332752..dfc2b5372a 100644 --- a/documentation/src/main/resources/openapi/sources/paths/connections/connections.yml +++ b/documentation/src/main/resources/openapi/sources/paths/connections/connections.yml @@ -65,7 +65,7 @@ post: Creates the connection defined in the JSON body. The ID of the connection will be **generated** by the backend. Any `ID` specified in the request body is therefore prohibited. - Supported connection types are `amqp-091`, `amqp-10`, `mqtt`, `mqtt-5`, `kafka` and `http-push`. + Supported connection types are `amqp-091`, `amqp-10`, `mqtt`, `mqtt-5`, `kafka`, `hono` and `http-push`. security: - DevOpsBasic: [ ] tags: @@ -129,50 +129,46 @@ post: application/json: schema: $ref: '../../schemas/connections/newConnection.yml' - example: { + example: { "name": "hono-example-connection-123", - "connectionType": "amqp-10", + "connectionType": "hono", "connectionStatus": "open", - "uri": "amqps://user:password@hono.eclipseprojects.io:5671", "sources": [ { "addresses": [ - "telemetry/FOO", - "..." + "telemetry", + "event", + "..." ], "authorizationContext": [ - "ditto:inbound-auth-subject", - "..." + "ditto:inbound-auth-subject", + "..." ], "consumerCount": 1, "enforcement": { "input": "{{ header:device_id }}", "filters": [ - "{{ thing:id }}" + "{{ thing:id }}" ] }, "payloadMapping": [ - "Ditto", - "..." + "Ditto", + "status" ] } ], "targets": [ { - "address": "events/twin", + "address": "command", "topics": [ - "_/_/things/twin/events" + "_/_/things/twin/events" ], "authorizationContext": [ - "ditto:outbound-auth-subject", - "..." + "ditto:outbound-auth-subject", + "..." ], - "headerMapping": { - "message-id": "{{ header:correlation-id }}", - "content-type": "application/vnd.eclipse.ditto+json" - } + "headerMapping": { } } - ] } description: |- diff --git a/documentation/src/main/resources/openapi/sources/schemas/connections/connectionType.yml b/documentation/src/main/resources/openapi/sources/schemas/connections/connectionType.yml index 43b8a43de2..5e6dc53be5 100644 --- a/documentation/src/main/resources/openapi/sources/schemas/connections/connectionType.yml +++ b/documentation/src/main/resources/openapi/sources/schemas/connections/connectionType.yml @@ -16,4 +16,5 @@ enum: - http-push - mqtt - mqtt-5 - - kafka \ No newline at end of file + - kafka, + - hono \ No newline at end of file diff --git a/documentation/src/main/resources/openapi/sources/schemas/connections/source.yml b/documentation/src/main/resources/openapi/sources/schemas/connections/source.yml index 48daaf5b07..feb78becf8 100644 --- a/documentation/src/main/resources/openapi/sources/schemas/connections/source.yml +++ b/documentation/src/main/resources/openapi/sources/schemas/connections/source.yml @@ -16,7 +16,8 @@ properties: type: array uniqueItems: true title: Array of source addresses - description: The source addresses this connection consumes messages from + description: The source addresses this connection consumes messages from. The "telemetry", "events", + "command_response" aliases should be used for connections of type "hono". items: type: string title: Source address @@ -146,6 +147,7 @@ properties: * Ditto protocol header value: `{{ header:[any-header-name] }}` If placeholder resolution fails for a response, then the response is dropped. + NOTE Use "command" alias for connections of type "hono". example: - "{{ header:device_id }}" - "{{ source:address }}" diff --git a/documentation/src/main/resources/openapi/sources/schemas/connections/target.yml b/documentation/src/main/resources/openapi/sources/schemas/connections/target.yml index 61144bcd0a..152b759351 100644 --- a/documentation/src/main/resources/openapi/sources/schemas/connections/target.yml +++ b/documentation/src/main/resources/openapi/sources/schemas/connections/target.yml @@ -24,6 +24,7 @@ properties: * Thing Namespace: `{{ thing:namespace }}` * Thing Name: `{{ thing:name }}` (the part of the ID without the namespace) + NOTE Use "command" alias for connections of type "hono". topics: type: array title: Topics diff --git a/documentation/src/main/resources/pages/ditto/connectivity-protocol-bindings-hono.md b/documentation/src/main/resources/pages/ditto/connectivity-protocol-bindings-hono.md new file mode 100644 index 0000000000..c6fd325131 --- /dev/null +++ b/documentation/src/main/resources/pages/ditto/connectivity-protocol-bindings-hono.md @@ -0,0 +1,207 @@ +--- +title: Eclipse Hono binding +keywords: binding, protocol, hono, kafka, kafka2 +tags: [connectivity] +permalink: connectivity-protocol-bindings-hono.html +--- + +Consume messages from Eclipse Hono through Apache Kafka brokers and send messages to +Eclipse Hono the same manner as [Kafka connection](connectivity-protocol-bindings-kafka2.html) does. + +This connection type is created just for convenience - to avoid the need the user to be aware of the specific +header mappings, address formats and Kafka specificConfig, which are required to connect to Eclipse Hono. +These specifics are applied automatically at runtime for the connections of type Hono. + +Hono connection is based on Kafka connection and uses it behind the scenes, so most of the +[Kafka connection documentation](connectivity-protocol-bindings-kafka2.html) is valid for Hono connection too, +but with the following specifics (exceptions): + +## Specific Hono connection configuration + +### Connection URI +In Hono connection definition, property `uri` should not be specified (any specified value will be ignored). +The connection URI and credentials are common for all Hono connections and are derived from the configuration of the connectivity service. +`uri` will be automatically generated, based on values of 3 configuration properties of connectivity service - +`ditto.connectivity.hono.base-uri`, `ditto.connectivity.hono.username` and `ditto.connectivity.hono.password`. +Property `base-uri` must specify protocol, host and port number +(see the [example below](#configuration-example)). +In order to connect to Kafka brokers, at runtime `username` and `password` values will be inserted between +protocol identifier and the host name of `base-uri` to form the connection URI like this `tcp://username:password@host.name:port` + +Note: If any of these parameters has to be changed, the service must be restarted to apply the new values. + + +### Source format +#### Source addresses +For a Hono connection source "addresses" are specified as aliases, which are resolved at runtime to Kafka topics to subscribe to. +Valid source addresses (aliases) are `event`, `telemetry` and `command_response`. +Runtime, these are resolved as following: +* `event` -> `hono.event` +* `telemetry` -> `hono.telemetry` +* `command_response` -> `hono.command_response` + +#### Source reply target +Similar to source addresses, the reply target `address` is an alias as well. The single valid value for it is `command`. +It is resolved to Kafka topic/key like this: +* `command` -> `hono.command/` (<thingId> is substituted by thing ID value). + +The needed header mappings for the `replyTarget` are also populated automatically at runtime and there is +no need to specify them in the connection definition. Any of the following specified value will be substituted (i.e. ignored). +Actually the `headerMapping` subsection is not required and could be omitted at all (in the context of `replyTarget`). + +For addresses `telemetry` and `event`, the following header mappings will be automatically applied: +* `device_id`: `{%raw%}{{ thing:id }}{%endraw%}` +* `subject`: `{%raw%}{{ header:subject \| fn:default(topic:action-subject) \| fn:default(topic:criterion) }}{%endraw%}-response` +* `correlation-id`: `{%raw%}{{ header:correlation-id }}{%endraw%}` + +For address `command_response`, the following header mappings will be automatically applied: +* `correlation-id`: `{%raw%}{{ header:correlation-id }}{%endraw%}` +* `status`: `{%raw%}{{ header:status }}{%endraw%}` + +Note: Any other header mappings defined manually will be merged with the auto-generated ones. + +The following example shows a valid Hono-connection source: +```json +{ + "addresses": ["event"], + "consumerCount": 1, + "qos": 1, + "authorizationContext": ["ditto:inbound-auth-subject"], + "enforcement": { + "input": "{%raw%}{{ header:device_id }}{%endraw%}", + "filters": ["{%raw%}{{ entity:id }}{%endraw%}"] + }, + "headerMapping": {}, + "payloadMapping": ["Ditto"], + "replyTarget": { + "enabled": true, + "address": "command", + "expectedResponseTypes": ["response", "error", "nack"] + }, + "acknowledgementRequests": { + "includes": [] + }, + "declaredAcks": [] +} +``` +#### Source header mapping + +Hono connection does not need any header mapping for sources. Anyway, the header mappings documented for +[Kafka connection](connectivity-protocol-bindings-kafka2.html) are still available. +See [Source header mapping](connectivity-protocol-bindings-kafka2.html#source-header-mapping) in Kafka protocol bindings +and [Header mapping for connections](connectivity-header-mapping.html). + +### Target format +#### Target address +The target `address` is specified as an alias and the only valid alias is `command`. +It is automatically resolved at runtime to the following Kafka topic/key: +* `command` -> `hono.command/` (<thingId> is substituted by thing ID value). + +#### Target header mapping +The target `headerMapping` section is also populated automatically at runtime and there is +no need to specify it the connection definitionm i.e. could be omitted. +If any of the following keys are specified in the connection will be ignored and automatically substituted as follows: +* `device_id`: `{%raw%}{{ thing:id }}{%endraw%}` +* `subject`: `{%raw%}{{ header:subject \| fn:default(topic:action-subject) }}{%endraw%}` +* `response-required`: `{%raw%}{{ header:response-required }}{%endraw%}` +* `correlation-id`: `{%raw%}{{ header:correlation-id }}{%endraw%}` + +Note: Any other header mappings defined manually will be merged with the auto-generated ones. + +The following example shows a valid Hono-connection target: +```json +{ + "address": "command", + "topics": [ + "_/_/things/twin/events", + "_/_/things/live/messages" + ], + "authorizationContext": ["ditto:outbound-auth-subject"] +} +``` + +### Specific configuration properties + +The properties needed by Kafka server in section `specificConfig` with the following keys will be automatically added at runtime to the connection. +Any manually specified definition of `bootstrapServers` and `saslMechanism` will be ignored, but `groupId` will not. +* `bootstrapServers` The value will be taken from configuration property `ditto.connectivity.hono.bootstrap-servers` of connectivity service. +It must contain a comma separated list of Kafka bootstrap servers to use for connecting to (in addition to automatically added connection uri). +* `saslMechanism` The value will be taken from configuration property `ditto.connectivity.hono.sasl-mechanism`. +The value must be one of `SaslMechanism` enum values to select the SASL mechanisms to use for authentication at Kafka: + * `PLAIN` + * `SCRAM-SHA-256` + * `SCRAM-SHA-512` +* `groupId`: could be specified by the user, but not required. If omitted, the value of the connection ID will be automatically used. + +Hono connection still allows to manually specify additional properties (like `debugEnabled`), which will be merged with auto-generated ones. +If no additional properties are needed, the whole section `specificConfig` could be omitted. + +### Certificate validation +The connection property `validateCertificates` is also set automatically. The value is taken from `ditto.connectivity.hono.validate-certificates` property. +For more details see [Connection configuration](connectivity-tls-certificates.html). + +## Examples +### Example of Hono connection +```json +{ + "connection": { + "id": "hono-example-connection-123", + "connectionType": "hono", + "connectionStatus": "open", + "failoverEnabled": true, + "sources": [ + { + "addresses": ["event"], + "consumerCount": 1, + "qos": 1, + "authorizationContext": ["ditto:inbound-auth-subject"], + "enforcement": { + "input": "{%raw%}{{ header:device_id }}{%endraw%}", + "filters": ["{%raw%}{{ entity:id }}{%endraw%}"] + }, + "headerMapping": {}, + "payloadMapping": ["Ditto"], + "replyTarget": { + "enabled": true, + "address": "command", + "expectedResponseTypes": ["response", "error", "nack"] + }, + "acknowledgementRequests": { + "includes": [] + }, + "declaredAcks": [] + } + ], + "targets": [ + { + "address": "command", + "topics": [ + "_/_/things/twin/events", + "_/_/things/live/messages" + ], + "authorizationContext": ["ditto:outbound-auth-subject"] + } + ] + } +} +``` +### Configuration example +Here is an example with all the configurations of connectivity, service which are needed by Hono connections: +``` +ditto { + connection { + hono { + base-uri = "tcp://localhost:9092" + username = "honoUsername" + password = "honoPassword" + sasl-mechanism = "PLAIN" + bootstrap-servers = "localhost:9092" + validate-certificates = false + } + } +} +``` +## Troubleshooting Hono connection configuration +To help the troubleshooting, a separate Piggyback command `retrieveHonoConnection` is implemented. +It is valid only for Hono connections. It returns the "real" Hono connection after all its properties being resolved or auto-generated. +The returned value could be used for inspection, but not for example to create a new Hono connection using it. diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/connections/ConnectionsRoute.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/connections/ConnectionsRoute.java index e6878d25b1..631d66c73a 100644 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/connections/ConnectionsRoute.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/connections/ConnectionsRoute.java @@ -313,7 +313,7 @@ private static Connection buildConnectionForPost(final String connectionJson) { } final JsonObjectBuilder jsonObjectBuilder = connectionJsonObject.toBuilder(); - jsonObjectBuilder.set(Connection.JsonFields.ID, UUID.randomUUID().toString()); + jsonObjectBuilder.set(Connection.JsonFields.ID, ConnectionId.generateRandom().toString()); final String connectionStatus = connectionJsonObject.getValue(Connection.JsonFields.CONNECTION_STATUS) .orElse(ConnectivityStatus.UNKNOWN.getName()); jsonObjectBuilder.set(Connection.JsonFields.CONNECTION_STATUS, connectionStatus); diff --git a/internal/utils/config/src/main/java/org/eclipse/ditto/internal/utils/config/ConfigWithFallback.java b/internal/utils/config/src/main/java/org/eclipse/ditto/internal/utils/config/ConfigWithFallback.java index 161fe23ef0..03b2651133 100644 --- a/internal/utils/config/src/main/java/org/eclipse/ditto/internal/utils/config/ConfigWithFallback.java +++ b/internal/utils/config/src/main/java/org/eclipse/ditto/internal/utils/config/ConfigWithFallback.java @@ -142,6 +142,10 @@ private static Config arrayToConfig(final KnownConfigValue[] knownConfigValues) return ConfigFactory.parseMap(fallbackValues); } + private static Map getJsonObjectAsMap(final JsonObject jsonObject) { + return jsonObject.stream().collect(Collectors.toMap(JsonField::getKeyName, JsonField::getValue)); + } + @Override public ConfigObject root() { return baseConfig.root(); diff --git a/internal/utils/config/src/main/java/org/eclipse/ditto/internal/utils/config/DefaultScopedConfig.java b/internal/utils/config/src/main/java/org/eclipse/ditto/internal/utils/config/DefaultScopedConfig.java index 495e9dfca5..a4a74cac7a 100644 --- a/internal/utils/config/src/main/java/org/eclipse/ditto/internal/utils/config/DefaultScopedConfig.java +++ b/internal/utils/config/src/main/java/org/eclipse/ditto/internal/utils/config/DefaultScopedConfig.java @@ -316,7 +316,7 @@ public String getString(final String path) { public > T getEnum(final Class enumClass, final String path) { try { return config.getEnum(enumClass, path); - } catch (final ConfigException.Missing | ConfigException.WrongType e) { + } catch (final ConfigException.Missing | ConfigException.WrongType | ConfigException.BadValue e) { final var msgPattern = "Failed to get Enum for path <{0}>!"; throw new DittoConfigError(MessageFormat.format(msgPattern, appendToConfigPath(path)), e); } diff --git a/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/AbstractPersistenceSupervisor.java b/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/AbstractPersistenceSupervisor.java index 8f1a7ca86a..906df54340 100644 --- a/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/AbstractPersistenceSupervisor.java +++ b/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/AbstractPersistenceSupervisor.java @@ -529,6 +529,7 @@ private void ensureEnforcerActorBeingStarted() { protected void restartChild() { if (persistenceActorChild != null) { + log.debug("Restarting persistence child actor."); waitingForStopBeforeRestart = true; getContext().stop(persistenceActorChild); // start happens when "Terminated" message is received. } diff --git a/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/commands/CommandStrategy.java b/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/commands/CommandStrategy.java index 8df5a8bfa6..fd815acb7e 100644 --- a/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/commands/CommandStrategy.java +++ b/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/commands/CommandStrategy.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 Contributors to the Eclipse Foundation + * Copyright (c) 2022 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -16,10 +16,12 @@ import javax.annotation.Nullable; -import org.eclipse.ditto.internal.utils.akka.logging.DittoDiagnosticLoggingAdapter; -import org.eclipse.ditto.internal.utils.persistentactors.results.Result; import org.eclipse.ditto.base.model.signals.commands.Command; import org.eclipse.ditto.base.model.signals.events.Event; +import org.eclipse.ditto.internal.utils.akka.logging.DittoDiagnosticLoggingAdapter; +import org.eclipse.ditto.internal.utils.persistentactors.results.Result; + +import akka.actor.ActorSystem; /** * The CommandStrategy interface. @@ -108,6 +110,11 @@ interface Context { */ DittoDiagnosticLoggingAdapter getLog(); + /** + * @return reference to actorSystem + */ + ActorSystem getActorSystem(); + } } diff --git a/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/commands/DefaultContext.java b/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/commands/DefaultContext.java index e6e64f7a15..9428a3228a 100644 --- a/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/commands/DefaultContext.java +++ b/internal/utils/persistent-actors/src/main/java/org/eclipse/ditto/internal/utils/persistentactors/commands/DefaultContext.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 Contributors to the Eclipse Foundation + * Copyright (c) 2022 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -20,6 +20,8 @@ import org.eclipse.ditto.internal.utils.akka.logging.DittoDiagnosticLoggingAdapter; +import akka.actor.ActorSystem; + /** * Holds the context required to execute the * {@link CommandStrategy}s. @@ -32,10 +34,13 @@ public final class DefaultContext implements CommandStrategy.Context { private final K state; private final DittoDiagnosticLoggingAdapter log; - private DefaultContext(final K state, final DittoDiagnosticLoggingAdapter log) { + private final ActorSystem actorSystem; + + private DefaultContext(final K state, final DittoDiagnosticLoggingAdapter log, final ActorSystem actorSystem) { this.state = checkNotNull(state, "state"); this.log = checkNotNull(log, "log"); + this.actorSystem = checkNotNull(actorSystem, "actorSystem"); } /** @@ -46,8 +51,9 @@ private DefaultContext(final K state, final DittoDiagnosticLoggingAdapter log) { * @return the instance. * @throws NullPointerException if any argument is {@code null}. */ - public static DefaultContext getInstance(final K state, final DittoDiagnosticLoggingAdapter log) { - return new DefaultContext<>(state, log); + public static DefaultContext getInstance(final K state, final DittoDiagnosticLoggingAdapter log, + final ActorSystem actorSystem) { + return new DefaultContext<>(state, log, actorSystem); } @Override @@ -60,6 +66,11 @@ public DittoDiagnosticLoggingAdapter getLog() { return log; } + @Override + public ActorSystem getActorSystem() { + return actorSystem; + } + @Override public boolean equals(final Object o) { if (this == o) { @@ -69,12 +80,14 @@ public boolean equals(final Object o) { return false; } final DefaultContext that = (DefaultContext) o; - return Objects.equals(state, that.state) && Objects.equals(log, that.log); + return Objects.equals(state, that.state) + && Objects.equals(log, that.log) + && Objects.equals(actorSystem, that.actorSystem); } @Override public int hashCode() { - return Objects.hash(state, log); + return Objects.hash(state, log, actorSystem); } @Override @@ -82,6 +95,7 @@ public String toString() { return getClass().getSimpleName() + " [" + "state=" + state + ", log=" + log + + ", actorSystem=" + actorSystem + "]"; } diff --git a/policies/service/pom.xml b/policies/service/pom.xml index e865f9dc6e..812398fd46 100644 --- a/policies/service/pom.xml +++ b/policies/service/pom.xml @@ -122,6 +122,12 @@ akka-testkit_${scala.version} test + + org.eclipse.ditto + ditto-internal-utils-akka + test-jar + test + org.eclipse.ditto ditto-internal-utils-test diff --git a/policies/service/src/main/java/org/eclipse/ditto/policies/service/persistence/actors/PolicyPersistenceActor.java b/policies/service/src/main/java/org/eclipse/ditto/policies/service/persistence/actors/PolicyPersistenceActor.java index 7decc3f14d..a64f0c3bb5 100755 --- a/policies/service/src/main/java/org/eclipse/ditto/policies/service/persistence/actors/PolicyPersistenceActor.java +++ b/policies/service/src/main/java/org/eclipse/ditto/policies/service/persistence/actors/PolicyPersistenceActor.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 Contributors to the Eclipse Foundation + * Copyright (c) 2022 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -151,7 +151,7 @@ protected Class getEventClass() { @Override protected CommandStrategy.Context getStrategyContext() { - return DefaultContext.getInstance(entityId, log); + return DefaultContext.getInstance(entityId, log, getContext().getSystem()); } @Override diff --git a/policies/service/src/test/java/org/eclipse/ditto/policies/service/persistence/actors/strategies/commands/AbstractPolicyCommandStrategyTest.java b/policies/service/src/test/java/org/eclipse/ditto/policies/service/persistence/actors/strategies/commands/AbstractPolicyCommandStrategyTest.java index 9a3c62d203..a7beb40187 100644 --- a/policies/service/src/test/java/org/eclipse/ditto/policies/service/persistence/actors/strategies/commands/AbstractPolicyCommandStrategyTest.java +++ b/policies/service/src/test/java/org/eclipse/ditto/policies/service/persistence/actors/strategies/commands/AbstractPolicyCommandStrategyTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Contributors to the Eclipse Foundation + * Copyright (c) 2022 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -31,20 +31,22 @@ import org.eclipse.ditto.base.model.headers.DittoHeaders; import org.eclipse.ditto.base.model.headers.WithDittoHeaders; import org.eclipse.ditto.base.model.headers.entitytag.EntityTag; -import org.eclipse.ditto.policies.model.Policy; -import org.eclipse.ditto.policies.model.PolicyId; -import org.eclipse.ditto.policies.service.persistence.TestConstants; +import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.base.model.signals.commands.CommandResponse; +import org.eclipse.ditto.base.model.signals.events.Event; +import org.eclipse.ditto.internal.utils.akka.ActorSystemResource; import org.eclipse.ditto.internal.utils.akka.logging.DittoDiagnosticLoggingAdapter; import org.eclipse.ditto.internal.utils.persistentactors.commands.CommandStrategy; import org.eclipse.ditto.internal.utils.persistentactors.commands.DefaultContext; import org.eclipse.ditto.internal.utils.persistentactors.results.Result; import org.eclipse.ditto.internal.utils.persistentactors.results.ResultVisitor; -import org.eclipse.ditto.base.model.signals.commands.Command; -import org.eclipse.ditto.base.model.signals.commands.CommandResponse; +import org.eclipse.ditto.policies.model.Policy; +import org.eclipse.ditto.policies.model.PolicyId; import org.eclipse.ditto.policies.model.signals.commands.PolicyCommandResponse; -import org.eclipse.ditto.base.model.signals.events.Event; import org.eclipse.ditto.policies.model.signals.events.PolicyEvent; +import org.eclipse.ditto.policies.service.persistence.TestConstants; import org.junit.BeforeClass; +import org.junit.ClassRule; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; @@ -57,6 +59,9 @@ public abstract class AbstractPolicyCommandStrategyTest { protected static DittoDiagnosticLoggingAdapter logger; + @ClassRule + public static final ActorSystemResource ACTOR_SYSTEM_RESOURCE = ActorSystemResource.newInstance(); + @BeforeClass public static void initTestConstants() { logger = Mockito.mock(DittoDiagnosticLoggingAdapter.class); @@ -66,7 +71,7 @@ public static void initTestConstants() { } protected static CommandStrategy.Context getDefaultContext() { - return DefaultContext.getInstance(TestConstants.Policy.POLICY_ID, logger); + return DefaultContext.getInstance(TestConstants.Policy.POLICY_ID, logger, ACTOR_SYSTEM_RESOURCE.getActorSystem()); } protected static DittoHeaders buildActivateTokenIntegrationHeaders() { diff --git a/policies/service/src/test/java/org/eclipse/ditto/policies/service/persistence/actors/strategies/commands/PolicyConflictStrategyTest.java b/policies/service/src/test/java/org/eclipse/ditto/policies/service/persistence/actors/strategies/commands/PolicyConflictStrategyTest.java index d6d39e6a8e..42c38c7e74 100644 --- a/policies/service/src/test/java/org/eclipse/ditto/policies/service/persistence/actors/strategies/commands/PolicyConflictStrategyTest.java +++ b/policies/service/src/test/java/org/eclipse/ditto/policies/service/persistence/actors/strategies/commands/PolicyConflictStrategyTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Contributors to the Eclipse Foundation + * Copyright (c) 2022 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -23,6 +23,7 @@ import org.eclipse.ditto.base.model.headers.WithDittoHeaders; import org.eclipse.ditto.base.model.headers.entitytag.EntityTagMatchers; import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.internal.utils.akka.ActorSystemResource; import org.eclipse.ditto.internal.utils.akka.logging.DittoDiagnosticLoggingAdapter; import org.eclipse.ditto.internal.utils.persistentactors.commands.CommandStrategy; import org.eclipse.ditto.internal.utils.persistentactors.commands.DefaultContext; @@ -36,6 +37,7 @@ import org.eclipse.ditto.policies.model.signals.commands.modify.CreatePolicy; import org.eclipse.ditto.policies.model.signals.events.PolicyEvent; import org.eclipse.ditto.policies.service.common.config.DefaultPolicyConfig; +import org.junit.ClassRule; import org.junit.Test; import org.mockito.Mockito; @@ -47,6 +49,9 @@ @SuppressWarnings({"rawtypes", "java:S3740"}) public final class PolicyConflictStrategyTest { + @ClassRule + public static final ActorSystemResource ACTOR_SYSTEM_RESOURCE = ActorSystemResource.newInstance(); + @Test public void assertImmutability() { assertInstancesOf(PolicyConflictStrategy.class, areImmutable()); @@ -59,7 +64,7 @@ public void createConflictResultWithoutPrecondition() { final PolicyId policyId = PolicyId.of("policy:id"); final Policy policy = PoliciesModelFactory.newPolicyBuilder(policyId).setRevision(25L).build(); final CommandStrategy.Context context = DefaultContext.getInstance(policyId, - mockLoggingAdapter()); + mockLoggingAdapter(), ACTOR_SYSTEM_RESOURCE.getActorSystem()); final CreatePolicy command = CreatePolicy.of(policy, DittoHeaders.empty()); final Result> result = underTest.apply(context, policy, 26L, command); result.accept(new ExpectErrorVisitor(PolicyConflictException.class)); @@ -72,7 +77,7 @@ public void createPreconditionFailedResultWithPrecondition() { final PolicyId policyId = PolicyId.of("policy:id"); final Policy policy = PoliciesModelFactory.newPolicyBuilder(policyId).setRevision(25L).build(); final CommandStrategy.Context context = DefaultContext.getInstance(policyId, - mockLoggingAdapter()); + mockLoggingAdapter(), ACTOR_SYSTEM_RESOURCE.getActorSystem()); final CreatePolicy command = CreatePolicy.of(policy, DittoHeaders.newBuilder() .ifNoneMatch(EntityTagMatchers.fromStrings("*")) .build()); diff --git a/things/service/pom.xml b/things/service/pom.xml index 5a31c142fa..c0c507bcf1 100644 --- a/things/service/pom.xml +++ b/things/service/pom.xml @@ -180,6 +180,12 @@ test + + org.eclipse.ditto + ditto-internal-utils-akka + test + test-jar + org.eclipse.ditto ditto-internal-utils-test diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActor.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActor.java index 408ca635d4..9e682bc0b8 100755 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActor.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActor.java @@ -146,7 +146,7 @@ protected Class getEventClass() { @Override protected CommandStrategy.Context getStrategyContext() { - return DefaultContext.getInstance(entityId, log); + return DefaultContext.getInstance(entityId, log, getContext().getSystem()); } @Override diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java index f86e3535a9..2e27c326e9 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 Contributors to the Eclipse Foundation + * Copyright (c) 2022 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -30,6 +30,7 @@ import org.eclipse.ditto.base.model.headers.WithDittoHeaders; import org.eclipse.ditto.base.model.signals.commands.Command; import org.eclipse.ditto.base.model.signals.commands.CommandResponse; +import org.eclipse.ditto.internal.utils.akka.ActorSystemResource; import org.eclipse.ditto.internal.utils.akka.logging.DittoDiagnosticLoggingAdapter; import org.eclipse.ditto.internal.utils.persistentactors.commands.AbstractCommandStrategy; import org.eclipse.ditto.internal.utils.persistentactors.commands.CommandStrategy; @@ -41,6 +42,7 @@ import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingModifiedEvent; import org.junit.BeforeClass; +import org.junit.ClassRule; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; @@ -56,6 +58,9 @@ public abstract class AbstractCommandStrategyTest { protected static DittoDiagnosticLoggingAdapter logger; + @ClassRule + public static final ActorSystemResource ACTOR_SYSTEM_RESOURCE = ActorSystemResource.newInstance(); + @BeforeClass public static void initTestConstants() { logger = Mockito.mock(DittoDiagnosticLoggingAdapter.class); @@ -65,7 +70,7 @@ public static void initTestConstants() { } protected static CommandStrategy.Context getDefaultContext() { - return DefaultContext.getInstance(THING_ID, logger); + return DefaultContext.getInstance(THING_ID, logger, ACTOR_SYSTEM_RESOURCE.getActorSystem()); } protected static , T extends ThingModifiedEvent> T assertModificationResult( diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingConflictStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingConflictStrategyTest.java index 1a67e361b3..028caf5a5e 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingConflictStrategyTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingConflictStrategyTest.java @@ -23,6 +23,7 @@ import org.eclipse.ditto.base.model.headers.WithDittoHeaders; import org.eclipse.ditto.base.model.headers.entitytag.EntityTagMatchers; import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.internal.utils.akka.ActorSystemResource; import org.eclipse.ditto.internal.utils.akka.logging.DittoDiagnosticLoggingAdapter; import org.eclipse.ditto.internal.utils.persistentactors.commands.CommandStrategy; import org.eclipse.ditto.internal.utils.persistentactors.commands.DefaultContext; @@ -35,6 +36,7 @@ import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingPreconditionFailedException; import org.eclipse.ditto.things.model.signals.commands.modify.CreateThing; import org.eclipse.ditto.things.model.signals.events.ThingEvent; +import org.junit.ClassRule; import org.junit.Test; import org.mockito.Mockito; @@ -44,6 +46,9 @@ @SuppressWarnings({"rawtypes", "java:S3740"}) public final class ThingConflictStrategyTest { + @ClassRule + public static final ActorSystemResource ACTOR_SYSTEM_RESOURCE = ActorSystemResource.newInstance(); + @Test public void assertImmutability() { assertInstancesOf(ThingConflictStrategy.class, areImmutable()); @@ -55,7 +60,7 @@ public void createConflictResultWithoutPrecondition() { final ThingId thingId = ThingId.of("thing:id"); final Thing thing = ThingsModelFactory.newThingBuilder().setId(thingId).setRevision(25L).build(); final CommandStrategy.Context context = DefaultContext.getInstance(thingId, - mockLoggingAdapter()); + mockLoggingAdapter(), ACTOR_SYSTEM_RESOURCE.getActorSystem()); final CreateThing command = CreateThing.of(thing, null, DittoHeaders.empty()); final Result> result = underTest.apply(context, thing, 26L, command); result.accept(new ExpectErrorVisitor(ThingConflictException.class)); @@ -67,7 +72,7 @@ public void createPreconditionFailedResultWithPrecondition() { final ThingId thingId = ThingId.of("thing:id"); final Thing thing = ThingsModelFactory.newThingBuilder().setId(thingId).setRevision(25L).build(); final CommandStrategy.Context context = DefaultContext.getInstance(thingId, - mockLoggingAdapter()); + mockLoggingAdapter(), ACTOR_SYSTEM_RESOURCE.getActorSystem()); final CreateThing command = CreateThing.of(thing, null, DittoHeaders.newBuilder() .ifNoneMatch(EntityTagMatchers.fromStrings("*")) .build());