This is an automated email from the ASF dual-hosted git repository.
pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 261167a57af NIFI-16435: Drop undeclared connector properties on
provider save (#11776)
261167a57af is described below
commit 261167a57afcb195a3d54a2bcd396b12d811d234
Author: Bob Paulin <[email protected]>
AuthorDate: Thu Oct 8 09:51:15 2026 -0500
NIFI-16435: Drop undeclared connector properties on provider save (#11776)
- Filter the loaded provider configuration to declared steps and
properties before merging
- Skip undeclared incoming properties and do not recreate undeclared
steps
- Add unit test and ConnectorUndeclaredExternalPropertyIT
---
.../connector/StandardConnectorRepository.java | 78 +++++++++-
.../connector/TestStandardConnectorRepository.java | 63 ++++++++
.../nifi-system-test-extensions/pom.xml | 6 +
...aredPropertyConnectorConfigurationProvider.java | 162 +++++++++++++++++++++
...onents.connector.ConnectorConfigurationProvider | 16 ++
.../ConnectorUndeclaredExternalPropertyIT.java | 156 ++++++++++++++++++++
6 files changed, 473 insertions(+), 8 deletions(-)
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
index 8dce9108847..35bebe87e87 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
@@ -1138,36 +1138,98 @@ public class StandardConnectorRepository implements
ConnectorRepository {
? new ArrayList<>(existingConfig.getWorkingFlowConfiguration())
: new ArrayList<>();
- VersionedConfigurationStep targetStep = null;
+ final Map<String, Set<String>> declaredPropertiesByStep =
declaredPropertiesByStep(connector);
+ final List<VersionedConfigurationStep> retainedSteps = new
ArrayList<>();
for (final VersionedConfigurationStep step : existingSteps) {
+ final Set<String> declaredPropertyNames =
declaredPropertiesByStep.get(step.getName());
+ if (declaredPropertyNames == null) {
+ logger.debug("Omitting configuration step [{}] from the saved
configuration of {} because the current Connector does not declare it",
+ step.getName(), connector);
+ continue;
+ }
+
+ final Map<String, VersionedConnectorValueReference>
retainedProperties = new HashMap<>();
+ if (step.getProperties() != null) {
+ for (final Map.Entry<String, VersionedConnectorValueReference>
propertyEntry : step.getProperties().entrySet()) {
+ if
(declaredPropertyNames.contains(propertyEntry.getKey())) {
+ retainedProperties.put(propertyEntry.getKey(),
propertyEntry.getValue());
+ } else {
+ logger.debug("Omitting property [{}] of step [{}] from
the saved configuration of {} because the current Connector does not declare
it",
+ propertyEntry.getKey(), step.getName(), connector);
+ }
+ }
+ }
+
+ final VersionedConfigurationStep retainedStep = new
VersionedConfigurationStep();
+ retainedStep.setName(step.getName());
+ retainedStep.setProperties(retainedProperties);
+ retainedSteps.add(retainedStep);
+ }
+
+ VersionedConfigurationStep targetStep = null;
+ for (final VersionedConfigurationStep step : retainedSteps) {
if (stepName.equals(step.getName())) {
targetStep = step;
break;
}
}
+ // The filter above only removes stored steps. The block below creates
a missing step, so return here when the requested step is not declared.
+ if (targetStep == null &&
!declaredPropertiesByStep.containsKey(stepName)) {
+ logger.debug("Omitting configuration step [{}] from the saved
configuration of {} because the current Connector does not declare it",
+ stepName, connector);
+ existingConfig.setWorkingFlowConfiguration(retainedSteps);
+ return existingConfig;
+ }
+
if (targetStep == null) {
targetStep = new VersionedConfigurationStep();
targetStep.setName(stepName);
targetStep.setProperties(new HashMap<>());
- existingSteps.add(targetStep);
+ retainedSteps.add(targetStep);
}
- final Map<String, VersionedConnectorValueReference> mergedProperties =
targetStep.getProperties() != null
- ? new HashMap<>(targetStep.getProperties())
- : new HashMap<>();
+ final Map<String, VersionedConnectorValueReference> mergedProperties =
new HashMap<>(targetStep.getProperties());
+ final Set<String> declaredPropertyNames =
declaredPropertiesByStep.get(stepName);
for (final Map.Entry<String, ConnectorValueReference> entry :
incomingConfiguration.getPropertyValues().entrySet()) {
- if (entry.getValue() != null) {
- mergedProperties.put(entry.getKey(),
toVersionedValueReference(entry.getValue()));
+ if (entry.getValue() == null) {
+ continue;
+ }
+
+ if (!declaredPropertyNames.contains(entry.getKey())) {
+ logger.debug("Omitting property [{}] of step [{}] from the
saved configuration of {} because the current Connector does not declare it",
+ entry.getKey(), stepName, connector);
+ continue;
}
+
+ mergedProperties.put(entry.getKey(),
toVersionedValueReference(entry.getValue()));
}
targetStep.setProperties(mergedProperties);
- existingConfig.setWorkingFlowConfiguration(existingSteps);
+ existingConfig.setWorkingFlowConfiguration(retainedSteps);
return existingConfig;
}
+ /**
+ * Returns the property names declared by the current Connector, keyed by
configuration step name.
+ */
+ private Map<String, Set<String>> declaredPropertiesByStep(final
ConnectorNode connector) {
+ final Map<String, Set<String>> declaredPropertiesByStep = new
HashMap<>();
+ for (final ConfigurationStep configurationStep :
connector.getConfigurationSteps()) {
+ final Set<String> propertyNames = new HashSet<>();
+ for (final ConnectorPropertyGroup propertyGroup :
configurationStep.getPropertyGroups()) {
+ for (final ConnectorPropertyDescriptor descriptor :
propertyGroup.getProperties()) {
+ propertyNames.add(descriptor.getName());
+ }
+ }
+
+ declaredPropertiesByStep.put(configurationStep.getName(),
propertyNames);
+ }
+
+ return declaredPropertiesByStep;
+ }
+
private Map<String, VersionedConnectorValueReference>
toVersionedProperties(final StepConfiguration configuration) {
final Map<String, VersionedConnectorValueReference>
versionedProperties = new HashMap<>();
for (final Map.Entry<String, ConnectorValueReference> entry :
configuration.getPropertyValues().entrySet()) {
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
index 09bf1a56ca6..c9c31862b00 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
@@ -536,6 +536,7 @@ public class TestStandardConnectorRepository {
final StandardConnectorRepository repository =
createRepositoryWithProvider(provider);
final ConnectorNode connector =
createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+
when(connector.getConfigurationSteps()).thenReturn(List.of(declaredConfigurationStep("step1",
"prop1")));
repository.addConnector(connector);
when(provider.load("connector-1")).thenReturn(Optional.empty());
@@ -567,6 +568,7 @@ public class TestStandardConnectorRepository {
final StandardConnectorRepository repository =
createRepositoryWithProvider(provider);
final ConnectorNode connector =
createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+
when(connector.getConfigurationSteps()).thenReturn(List.of(declaredConfigurationStep("step1",
"prop1")));
repository.addConnector(connector);
when(provider.load("connector-1")).thenReturn(Optional.empty());
@@ -585,6 +587,7 @@ public class TestStandardConnectorRepository {
final StandardConnectorRepository repository =
createRepositoryWithProvider(provider);
final ConnectorNode connector =
createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+
when(connector.getConfigurationSteps()).thenReturn(List.of(declaredConfigurationStep("step1",
"propA", "propB", "propC")));
repository.addConnector(connector);
final VersionedConfigurationStep existingStep =
createVersionedStep("step1",
@@ -617,6 +620,46 @@ public class TestStandardConnectorRepository {
verify(connector).setConfiguration("step1", incomingConfig);
}
+ @Test
+ public void testConfigureConnectorDropsUndeclaredExternalProperties()
throws FlowUpdateException {
+ final ConnectorConfigurationProvider provider =
mock(ConnectorConfigurationProvider.class);
+ final StandardConnectorRepository repository =
createRepositoryWithProvider(provider);
+
+ final ConnectorNode connector =
createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+
when(connector.getConfigurationSteps()).thenReturn(List.of(declaredConfigurationStep("step1",
"propA", "propC")));
+ repository.addConnector(connector);
+
+ final VersionedConfigurationStep existingStep =
createVersionedStep("step1",
+ Map.of("propA", createStringLiteralRef("old-A"), "propB",
createStringLiteralRef("old-B"), "propC", createStringLiteralRef("old-C")));
+ final VersionedConfigurationStep undeclaredStep =
createVersionedStep("legacy",
+ Map.of("obsolete", createStringLiteralRef("gone")));
+ final ConnectorWorkingConfiguration existingConfig = new
ConnectorWorkingConfiguration();
+ existingConfig.setName("Test Connector");
+ existingConfig.setWorkingFlowConfiguration(new
ArrayList<>(List.of(existingStep, undeclaredStep)));
+
when(provider.load("connector-1")).thenReturn(Optional.of(existingConfig));
+
+ final Map<String, ConnectorValueReference> incomingProps = new
HashMap<>();
+ incomingProps.put("propA", new StringLiteralValue("new-A"));
+ incomingProps.put("propD", new StringLiteralValue("not-declared"));
+ final StepConfiguration incomingConfig = new
StepConfiguration(incomingProps);
+
+ repository.configureConnector(connector, "step1", incomingConfig);
+
+ final ArgumentCaptor<ConnectorWorkingConfiguration> configCaptor =
ArgumentCaptor.forClass(ConnectorWorkingConfiguration.class);
+ verify(provider).save(eq("connector-1"), configCaptor.capture());
+
+ final ConnectorWorkingConfiguration savedConfig =
configCaptor.getValue();
+ assertEquals(1, savedConfig.getWorkingFlowConfiguration().size());
+ final VersionedConfigurationStep savedStep =
savedConfig.getWorkingFlowConfiguration().getFirst();
+ assertEquals("step1", savedStep.getName());
+
+ final Map<String, VersionedConnectorValueReference> savedProps =
savedStep.getProperties();
+ assertEquals("new-A", savedProps.get("propA").getValue());
+ assertEquals("old-C", savedProps.get("propC").getValue());
+ assertFalse(savedProps.containsKey("propB"));
+ assertFalse(savedProps.containsKey("propD"));
+ }
+
@Test
public void testDiscardWorkingConfigurationCallsProviderDiscard() {
final ConnectorConfigurationProvider provider =
mock(ConnectorConfigurationProvider.class);
@@ -2010,6 +2053,26 @@ public class TestStandardConnectorRepository {
return connector;
}
+ private ConfigurationStep declaredConfigurationStep(final String stepName,
final String... propertyNames) {
+ final List<ConnectorPropertyDescriptor> descriptors = new
ArrayList<>();
+ for (final String propertyName : propertyNames) {
+ descriptors.add(new ConnectorPropertyDescriptor.Builder()
+ .name(propertyName)
+ .description(propertyName)
+ .required(false)
+ .build());
+ }
+
+ return new ConfigurationStep.Builder()
+ .name(stepName)
+ .propertyGroups(List.of(ConnectorPropertyGroup.builder()
+ .name("Properties")
+ .description("Declared properties")
+ .properties(descriptors)
+ .build()))
+ .build();
+ }
+
private VersionedConfigurationStep createVersionedStep(final String name,
final Map<String, VersionedConnectorValueReference> properties) {
final VersionedConfigurationStep step = new
VersionedConfigurationStep();
step.setName(name);
diff --git
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/pom.xml
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/pom.xml
index a1b61e603c4..ea90f522090 100644
---
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/pom.xml
+++
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/pom.xml
@@ -54,6 +54,12 @@
<artifactId>nifi-connector-common</artifactId>
<version>2.13.0-SNAPSHOT</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.nifi</groupId>
+ <artifactId>nifi-framework-api</artifactId>
+ <version>2.13.0-SNAPSHOT</version>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-system-test-extensions-services-api</artifactId>
diff --git
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/UndeclaredPropertyConnectorConfigurationProvider.java
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/UndeclaredPropertyConnectorConfigurationProvider.java
new file mode 100644
index 00000000000..c03d7d8e860
--- /dev/null
+++
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/UndeclaredPropertyConnectorConfigurationProvider.java
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.connectors.tests.system;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.components.connector.ConnectorConfigurationProvider;
+import
org.apache.nifi.components.connector.ConnectorConfigurationProviderException;
+import
org.apache.nifi.components.connector.ConnectorConfigurationProviderInitializationContext;
+import org.apache.nifi.components.connector.ConnectorWorkingConfiguration;
+import org.apache.nifi.flow.VersionedConfigurationStep;
+import org.apache.nifi.flow.VersionedConnectorValueReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory configuration provider whose initial configuration for an unsaved
connector includes a property
+ * the test connector does not declare. Each save is also written to disk so a
system test can observe properties
+ * that the connector REST API does not return.
+ */
+public class UndeclaredPropertyConnectorConfigurationProvider implements
ConnectorConfigurationProvider {
+ public static final String STATE_DIRECTORY_PROPERTY = "state.directory";
+ public static final String INITIAL_CONFIG_FILE_NAME =
"initial-config.json";
+ public static final String SAVED_CONFIG_FILE_NAME = "saved-config.json";
+
+ static final String STEP_NAME = "Ignored Step";
+ static final String DECLARED_PROPERTY_NAME = "Ignored Property";
+ static final String DECLARED_PROPERTY_VALUE = "from-external-config";
+ static final String UNDECLARED_PROPERTY_NAME = "Removed Property";
+ static final String UNDECLARED_PROPERTY_VALUE = "should-be-dropped";
+
+ private static final Logger logger =
LoggerFactory.getLogger(UndeclaredPropertyConnectorConfigurationProvider.class);
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final Map<String, ConnectorWorkingConfiguration>
savedConfigurations = new ConcurrentHashMap<>();
+
+ private File stateDirectory;
+
+ @Override
+ public void initialize(final
ConnectorConfigurationProviderInitializationContext context) throws
ConnectorConfigurationProviderException {
+ final String stateDirectoryPath =
context.getProperties().get(STATE_DIRECTORY_PROPERTY);
+ if (stateDirectoryPath == null || stateDirectoryPath.isBlank()) {
+ throw new ConnectorConfigurationProviderException("Connector
configuration provider property [%s] is
required".formatted(STATE_DIRECTORY_PROPERTY));
+ }
+
+ stateDirectory = new File(stateDirectoryPath);
+ if (!stateDirectory.isDirectory() && !stateDirectory.mkdirs()) {
+ throw new ConnectorConfigurationProviderException("Unable to
create connector configuration provider state directory
[%s]".formatted(stateDirectoryPath));
+ }
+
+ writeConfiguration(new File(stateDirectory, INITIAL_CONFIG_FILE_NAME),
seedConfiguration());
+ }
+
+ @Override
+ public Optional<ConnectorWorkingConfiguration> load(final String
connectorId) {
+ final ConnectorWorkingConfiguration savedConfiguration =
savedConfigurations.get(connectorId);
+ if (savedConfiguration != null) {
+ return Optional.of(copy(savedConfiguration));
+ }
+
+ return Optional.of(seedConfiguration());
+ }
+
+ @Override
+ public void save(final String connectorId, final
ConnectorWorkingConfiguration configuration) {
+ final ConnectorWorkingConfiguration savedConfiguration =
copy(configuration);
+ savedConfigurations.put(connectorId, savedConfiguration);
+ writeConfiguration(new File(stateDirectory, SAVED_CONFIG_FILE_NAME),
savedConfiguration);
+ }
+
+ @Override
+ public void discard(final String connectorId) {
+ logger.debug("Discard is a no-op for connector {}", connectorId);
+ }
+
+ @Override
+ public void delete(final String connectorId) {
+ savedConfigurations.remove(connectorId);
+ }
+
+ @Override
+ public void verifyCreate(final String connectorId) {
+ logger.debug("Verify create is a no-op for connector {}", connectorId);
+ }
+
+ @Override
+ public void storeAsset(final String connectorId, final String nifiUuid,
final String assetName, final InputStream content) {
+ logger.debug("Store asset is a no-op for connector {} and asset {}",
connectorId, nifiUuid);
+ }
+
+ @Override
+ public void deleteAsset(final String connectorId, final String nifiUuid) {
+ logger.debug("Delete asset is a no-op for connector {} and asset {}",
connectorId, nifiUuid);
+ }
+
+ @Override
+ public void syncAssets(final String connectorId) {
+ logger.debug("Sync assets is a no-op for connector {}", connectorId);
+ }
+
+ private ConnectorWorkingConfiguration seedConfiguration() {
+ final VersionedConnectorValueReference declaredValue = new
VersionedConnectorValueReference();
+ declaredValue.setValueType("STRING_LITERAL");
+ declaredValue.setValue(DECLARED_PROPERTY_VALUE);
+
+ final VersionedConnectorValueReference undeclaredValue = new
VersionedConnectorValueReference();
+ undeclaredValue.setValueType("STRING_LITERAL");
+ undeclaredValue.setValue(UNDECLARED_PROPERTY_VALUE);
+
+ final Map<String, VersionedConnectorValueReference> properties = new
HashMap<>();
+ properties.put(DECLARED_PROPERTY_NAME, declaredValue);
+ properties.put(UNDECLARED_PROPERTY_NAME, undeclaredValue);
+
+ final VersionedConfigurationStep step = new
VersionedConfigurationStep();
+ step.setName(STEP_NAME);
+ step.setProperties(properties);
+
+ final ConnectorWorkingConfiguration configuration = new
ConnectorWorkingConfiguration();
+ configuration.setName("NopConnector");
+ configuration.setWorkingFlowConfiguration(new
ArrayList<>(List.of(step)));
+ return configuration;
+ }
+
+ private ConnectorWorkingConfiguration copy(final
ConnectorWorkingConfiguration configuration) {
+ try {
+ return
OBJECT_MAPPER.readValue(OBJECT_MAPPER.writeValueAsBytes(configuration),
ConnectorWorkingConfiguration.class);
+ } catch (final IOException e) {
+ throw new ConnectorConfigurationProviderException("Unable to copy
connector working configuration", e);
+ }
+ }
+
+ private void writeConfiguration(final File destination, final
ConnectorWorkingConfiguration configuration) {
+ try {
+
OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValue(destination,
configuration);
+ } catch (final IOException e) {
+ throw new ConnectorConfigurationProviderException("Unable to write
connector working configuration to
[%s]".formatted(destination.getAbsolutePath()), e);
+ }
+ }
+}
diff --git
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.ConnectorConfigurationProvider
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.ConnectorConfigurationProvider
new file mode 100644
index 00000000000..56e344eb084
--- /dev/null
+++
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.ConnectorConfigurationProvider
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+org.apache.nifi.connectors.tests.system.UndeclaredPropertyConnectorConfigurationProvider
diff --git
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorUndeclaredExternalPropertyIT.java
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorUndeclaredExternalPropertyIT.java
new file mode 100644
index 00000000000..d565766d8b0
--- /dev/null
+++
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorUndeclaredExternalPropertyIT.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.nifi.tests.system.connectors;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.tests.system.InstanceConfiguration;
+import org.apache.nifi.tests.system.NiFiInstanceFactory;
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.tests.system.SpawnedStandaloneNiFiInstanceFactory;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ConnectorConfigurationDTO;
+import org.apache.nifi.web.api.dto.ConnectorValueReferenceDTO;
+import org.apache.nifi.web.api.entity.ConnectorEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Verifies that saving a connector drops properties and steps that the
current connector does not declare,
+ * even when the external configuration provider loaded them.
+ */
+public class ConnectorUndeclaredExternalPropertyIT extends NiFiSystemIT {
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private static final String PROVIDER_CLASS =
"org.apache.nifi.connectors.tests.system.UndeclaredPropertyConnectorConfigurationProvider";
+ private static final File STATE_DIRECTORY = new
File("target/undeclared-external-property").getAbsoluteFile();
+ private static final String STEP_NAME = "Ignored Step";
+ private static final String DECLARED_PROPERTY_NAME = "Ignored Property";
+ private static final String DECLARED_PROPERTY_VALUE =
"from-external-config";
+ private static final String UPDATED_PROPERTY_VALUE = "saved-by-test";
+ private static final String UNDECLARED_PROPERTY_NAME = "Removed Property";
+
+ @Override
+ public NiFiInstanceFactory getInstanceFactory() {
+ deleteRecursively(STATE_DIRECTORY);
+
+ return new SpawnedStandaloneNiFiInstanceFactory(
+ new InstanceConfiguration.Builder()
+
.bootstrapConfig("src/test/resources/conf/default/bootstrap.conf")
+ .instanceDirectory("target/standalone-instance")
+ .overrideNifiProperties(Map.of(
+ "nifi.connector.configuration.provider.implementation",
PROVIDER_CLASS,
+
"nifi.connector.configuration.provider.properties.state.directory",
STATE_DIRECTORY.getAbsolutePath()
+ ))
+ .build());
+ }
+
+ @Override
+ protected boolean isDestroyEnvironmentAfterEachTest() {
+ return true;
+ }
+
+ @Override
+ protected boolean isAllowFactoryReuse() {
+ return false;
+ }
+
+ @Test
+ public void testSaveDropsUndeclaredExternalProperty() throws
NiFiClientException, IOException {
+ final JsonNode initialConfiguration =
readConfiguration("initial-config.json");
+ assertEquals(DECLARED_PROPERTY_VALUE,
propertyValue(initialConfiguration, DECLARED_PROPERTY_NAME));
+ assertEquals("should-be-dropped", propertyValue(initialConfiguration,
UNDECLARED_PROPERTY_NAME));
+
+ final ConnectorEntity connector =
getClientUtil().createConnector("NopConnector");
+ assertNotNull(connector);
+
+ final ConnectorEntity beforeSave =
getNifiClient().getConnectorClient().getConnector(connector.getId());
+ assertEquals(DECLARED_PROPERTY_VALUE,
ignoredPropertyValue(beforeSave));
+
+ getClientUtil().configureConnector(connector, STEP_NAME,
Map.of(DECLARED_PROPERTY_NAME, UPDATED_PROPERTY_VALUE));
+
+ final JsonNode savedConfiguration =
readConfiguration("saved-config.json");
+ assertEquals(UPDATED_PROPERTY_VALUE, propertyValue(savedConfiguration,
DECLARED_PROPERTY_NAME));
+ assertFalse(containsProperty(savedConfiguration,
UNDECLARED_PROPERTY_NAME));
+
+ final ConnectorEntity afterSave =
getNifiClient().getConnectorClient().getConnector(connector.getId());
+ assertEquals(UPDATED_PROPERTY_VALUE, ignoredPropertyValue(afterSave));
+ }
+
+ private JsonNode readConfiguration(final String fileName) throws
IOException {
+ return OBJECT_MAPPER.readTree(new File(STATE_DIRECTORY, fileName));
+ }
+
+ private String ignoredPropertyValue(final ConnectorEntity connector) {
+ final ConnectorConfigurationDTO workingConfiguration =
connector.getComponent().getWorkingConfiguration();
+ final Map<String, ConnectorValueReferenceDTO> propertyValues =
workingConfiguration.getConfigurationStepConfigurations().getFirst()
+ .getPropertyGroupConfigurations().getFirst()
+ .getPropertyValues();
+ return propertyValues.get(DECLARED_PROPERTY_NAME).getValue();
+ }
+
+ private String propertyValue(final JsonNode configuration, final String
propertyName) {
+ final JsonNode property = findProperty(configuration, propertyName);
+ assertNotNull(property);
+ return property.get("value").asText();
+ }
+
+ private boolean containsProperty(final JsonNode configuration, final
String propertyName) {
+ return findProperty(configuration, propertyName) != null;
+ }
+
+ private JsonNode findProperty(final JsonNode configuration, final String
propertyName) {
+ final JsonNode steps = configuration.get("workingFlowConfiguration");
+ if (steps == null) {
+ return null;
+ }
+
+ for (final JsonNode step : steps) {
+ final JsonNode properties = step.get("properties");
+ if (properties != null && properties.has(propertyName)) {
+ return properties.get(propertyName);
+ }
+ }
+
+ return null;
+ }
+
+ private void deleteRecursively(final File file) {
+ if (!file.exists()) {
+ return;
+ }
+
+ final File[] children = file.listFiles();
+ if (children != null) {
+ for (final File child : children) {
+ deleteRecursively(child);
+ }
+ }
+
+ if (!file.delete()) {
+ throw new IllegalStateException("Unable to delete " +
file.getAbsolutePath());
+ }
+ }
+}