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 fb432a2db5a NIFI-7307 Adding support for setting and retrieving Azure
Blob Storage metadata and tags (#10939)
fb432a2db5a is described below
commit fb432a2db5a766c5fb2929625569ceb998c6f5e9
Author: pkelly-nifi <[email protected]>
AuthorDate: Thu Jul 16 08:45:00 2026 -0400
NIFI-7307 Adding support for setting and retrieving Azure Blob Storage
metadata and tags (#10939)
---
...ctFetchAzureBlobStoragePropertiesProcessor.java | 122 ++++++++
.../storage/FetchAzureBlobStorageMetadata.java | 50 +++
.../azure/storage/FetchAzureBlobStorageTags.java | 50 +++
.../azure/storage/PutAzureBlobStorage_v12.java | 100 +++++-
.../azure/storage/utils/BlobAttributes.java | 4 +
.../services/org.apache.nifi.processor.Processor | 2 +
.../storage/TestFetchAzureBlobStorageMetadata.java | 271 ++++++++++++++++
.../storage/TestFetchAzureBlobStorageTags.java | 237 ++++++++++++++
.../azure/storage/TestPutAzureBlobStorage_v12.java | 345 +++++++++++++++++++++
9 files changed, 1179 insertions(+), 2 deletions(-)
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/AbstractFetchAzureBlobStoragePropertiesProcessor.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/AbstractFetchAzureBlobStoragePropertiesProcessor.java
new file mode 100644
index 00000000000..b46f9ca1ea6
--- /dev/null
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/AbstractFetchAzureBlobStoragePropertiesProcessor.java
@@ -0,0 +1,122 @@
+/*
+ * 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.processors.azure.storage;
+
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobServiceClient;
+import com.azure.storage.blob.models.BlobErrorCode;
+import com.azure.storage.blob.models.BlobStorageException;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.processors.azure.AbstractAzureBlobProcessor_v12;
+import org.apache.nifi.processors.azure.storage.utils.AzureStorageUtils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static
org.apache.nifi.processors.azure.storage.utils.AzureStorageUtils.BLOB_STORAGE_CREDENTIALS_SERVICE;
+import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_BLOBNAME;
+import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_CONTAINER;
+
+public abstract class AbstractFetchAzureBlobStoragePropertiesProcessor extends
AbstractAzureBlobProcessor_v12 {
+
+ public static final PropertyDescriptor CONTAINER = new
PropertyDescriptor.Builder()
+ .fromPropertyDescriptor(AzureStorageUtils.CONTAINER)
+ .defaultValue(String.format("${%s}", ATTR_NAME_CONTAINER))
+ .build();
+
+ public static final PropertyDescriptor BLOB_NAME = new
PropertyDescriptor.Builder()
+ .fromPropertyDescriptor(AbstractAzureBlobProcessor_v12.BLOB_NAME)
+ .defaultValue(String.format("${%s}", ATTR_NAME_BLOBNAME))
+ .build();
+
+ private static final List<PropertyDescriptor> PROPERTIES = List.of(
+ BLOB_STORAGE_CREDENTIALS_SERVICE,
+ CONTAINER,
+ BLOB_NAME,
+ AzureStorageUtils.PROXY_CONFIGURATION_SERVICE
+ );
+
+ public static final Relationship REL_FOUND = new Relationship.Builder()
+ .name("found")
+ .description("A blob with the supplied name was found in the
container")
+ .build();
+
+ public static final Relationship REL_NOT_FOUND = new Relationship.Builder()
+ .name("not found")
+ .description("No blob was found with the supplied name in the
container")
+ .build();
+
+ private static final Set<Relationship> RELATIONSHIPS = Set.of(REL_FOUND,
REL_NOT_FOUND, REL_FAILURE);
+
+ @Override
+ public Set<Relationship> getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ @Override
+ protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return PROPERTIES;
+ }
+
+ protected abstract String getAttributePrefix();
+
+ protected abstract Map<String, String> fetchProperties(BlobClient
blobClient);
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) throws ProcessException {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ return;
+ }
+
+ final String containerName =
context.getProperty(CONTAINER).evaluateAttributeExpressions(flowFile).getValue();
+ final String blobName =
context.getProperty(BLOB_NAME).evaluateAttributeExpressions(flowFile).getValue();
+
+ try {
+ final BlobServiceClient storageClient = getStorageClient(context,
flowFile);
+ final BlobContainerClient containerClient =
storageClient.getBlobContainerClient(containerName);
+ final BlobClient blobClient =
containerClient.getBlobClient(blobName);
+
+ final Map<String, String> newAttributes = new HashMap<>();
+ fetchProperties(blobClient).forEach((key, value) ->
+ newAttributes.put(getAttributePrefix().formatted(key), value)
+ );
+
+ flowFile = session.putAllAttributes(flowFile, newAttributes);
+ final String eventDetails = String.format("Attributes added [%s]",
String.join(", ", newAttributes.keySet()));
+ session.getProvenanceReporter().modifyAttributes(flowFile,
eventDetails);
+ session.transfer(flowFile, REL_FOUND);
+ } catch (final BlobStorageException e) {
+ if (e.getErrorCode() == BlobErrorCode.BLOB_NOT_FOUND) {
+ getLogger().warn("Specified blob ({}) does not exist, routing
to not found.", blobName);
+ session.transfer(flowFile, REL_NOT_FOUND);
+ } else {
+ getLogger().error("Failed to retrieve properties for the
specified blob ({}) from Azure Blob Storage. Routing to failure", blobName, e);
+ flowFile = session.penalize(flowFile);
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ }
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/FetchAzureBlobStorageMetadata.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/FetchAzureBlobStorageMetadata.java
new file mode 100644
index 00000000000..9794356651b
--- /dev/null
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/FetchAzureBlobStorageMetadata.java
@@ -0,0 +1,50 @@
+/*
+ * 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.processors.azure.storage;
+
+import com.azure.storage.blob.BlobClient;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.InputRequirement.Requirement;
+import org.apache.nifi.annotation.behavior.WritesAttribute;
+import org.apache.nifi.annotation.behavior.WritesAttributes;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.SeeAlso;
+import org.apache.nifi.annotation.documentation.Tags;
+
+import java.util.Map;
+import java.util.Optional;
+
+@Tags({"azure", "microsoft", "cloud", "storage", "blob"})
+@SeeAlso({ListAzureBlobStorage_v12.class, FetchAzureBlobStorage_v12.class,
PutAzureBlobStorage_v12.class,
+ CopyAzureBlobStorage_v12.class, DeleteAzureBlobStorage_v12.class,
FetchAzureBlobStorageTags.class})
+@CapabilityDescription("Retrieves user metadata from the specified blob from
Azure Blob Storage. The processor uses Azure Blob Storage client library v12.")
+@InputRequirement(Requirement.INPUT_REQUIRED)
+@WritesAttributes({@WritesAttribute(attribute =
"azure.user.metadata.<metadata-key>", description = "The value of the retrieved
metadata")})
+public class FetchAzureBlobStorageMetadata extends
AbstractFetchAzureBlobStoragePropertiesProcessor {
+
+ private static final String ATTRIBUTE_PREFIX = "azure.user.metadata.%s";
+
+ @Override
+ protected String getAttributePrefix() {
+ return ATTRIBUTE_PREFIX;
+ }
+
+ @Override
+ protected Map<String, String> fetchProperties(final BlobClient blobClient)
{
+ return
Optional.ofNullable(blobClient.getProperties().getMetadata()).orElse(Map.of());
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/FetchAzureBlobStorageTags.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/FetchAzureBlobStorageTags.java
new file mode 100644
index 00000000000..0f663bbe625
--- /dev/null
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/FetchAzureBlobStorageTags.java
@@ -0,0 +1,50 @@
+/*
+ * 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.processors.azure.storage;
+
+import com.azure.storage.blob.BlobClient;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.InputRequirement.Requirement;
+import org.apache.nifi.annotation.behavior.WritesAttribute;
+import org.apache.nifi.annotation.behavior.WritesAttributes;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.SeeAlso;
+import org.apache.nifi.annotation.documentation.Tags;
+
+import java.util.Map;
+import java.util.Optional;
+
+@Tags({"azure", "microsoft", "cloud", "storage", "blob"})
+@SeeAlso({ListAzureBlobStorage_v12.class, FetchAzureBlobStorage_v12.class,
PutAzureBlobStorage_v12.class,
+ CopyAzureBlobStorage_v12.class, DeleteAzureBlobStorage_v12.class,
FetchAzureBlobStorageMetadata.class})
+@CapabilityDescription("Retrieves tags from the specified blob from Azure Blob
Storage. The processor uses Azure Blob Storage client library v12.")
+@InputRequirement(Requirement.INPUT_REQUIRED)
+@WritesAttributes({@WritesAttribute(attribute = "azure.tag.<tag-key>",
description = "The value of the retrieved tag")})
+public class FetchAzureBlobStorageTags extends
AbstractFetchAzureBlobStoragePropertiesProcessor {
+
+ private static final String ATTRIBUTE_PREFIX = "azure.tag.%s";
+
+ @Override
+ protected String getAttributePrefix() {
+ return ATTRIBUTE_PREFIX;
+ }
+
+ @Override
+ protected Map<String, String> fetchProperties(final BlobClient blobClient)
{
+ return Optional.ofNullable(blobClient.getTags()).orElse(Map.of());
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/PutAzureBlobStorage_v12.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/PutAzureBlobStorage_v12.java
index 50c0a4d9d9f..07d5f814248 100644
---
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/PutAzureBlobStorage_v12.java
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/PutAzureBlobStorage_v12.java
@@ -29,6 +29,8 @@ import com.azure.storage.blob.models.BlobType;
import com.azure.storage.blob.models.BlockBlobItem;
import com.azure.storage.blob.models.ParallelTransferOptions;
import com.azure.storage.blob.options.BlobParallelUploadOptions;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
import org.apache.nifi.annotation.behavior.InputRequirement;
import org.apache.nifi.annotation.behavior.InputRequirement.Requirement;
import org.apache.nifi.annotation.behavior.WritesAttribute;
@@ -36,15 +38,18 @@ import org.apache.nifi.annotation.behavior.WritesAttributes;
import org.apache.nifi.annotation.documentation.CapabilityDescription;
import org.apache.nifi.annotation.documentation.SeeAlso;
import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.components.AllowableValue;
import org.apache.nifi.components.PropertyDescriptor;
import org.apache.nifi.components.ValidationContext;
import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.expression.ExpressionLanguageScope;
import org.apache.nifi.fileresource.service.api.FileResource;
import org.apache.nifi.flowfile.FlowFile;
import org.apache.nifi.migration.PropertyConfiguration;
import org.apache.nifi.processor.ProcessContext;
import org.apache.nifi.processor.ProcessSession;
import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.processor.util.StandardValidators;
import org.apache.nifi.processors.azure.AbstractAzureBlobProcessor_v12;
import org.apache.nifi.processors.azure.ClientSideEncryptionSupport;
import org.apache.nifi.processors.azure.storage.utils.AzureStorageUtils;
@@ -59,6 +64,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
import static com.azure.core.http.ContentType.APPLICATION_OCTET_STREAM;
import static com.azure.core.util.FluxUtil.toFluxByteBuffer;
@@ -76,6 +82,7 @@ import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_DESCRIPTION_MIME_TYPE;
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_DESCRIPTION_PRIMARY_URI;
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_DESCRIPTION_TIMESTAMP;
+import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_DESCRIPTION_USER_METADATA;
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_BLOBNAME;
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_BLOBTYPE;
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_CONTAINER;
@@ -87,14 +94,19 @@ import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_MIME_TYPE;
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_PRIMARY_URI;
import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_TIMESTAMP;
+import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_USER_METADATA;
import static
org.apache.nifi.processors.transfer.ResourceTransferProperties.FILE_RESOURCE_SERVICE;
import static
org.apache.nifi.processors.transfer.ResourceTransferProperties.RESOURCE_TRANSFER_SOURCE;
import static
org.apache.nifi.processors.transfer.ResourceTransferUtils.getFileResource;
@Tags({"azure", "microsoft", "cloud", "storage", "blob"})
@SeeAlso({ListAzureBlobStorage_v12.class, FetchAzureBlobStorage_v12.class,
DeleteAzureBlobStorage_v12.class,
- CopyAzureBlobStorage_v12.class})
+ CopyAzureBlobStorage_v12.class, FetchAzureBlobStorageMetadata.class,
FetchAzureBlobStorageTags.class})
@CapabilityDescription("Puts content into a blob on Azure Blob Storage. The
processor uses Azure Blob Storage client library v12.")
+@DynamicProperty(name = "The name of a User-Defined Metadata field to add to
the blob",
+ value = "The value of a User-Defined Metadata field to add to the
blob",
+ description = "Allows user-defined metadata to be added to the blob as
key/value pairs",
+ expressionLanguageScope = ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
@InputRequirement(Requirement.INPUT_REQUIRED)
@WritesAttributes({@WritesAttribute(attribute = ATTR_NAME_CONTAINER,
description = ATTR_DESCRIPTION_CONTAINER),
@WritesAttribute(attribute = ATTR_NAME_BLOBNAME, description =
ATTR_DESCRIPTION_BLOBNAME),
@@ -106,9 +118,33 @@ import static
org.apache.nifi.processors.transfer.ResourceTransferUtils.getFileR
@WritesAttribute(attribute = ATTR_NAME_TIMESTAMP, description =
ATTR_DESCRIPTION_TIMESTAMP),
@WritesAttribute(attribute = ATTR_NAME_LENGTH, description =
ATTR_DESCRIPTION_LENGTH),
@WritesAttribute(attribute = ATTR_NAME_ERROR_CODE, description =
ATTR_DESCRIPTION_ERROR_CODE),
- @WritesAttribute(attribute = ATTR_NAME_IGNORED, description =
ATTR_DESCRIPTION_IGNORED)})
+ @WritesAttribute(attribute = ATTR_NAME_IGNORED, description =
ATTR_DESCRIPTION_IGNORED),
+ @WritesAttribute(attribute = ATTR_NAME_USER_METADATA, description =
ATTR_DESCRIPTION_USER_METADATA)})
public class PutAzureBlobStorage_v12 extends AbstractAzureBlobProcessor_v12
implements ClientSideEncryptionSupport {
+ public static final PropertyDescriptor BLOB_TAG_PREFIX = new
PropertyDescriptor.Builder()
+ .name("Blob Tag Prefix")
+ .description("""
+ Specifies the prefix which would be scanned against the
incoming FlowFile's attributes and the matching attribute's
+ name and value would be considered as the outgoing Azure
blob's Tag name and Tag value respectively. For Ex: If the
+ incoming FlowFile carries the attributes tagAzurecountry,
tagAzurePII, the tag prefix to be specified would be 'tagAzure'.
+ """)
+ .required(false)
+ .addValidator(StandardValidators.NON_EMPTY_EL_VALIDATOR)
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .build();
+
+ public static final PropertyDescriptor REMOVE_TAG_PREFIX = new
PropertyDescriptor.Builder()
+ .name("Remove Tag Prefix")
+ .description("""
+ If set to 'True', the value provided for '%s' will be
removed from
+ the attribute(s) and then considered as the Tag name. For
ex: If the incoming FlowFile carries the attributes tagAzurecountry,
+ tagAzurePII and the prefix is set to 'tagAzure' then the
corresponding tag names would be 'country' and 'PII'.
+ """.formatted(BLOB_TAG_PREFIX.getDisplayName()))
+ .allowableValues(new AllowableValue("true", "True"), new
AllowableValue("false", "False"))
+ .defaultValue("false")
+ .build();
+
private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS =
List.of(
BLOB_STORAGE_CREDENTIALS_SERVICE,
AzureStorageUtils.CONTAINER,
@@ -116,6 +152,8 @@ public class PutAzureBlobStorage_v12 extends
AbstractAzureBlobProcessor_v12 impl
AzureStorageUtils.CONFLICT_RESOLUTION,
BLOB_NAME,
CONTENT_MD5,
+ BLOB_TAG_PREFIX,
+ REMOVE_TAG_PREFIX,
RESOURCE_TRANSFER_SOURCE,
FILE_RESOURCE_SERVICE,
AzureStorageUtils.PROXY_CONFIGURATION_SERVICE,
@@ -124,6 +162,16 @@ public class PutAzureBlobStorage_v12 extends
AbstractAzureBlobProcessor_v12 impl
CSE_LOCAL_KEY
);
+ @Override
+ protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final
String propertyDescriptorName) {
+ return new PropertyDescriptor.Builder()
+ .name(propertyDescriptorName)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .dynamic(true)
+ .build();
+ }
+
@Override
protected Collection<ValidationResult> customValidate(ValidationContext
validationContext) {
final List<ValidationResult> results = new
ArrayList<>(super.customValidate(validationContext));
@@ -187,6 +235,14 @@ public class PutAzureBlobStorage_v12 extends
AbstractAzureBlobProcessor_v12 impl
blobParallelUploadOptions.setParallelTransferOptions(parallelTransferOptions);
blobParallelUploadOptions.setRequestConditions(blobRequestConditions);
+ if (context.getProperty(BLOB_TAG_PREFIX).isSet()) {
+
blobParallelUploadOptions.setTags(getObjectTags(context, flowFile));
+ }
+ final Map<String, String> userMetadata =
getUserMetadata(context, flowFile);
+ if (!userMetadata.isEmpty()) {
+ blobParallelUploadOptions.setMetadata(userMetadata);
+ }
+
final String contentMd5 =
context.getProperty(CONTENT_MD5).evaluateAttributeExpressions(sourceFlowFile).getValue();
if (contentMd5 != null) {
final byte[] md5Bytes = convertMd5ToBytes(contentMd5);
@@ -201,6 +257,12 @@ public class PutAzureBlobStorage_v12 extends
AbstractAzureBlobProcessor_v12 impl
if (ignore) {
attributes.put(ATTR_NAME_IGNORED, "false");
}
+ final String userMetadataAttributeValue =
userMetadata.entrySet().stream()
+ .map(entry -> String.format("%s=%s",
entry.getKey(), entry.getValue()))
+ .collect(Collectors.joining("\n"));
+ if (StringUtils.isNotBlank(userMetadataAttributeValue)) {
+ attributes.put(ATTR_NAME_USER_METADATA,
userMetadataAttributeValue);
+ }
}
} catch (BlobStorageException e) {
final BlobErrorCode errorCode = e.getErrorCode();
@@ -245,4 +307,38 @@ public class PutAzureBlobStorage_v12 extends
AbstractAzureBlobProcessor_v12 impl
attributes.put(ATTR_NAME_LANG, null);
attributes.put(ATTR_NAME_MIME_TYPE, APPLICATION_OCTET_STREAM);
}
+
+ private Map<String, String> getObjectTags(ProcessContext context, FlowFile
flowFile) {
+ final String prefix =
context.getProperty(BLOB_TAG_PREFIX).evaluateAttributeExpressions(flowFile).getValue();
+ final Map<String, String> objectTags = new HashMap<>();
+ final Map<String, String> attributesMap = flowFile.getAttributes();
+
+ attributesMap.entrySet().stream()
+ .filter(attribute -> attribute.getKey().startsWith(prefix))
+ .forEach(attribute -> {
+ String tagKey = attribute.getKey();
+ String tagValue = attribute.getValue();
+
+ if (context.getProperty(REMOVE_TAG_PREFIX).asBoolean()) {
+ tagKey = tagKey.substring(prefix.length());
+ }
+ objectTags.put(tagKey, tagValue);
+ });
+
+ return objectTags;
+ }
+
+ private Map<String, String> getUserMetadata(ProcessContext context,
FlowFile flowFile) {
+ final Map<String, String> userMetadata = new HashMap<>();
+
+ for (final Map.Entry<PropertyDescriptor, String> entry :
context.getProperties().entrySet()) {
+ if (entry.getKey().isDynamic()) {
+ final String value = context.getProperty(
+
entry.getKey()).evaluateAttributeExpressions(flowFile).getValue();
+ userMetadata.put(entry.getKey().getName(), value);
+ }
+ }
+
+ return userMetadata;
+ }
}
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/utils/BlobAttributes.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/utils/BlobAttributes.java
index 7ce30c1da7c..7d64406f587 100644
---
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/utils/BlobAttributes.java
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/processors/azure/storage/utils/BlobAttributes.java
@@ -51,4 +51,8 @@ public final class BlobAttributes {
public static final String ATTR_NAME_IGNORED = "azure.ignored";
public static final String ATTR_DESCRIPTION_IGNORED = "When Conflict
Resolution Strategy is 'ignore', " +
"this property will be true/false depending on whether the blob
was ignored.";
+
+ public static final String ATTR_NAME_USER_METADATA = "azure.usermetadata";
+ public static final String ATTR_DESCRIPTION_USER_METADATA = "A
human-readable form of the User Metadata of " +
+ "the blob, if any was set";
}
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
index f7ef2f37070..de8b21e3b0d 100644
---
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
@@ -27,6 +27,8 @@
org.apache.nifi.processors.azure.storage.FetchAzureBlobStorage_v12
org.apache.nifi.processors.azure.storage.PutAzureBlobStorage_v12
org.apache.nifi.processors.azure.storage.DeleteAzureBlobStorage_v12
org.apache.nifi.processors.azure.storage.CopyAzureBlobStorage_v12
+org.apache.nifi.processors.azure.storage.FetchAzureBlobStorageMetadata
+org.apache.nifi.processors.azure.storage.FetchAzureBlobStorageTags
org.apache.nifi.processors.azure.storage.MoveAzureDataLakeStorage
org.apache.nifi.processors.azure.storage.queue.GetAzureQueueStorage_v12
org.apache.nifi.processors.azure.storage.queue.PutAzureQueueStorage_v12
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestFetchAzureBlobStorageMetadata.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestFetchAzureBlobStorageMetadata.java
new file mode 100644
index 00000000000..9453e98b742
--- /dev/null
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestFetchAzureBlobStorageMetadata.java
@@ -0,0 +1,271 @@
+/*
+ * 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.processors.azure.storage;
+
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobServiceClient;
+import com.azure.storage.blob.models.BlobErrorCode;
+import com.azure.storage.blob.models.BlobProperties;
+import com.azure.storage.blob.models.BlobStorageException;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.context.PropertyContext;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.provenance.ProvenanceEventRecord;
+import org.apache.nifi.provenance.ProvenanceEventType;
+import org.apache.nifi.util.MockFlowFile;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static
org.apache.nifi.processors.azure.storage.utils.AzureStorageUtils.BLOB_STORAGE_CREDENTIALS_SERVICE;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TestFetchAzureBlobStorageMetadata {
+
+ private static final String CONTAINER_NAME = "test-container";
+ private static final String BLOB_NAME = "test-blob";
+
+ private TestRunner runner;
+ private BlobServiceClient storageClient;
+ private BlobClient blobClient;
+ private BlobProperties blobProperties;
+
+ @BeforeEach
+ void setUp() {
+ storageClient = mock(BlobServiceClient.class);
+ final BlobContainerClient containerClient =
mock(BlobContainerClient.class);
+ blobClient = mock(BlobClient.class);
+ blobProperties = mock(BlobProperties.class);
+
+
when(storageClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(containerClient);
+ when(containerClient.getBlobClient(BLOB_NAME)).thenReturn(blobClient);
+ when(blobClient.getProperties()).thenReturn(blobProperties);
+
+ final FetchAzureBlobStorageMetadata processor = new
FetchAzureBlobStorageMetadata() {
+ @Override
+ protected BlobServiceClient getStorageClient(PropertyContext
context, FlowFile flowFile) {
+ return storageClient;
+ }
+
+ @Override
+ protected List<PropertyDescriptor>
getSupportedPropertyDescriptors() {
+ return super.getSupportedPropertyDescriptors().stream()
+ .filter(pd ->
!pd.equals(BLOB_STORAGE_CREDENTIALS_SERVICE))
+ .toList();
+ }
+ };
+
+ runner = TestRunners.newTestRunner(processor);
+
runner.setProperty(AbstractFetchAzureBlobStoragePropertiesProcessor.CONTAINER,
CONTAINER_NAME);
+
runner.setProperty(AbstractFetchAzureBlobStoragePropertiesProcessor.BLOB_NAME,
BLOB_NAME);
+ }
+
+ @Test
+ void testSuccessfulMetadataRetrieval() {
+ final Map<String, String> metadata = Map.of(
+ "author", "jane-doe",
+ "source-system", "erp",
+ "processing-date", "2024-01-15"
+ );
+ when(blobProperties.getMetadata()).thenReturn(metadata);
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+
+ assertEquals("jane-doe",
flowFile.getAttribute("azure.user.metadata.author"));
+ assertEquals("erp",
flowFile.getAttribute("azure.user.metadata.source-system"));
+ assertEquals("2024-01-15",
flowFile.getAttribute("azure.user.metadata.processing-date"));
+ }
+
+ @Test
+ void testEmptyMetadataRetrieval() {
+ when(blobProperties.getMetadata()).thenReturn(Map.of());
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+
+ flowFile.getAttributes().forEach((key, value) ->
+ assertFalse(key.startsWith("azure.user.metadata."),
+ "No metadata attributes should exist when blob has none,
found: " + key)
+ );
+ }
+
+ @Test
+ void testBlobNotFound() {
+ BlobStorageException exception =
mockBlobStorageException(BlobErrorCode.BLOB_NOT_FOUND);
+ when(blobClient.getProperties()).thenThrow(exception);
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_NOT_FOUND, 1);
+ }
+
+ @Test
+ void testOtherBlobStorageExceptionRoutesToFailure() {
+ BlobStorageException exception =
mockBlobStorageException(BlobErrorCode.AUTHORIZATION_FAILURE);
+ when(blobClient.getProperties()).thenThrow(exception);
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FAILURE,
1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FAILURE).getFirst();
+ assertTrue(flowFile.isPenalized(), "FlowFile should be penalized on
failure");
+ }
+
+ @Test
+ void testContainerAndBlobNameFromFlowFileAttributes() {
+
runner.setProperty(AbstractFetchAzureBlobStoragePropertiesProcessor.CONTAINER,
+ "${azure.container}");
+
runner.setProperty(AbstractFetchAzureBlobStoragePropertiesProcessor.BLOB_NAME,
+ "${azure.blobname}");
+
+ final String dynamicContainer = "other-container";
+ final String dynamicBlob = "other-blob";
+
+ final BlobContainerClient otherContainerClient =
mock(BlobContainerClient.class);
+ final BlobClient otherBlobClient = mock(BlobClient.class);
+ final BlobProperties otherBlobProperties = mock(BlobProperties.class);
+
+ when(storageClient.getBlobContainerClient(dynamicContainer))
+ .thenReturn(otherContainerClient);
+ when(otherContainerClient.getBlobClient(dynamicBlob))
+ .thenReturn(otherBlobClient);
+ when(otherBlobClient.getProperties()).thenReturn(otherBlobProperties);
+ when(otherBlobProperties.getMetadata()).thenReturn(Map.of("origin",
"external"));
+
+ runner.enqueue("", Map.of(
+ "azure.container", dynamicContainer,
+ "azure.blobname", dynamicBlob
+ ));
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+ assertEquals("external",
flowFile.getAttribute("azure.user.metadata.origin"));
+ }
+
+ @Test
+ void testProvenanceEventOnFound() {
+ when(blobProperties.getMetadata()).thenReturn(Map.of("key", "value"));
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final ProvenanceEventRecord modifyEvent =
runner.getProvenanceEvents().stream()
+ .filter(e -> e.getEventType() ==
ProvenanceEventType.ATTRIBUTES_MODIFIED)
+ .findFirst()
+ .orElse(null);
+ assertNotNull(modifyEvent, "Should have an ATTRIBUTES_MODIFIED
provenance event");
+ }
+
+ @Test
+ void testMetadataAttributePrefix() {
+ when(blobProperties.getMetadata()).thenReturn(Map.of("customKey",
"customValue"));
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+ assertEquals("customValue",
flowFile.getAttribute("azure.user.metadata.customKey"));
+ assertNull(flowFile.getAttribute("customKey"),
+ "Raw key should not appear without prefix");
+ }
+
+ @Test
+ void testMultipleFlowFiles() {
+ when(blobProperties.getMetadata())
+ .thenReturn(Map.of("seq", "1"))
+ .thenReturn(Map.of("seq", "2"));
+
+ runner.enqueue("");
+ runner.enqueue("");
+ runner.run(2);
+
+ assertEquals(2, runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).size());
+ }
+
+ @Test
+ void testMetadataWithManyEntries() {
+ final Map<String, String> largeMetadata = new HashMap<>();
+ for (int i = 0; i < 20; i++) {
+ largeMetadata.put("key" + i, "value" + i);
+ }
+ when(blobProperties.getMetadata()).thenReturn(largeMetadata);
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+
+ for (int i = 0; i < 20; i++) {
+ assertEquals("value" + i,
+ flowFile.getAttribute("azure.user.metadata.key" + i));
+ }
+ }
+
+ private static BlobStorageException mockBlobStorageException(BlobErrorCode
errorCode) {
+ final BlobStorageException exception =
mock(BlobStorageException.class);
+ when(exception.getErrorCode()).thenReturn(errorCode);
+ when(exception.getMessage()).thenReturn("Mocked: " + errorCode);
+ return exception;
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestFetchAzureBlobStorageTags.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestFetchAzureBlobStorageTags.java
new file mode 100644
index 00000000000..1c9a4a6b4ce
--- /dev/null
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestFetchAzureBlobStorageTags.java
@@ -0,0 +1,237 @@
+/*
+ * 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.processors.azure.storage;
+
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobServiceClient;
+import com.azure.storage.blob.models.BlobErrorCode;
+import com.azure.storage.blob.models.BlobStorageException;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.context.PropertyContext;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.provenance.ProvenanceEventRecord;
+import org.apache.nifi.provenance.ProvenanceEventType;
+import org.apache.nifi.util.MockFlowFile;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static
org.apache.nifi.processors.azure.storage.utils.AzureStorageUtils.BLOB_STORAGE_CREDENTIALS_SERVICE;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TestFetchAzureBlobStorageTags {
+
+ private static final String CONTAINER_NAME = "test-container";
+ private static final String BLOB_NAME = "test-blob";
+
+ private TestRunner runner;
+ private BlobServiceClient storageClient;
+ private BlobClient blobClient;
+
+ @BeforeEach
+ void setUp() {
+ storageClient = mock(BlobServiceClient.class);
+ final BlobContainerClient containerClient =
mock(BlobContainerClient.class);
+ blobClient = mock(BlobClient.class);
+
+
when(storageClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(containerClient);
+ when(containerClient.getBlobClient(BLOB_NAME)).thenReturn(blobClient);
+
+ final FetchAzureBlobStorageTags processor = new
FetchAzureBlobStorageTags() {
+ @Override
+ protected BlobServiceClient getStorageClient(PropertyContext
context, FlowFile flowFile) {
+ return storageClient;
+ }
+
+ @Override
+ protected List<PropertyDescriptor>
getSupportedPropertyDescriptors() {
+ return super.getSupportedPropertyDescriptors().stream()
+ .filter(pd ->
!pd.equals(BLOB_STORAGE_CREDENTIALS_SERVICE))
+ .toList();
+ }
+ };
+
+ runner = TestRunners.newTestRunner(processor);
+
runner.setProperty(AbstractFetchAzureBlobStoragePropertiesProcessor.CONTAINER,
CONTAINER_NAME);
+
runner.setProperty(AbstractFetchAzureBlobStoragePropertiesProcessor.BLOB_NAME,
BLOB_NAME);
+ }
+
+ @Test
+ void testSuccessfulTagRetrieval() {
+ final Map<String, String> tags = Map.of(
+ "environment", "production",
+ "department", "engineering",
+ "cost-center", "12345"
+ );
+ when(blobClient.getTags()).thenReturn(tags);
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+
+ assertEquals("production",
flowFile.getAttribute("azure.tag.environment"));
+ assertEquals("engineering",
flowFile.getAttribute("azure.tag.department"));
+ assertEquals("12345", flowFile.getAttribute("azure.tag.cost-center"));
+ }
+
+ @Test
+ void testEmptyTagsRetrieval() {
+ when(blobClient.getTags()).thenReturn(Map.of());
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+
+ flowFile.getAttributes().forEach((key, value) ->
+ assertFalse(key.startsWith("azure.tag."),
+ "No tag attributes should be set when blob has no tags,
found: " + key)
+ );
+ }
+
+ @Test
+ void testBlobNotFound() {
+ BlobStorageException exception =
mockBlobStorageException(BlobErrorCode.BLOB_NOT_FOUND);
+ when(blobClient.getTags()).thenThrow(exception);
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_NOT_FOUND, 1);
+ }
+
+ @Test
+ void testOtherBlobStorageExceptionRoutesToFailure() {
+ BlobStorageException exception =
mockBlobStorageException(BlobErrorCode.AUTHORIZATION_FAILURE);
+ when(blobClient.getTags()).thenThrow(exception);
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FAILURE,
1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FAILURE).getFirst();
+ assertTrue(flowFile.isPenalized(), "FlowFile should be penalized on
failure");
+ }
+
+ @Test
+ void testContainerAndBlobNameFromFlowFileAttributes() {
+
runner.setProperty(AbstractFetchAzureBlobStoragePropertiesProcessor.CONTAINER,
+ "${azure.container}");
+
runner.setProperty(AbstractFetchAzureBlobStoragePropertiesProcessor.BLOB_NAME,
+ "${azure.blobname}");
+
+ final String dynamicContainer = "dynamic-container";
+ final String dynamicBlob = "dynamic-blob";
+
+ final BlobContainerClient dynamicContainerClient =
mock(BlobContainerClient.class);
+ final BlobClient dynamicBlobClient = mock(BlobClient.class);
+ when(storageClient.getBlobContainerClient(dynamicContainer))
+ .thenReturn(dynamicContainerClient);
+ when(dynamicContainerClient.getBlobClient(dynamicBlob))
+ .thenReturn(dynamicBlobClient);
+ when(dynamicBlobClient.getTags()).thenReturn(Map.of("region",
"us-east"));
+
+ runner.enqueue("", Map.of(
+ "azure.container", dynamicContainer,
+ "azure.blobname", dynamicBlob
+ ));
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+ assertEquals("us-east", flowFile.getAttribute("azure.tag.region"));
+ }
+
+ @Test
+ void testProvenanceEventOnFound() {
+ when(blobClient.getTags()).thenReturn(Map.of("key", "value"));
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final ProvenanceEventRecord modifyEvent =
runner.getProvenanceEvents().stream()
+ .filter(e -> e.getEventType() ==
ProvenanceEventType.ATTRIBUTES_MODIFIED)
+ .findFirst()
+ .orElse(null);
+ assertNotNull(modifyEvent, "Should have an ATTRIBUTES_MODIFIED
provenance event");
+ }
+
+ @Test
+ void testTagAttributePrefix() {
+ when(blobClient.getTags()).thenReturn(Map.of("myKey", "myValue"));
+
+ runner.enqueue("");
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(
+ AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND, 1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).getFirst();
+ assertEquals("myValue", flowFile.getAttribute("azure.tag.myKey"));
+ }
+
+ @Test
+ void testMultipleFlowFiles() {
+ when(blobClient.getTags())
+ .thenReturn(Map.of("batch", "1"))
+ .thenReturn(Map.of("batch", "2"));
+
+ runner.enqueue("");
+ runner.enqueue("");
+ runner.run(2);
+
+ assertEquals(2, runner.getFlowFilesForRelationship(
+
AbstractFetchAzureBlobStoragePropertiesProcessor.REL_FOUND).size());
+ }
+
+ private static BlobStorageException mockBlobStorageException(BlobErrorCode
errorCode) {
+ final BlobStorageException exception =
mock(BlobStorageException.class);
+ when(exception.getErrorCode()).thenReturn(errorCode);
+ when(exception.getMessage()).thenReturn("Mocked: " + errorCode);
+ return exception;
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestPutAzureBlobStorage_v12.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestPutAzureBlobStorage_v12.java
index 74f78e66645..cf7cf8a23c5 100644
---
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestPutAzureBlobStorage_v12.java
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/processors/azure/storage/TestPutAzureBlobStorage_v12.java
@@ -16,19 +16,62 @@
*/
package org.apache.nifi.processors.azure.storage;
+import com.azure.core.http.rest.Response;
+import com.azure.core.util.Context;
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobServiceClient;
+import com.azure.storage.blob.models.BlobProperties;
+import com.azure.storage.blob.models.BlobType;
+import com.azure.storage.blob.models.BlockBlobItem;
+import com.azure.storage.blob.options.BlobParallelUploadOptions;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.context.PropertyContext;
+import org.apache.nifi.flowfile.FlowFile;
import org.apache.nifi.migration.ProxyServiceMigration;
import org.apache.nifi.processors.azure.AbstractAzureBlobProcessor_v12;
import org.apache.nifi.processors.azure.storage.utils.AzureStorageUtils;
+import org.apache.nifi.provenance.ProvenanceEventRecord;
+import org.apache.nifi.provenance.ProvenanceEventType;
+import org.apache.nifi.util.MockFlowFile;
import org.apache.nifi.util.PropertyMigrationResult;
import org.apache.nifi.util.TestRunner;
import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import java.time.OffsetDateTime;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+import static
org.apache.nifi.processors.azure.storage.utils.AzureStorageUtils.BLOB_STORAGE_CREDENTIALS_SERVICE;
+import static
org.apache.nifi.processors.azure.storage.utils.BlobAttributes.ATTR_NAME_USER_METADATA;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
public class TestPutAzureBlobStorage_v12 {
+
+ private static final String CONTAINER_NAME = "test-container";
+ private static final String BLOB_NAME = "test-blob";
+ private static final String TEST_CONTENT = "test content";
+ private static final String ETAG = "test-etag";
+ private static final String BLOB_URL =
"https://account.blob.core.windows.net/test-container/test-blob";
+
+ private TestRunner runner;
+ private BlobClient blobClient;
+ private ArgumentCaptor<BlobParallelUploadOptions> uploadOptionsCaptor;
+
@Test
void testMigration() {
TestRunner runner =
TestRunners.newTestRunner(PutAzureBlobStorage_v12.class);
@@ -43,4 +86,306 @@ public class TestPutAzureBlobStorage_v12 {
assertEquals(expectedRenamed,
propertyMigrationResult.getPropertiesRenamed());
}
+
+ @Nested
+ class PutAzureTests {
+
+ @BeforeEach
+ void setUp() {
+ final BlobServiceClient storageClient =
mock(BlobServiceClient.class);
+ final BlobContainerClient containerClient =
mock(BlobContainerClient.class);
+ blobClient = mock(BlobClient.class);
+ uploadOptionsCaptor =
ArgumentCaptor.forClass(BlobParallelUploadOptions.class);
+
+
when(storageClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(containerClient);
+
when(containerClient.getBlobClient(BLOB_NAME)).thenReturn(blobClient);
+ when(containerClient.exists()).thenReturn(true);
+ when(blobClient.getBlobUrl()).thenReturn(BLOB_URL);
+ when(blobClient.getContainerName()).thenReturn(CONTAINER_NAME);
+ when(blobClient.getBlobName()).thenReturn(BLOB_NAME);
+
+ final BlockBlobItem blockBlobItem = mock(BlockBlobItem.class);
+ when(blockBlobItem.getETag()).thenReturn(ETAG);
+
when(blockBlobItem.getLastModified()).thenReturn(OffsetDateTime.now());
+
+ final Response<BlockBlobItem> response = mock(Response.class);
+ when(response.getValue()).thenReturn(blockBlobItem);
+ when(blobClient.uploadWithResponse(
+ any(BlobParallelUploadOptions.class), isNull(),
any(Context.class)))
+ .thenReturn(response);
+
+ final BlobProperties blobProperties = mock(BlobProperties.class);
+ when(blobProperties.getBlobType()).thenReturn(BlobType.BLOCK_BLOB);
+ when(blobProperties.getETag()).thenReturn(ETAG);
+
when(blobProperties.getContentType()).thenReturn("application/octet-stream");
+
when(blobProperties.getLastModified()).thenReturn(OffsetDateTime.now());
+ when(blobProperties.getBlobSize()).thenReturn((long)
TEST_CONTENT.length());
+ when(blobProperties.getContentLanguage()).thenReturn(null);
+ when(blobClient.getProperties()).thenReturn(blobProperties);
+
+ final PutAzureBlobStorage_v12 processor = new
PutAzureBlobStorage_v12() {
+ @Override
+ protected BlobServiceClient getStorageClient(PropertyContext
context, FlowFile flowFile) {
+ return storageClient;
+ }
+
+ @Override
+ protected List<PropertyDescriptor>
getSupportedPropertyDescriptors() {
+ return super.getSupportedPropertyDescriptors().stream()
+ .filter(pd ->
!pd.equals(BLOB_STORAGE_CREDENTIALS_SERVICE))
+ .toList();
+ }
+ };
+
+ runner = TestRunners.newTestRunner(processor);
+ runner.setProperty(AbstractAzureBlobProcessor_v12.BLOB_NAME,
BLOB_NAME);
+ runner.setProperty("Container Name", CONTAINER_NAME);
+ }
+
+ @Test
+ void testTagsFromFlowFileAttributesWithPrefix() {
+ runner.setProperty(PutAzureBlobStorage_v12.BLOB_TAG_PREFIX,
"azure.tag.");
+
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("azure.tag.environment", "production");
+ attributes.put("azure.tag.team", "engineering");
+ attributes.put("other.attribute", "should-be-ignored");
+
+ runner.enqueue(TEST_CONTENT, attributes);
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ final Map<String, String> tags =
uploadOptionsCaptor.getValue().getTags();
+
+ assertNotNull(tags, "Tags should not be null when tag prefix is
set");
+ assertEquals(2, tags.size());
+ assertEquals("production", tags.get("azure.tag.environment"));
+ assertEquals("engineering", tags.get("azure.tag.team"));
+ }
+
+ @Test
+ void testTagsFromFlowFileAttributesWithPrefixRemoval() {
+ runner.setProperty(PutAzureBlobStorage_v12.BLOB_TAG_PREFIX,
"azure.tag.");
+ runner.setProperty(PutAzureBlobStorage_v12.REMOVE_TAG_PREFIX,
"true");
+
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("azure.tag.environment", "staging");
+ attributes.put("azure.tag.department", "finance");
+ attributes.put("unrelated.key", "unrelated-value");
+
+ runner.enqueue(TEST_CONTENT, attributes);
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ final Map<String, String> tags =
uploadOptionsCaptor.getValue().getTags();
+
+ assertNotNull(tags);
+ assertEquals(2, tags.size());
+ assertEquals("staging", tags.get("environment"));
+ assertEquals("finance", tags.get("department"));
+ }
+
+ @Test
+ void testNoTagsWhenPrefixNotSet() {
+ runner.enqueue(TEST_CONTENT, Map.of("azure.tag.something",
"value"));
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ assertNull(uploadOptionsCaptor.getValue().getTags(),
+ "Tags should be null when tag prefix property is not set");
+ }
+
+ @Test
+ void testNoMatchingTagAttributes() {
+ runner.setProperty(PutAzureBlobStorage_v12.BLOB_TAG_PREFIX,
"azure.tag.");
+
+ runner.enqueue(TEST_CONTENT, Map.of("other.key", "other-value"));
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ final Map<String, String> tags =
uploadOptionsCaptor.getValue().getTags();
+
+ assertNotNull(tags);
+ assertTrue(tags.isEmpty(),
+ "Tags should be empty when no attributes match the
prefix");
+ }
+
+ @Test
+ void testTagPrefixWithExpressionLanguage() {
+ runner.setProperty(PutAzureBlobStorage_v12.BLOB_TAG_PREFIX,
"${tag.prefix}");
+
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("tag.prefix", "custom.");
+ attributes.put("custom.region", "us-east-1");
+ attributes.put("custom.tier", "standard");
+
+ runner.enqueue(TEST_CONTENT, attributes);
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ final Map<String, String> tags =
uploadOptionsCaptor.getValue().getTags();
+
+ assertNotNull(tags);
+ assertEquals(2, tags.size());
+ assertEquals("us-east-1", tags.get("custom.region"));
+ assertEquals("standard", tags.get("custom.tier"));
+ }
+
+ @Test
+ void testRemoveTagPrefixDefaultsToFalse() {
+ runner.setProperty(PutAzureBlobStorage_v12.BLOB_TAG_PREFIX,
"pfx.");
+
+ runner.enqueue(TEST_CONTENT, Map.of("pfx.key", "val"));
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ final Map<String, String> tags =
uploadOptionsCaptor.getValue().getTags();
+
+ assertNotNull(tags);
+ assertTrue(tags.containsKey("pfx.key"),
+ "Full attribute name should be the tag key when prefix
removal is off");
+ assertFalse(tags.containsKey("key"),
+ "Stripped key should not appear when prefix removal is
off");
+ }
+
+ @Test
+ void testUserMetadataFromDynamicProperties() {
+ runner.setProperty("x-custom-header", "header-value");
+ runner.setProperty("department", "engineering");
+
+ runner.enqueue(TEST_CONTENT);
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ final Map<String, String> metadata =
+ uploadOptionsCaptor.getValue().getMetadata();
+
+ assertNotNull(metadata);
+ assertEquals(2, metadata.size());
+ assertEquals("header-value", metadata.get("x-custom-header"));
+ assertEquals("engineering", metadata.get("department"));
+ }
+
+ @Test
+ void testUserMetadataWithExpressionLanguage() {
+ runner.setProperty("source-system", "${system.name}");
+
+ runner.enqueue(TEST_CONTENT, Map.of("system.name", "crm-export"));
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ final Map<String, String> metadata =
+ uploadOptionsCaptor.getValue().getMetadata();
+
+ assertNotNull(metadata);
+ assertEquals("crm-export", metadata.get("source-system"));
+ }
+
+ @Test
+ void testNoUserMetadataWhenNoDynamicProperties() {
+ runner.enqueue(TEST_CONTENT);
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ assertNull(uploadOptionsCaptor.getValue().getMetadata(),
+ "Metadata should not be set when no dynamic properties
exist");
+ }
+
+ @Test
+ void testUserMetadataAttributeOnSuccessFlowFile() {
+ runner.setProperty("project", "alpha");
+ runner.setProperty("owner", "team-a");
+
+ runner.enqueue(TEST_CONTENT);
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+ AbstractAzureBlobProcessor_v12.REL_SUCCESS).getFirst();
+ final String attr = flowFile.getAttribute(ATTR_NAME_USER_METADATA);
+
+ assertNotNull(attr, "User metadata attribute should be present");
+ assertTrue(attr.contains("project=alpha"));
+ assertTrue(attr.contains("owner=team-a"));
+ }
+
+ @Test
+ void testNoUserMetadataAttributeWhenNoDynamicProperties() {
+ runner.enqueue(TEST_CONTENT);
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+ AbstractAzureBlobProcessor_v12.REL_SUCCESS).getFirst();
+ assertNull(flowFile.getAttribute(ATTR_NAME_USER_METADATA),
+ "User metadata attribute should be absent when no dynamic
properties exist");
+ }
+
+ @Test
+ void testBothTagsAndMetadataSet() {
+ runner.setProperty(PutAzureBlobStorage_v12.BLOB_TAG_PREFIX,
"tag.");
+ runner.setProperty(PutAzureBlobStorage_v12.REMOVE_TAG_PREFIX,
"true");
+ runner.setProperty("meta-key", "meta-value");
+
+ runner.enqueue(TEST_CONTENT, Map.of("tag.env", "prod"));
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ verify(blobClient).uploadWithResponse(
+ uploadOptionsCaptor.capture(), isNull(),
any(Context.class));
+ final BlobParallelUploadOptions options =
uploadOptionsCaptor.getValue();
+
+ assertEquals("prod", options.getTags().get("env"));
+ assertEquals("meta-value", options.getMetadata().get("meta-key"));
+
+ final MockFlowFile flowFile = runner.getFlowFilesForRelationship(
+ AbstractAzureBlobProcessor_v12.REL_SUCCESS).getFirst();
+ assertNotNull(flowFile.getAttribute(ATTR_NAME_USER_METADATA));
+ }
+
+ @Test
+ void testProvenanceEventOnSuccess() {
+ runner.enqueue(TEST_CONTENT);
+ runner.run();
+
+
runner.assertAllFlowFilesTransferred(AbstractAzureBlobProcessor_v12.REL_SUCCESS,
1);
+
+ final ProvenanceEventRecord sendEvent =
runner.getProvenanceEvents().stream()
+ .filter(e -> e.getEventType() == ProvenanceEventType.SEND)
+ .findFirst()
+ .orElse(null);
+ assertNotNull(sendEvent, "Should have a SEND provenance event");
+ assertTrue(sendEvent.getTransitUri().contains(BLOB_NAME));
+ }
+ }
}